From de31c7c18e17030cbd0359fbe1da41e243f3625e Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Mon, 15 Jun 2026 18:12:55 -0400 Subject: [PATCH] feat: add TemplateBuilderCreateTemplate SDK types and client method (#26360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `POST /api/v2/templatebuilder/compose/template`, a synchronous endpoint that composes a template from a base and modules, validates it via a provisioner import job, and creates the template in a single request. The handler composes terraform files, bundles them as a tar, inserts the file with hash-based dedup, creates a template version with an import job, waits up to 2 minutes for the job to complete, classifies errors for known failure modes (network-unreachable registry, DNS failures), then creates the template on success. Canceled and failed jobs return appropriate error responses. Also adds `hclwrite.Format` to composed terraform output for canonical HCL formatting. Closes https://linear.app/codercom/issue/DEVEX-279
Implementation notes - SDK types and client method in `codersdk/templatebuilder.go` with validation tags matching the standard template creation path (`template_display_name`, `lt=128`) - `ClassifyProvisionerError` in `coderd/templatebuilder/errors.go` detects DNS, connection refused, i/o timeout, and TLS handshake failures and returns actionable messages - `waitForProvisionerJob` polls with a ramp-up interval schedule (100ms, 200ms, 500ms, then 1s steady) and accepts an `onUpdate` callback for future SSE streaming - Audit logging for both template and template version creation - TOCTOU name uniqueness: early check for fast feedback, DB unique constraint catch for the race window (returns 409, not 500) - Swagger annotations for all error responses (400, 404, 409, 504)
> 🤖 Generated by Coder Agents --- coderd/apidoc/docs.go | 111 ++++++ coderd/apidoc/swagger.json | 102 ++++++ coderd/coderd.go | 1 + coderd/templatebuilder/compose.go | 16 +- coderd/templatebuilder/compose_test.go | 2 +- coderd/templatebuilder/errors.go | 32 ++ coderd/templatebuilder/errors_test.go | 72 ++++ coderd/templatebuilder_handler.go | 451 +++++++++++++++++++++++++ codersdk/templatebuilder.go | 36 ++ docs/reference/api/schemas.md | 108 ++++++ docs/reference/api/templatebuilder.md | 122 +++++++ site/src/api/typesGenerated.ts | 25 ++ 12 files changed, 1074 insertions(+), 4 deletions(-) create mode 100644 coderd/templatebuilder/errors.go create mode 100644 coderd/templatebuilder/errors_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 78f7af39c0..7de4c46c91 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7394,6 +7394,69 @@ const docTemplate = `{ ] } }, + "/api/v2/templatebuilder/compose/template": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TemplateBuilder" + ], + "summary": "Compose and create a template", + "operationId": "compose-and-create-a-template", + "parameters": [ + { + "description": "Create template request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.TemplateBuilderCreateTemplateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.TemplateBuilderCreateTemplateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "504": { + "description": "Gateway Timeout", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/templatebuilder/modules": { "get": { "produces": [ @@ -23761,6 +23824,54 @@ const docTemplate = `{ } } }, + "codersdk.TemplateBuilderCreateTemplateRequest": { + "type": "object", + "required": [ + "name", + "organization_id" + ], + "properties": { + "base_template_id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateBuilderComposeModule" + } + }, + "name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "provisioner_tags": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "codersdk.TemplateBuilderCreateTemplateResponse": { + "type": "object", + "properties": { + "template": { + "$ref": "#/definitions/codersdk.Template" + } + } + }, "codersdk.TemplateBuilderModule": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 10677d22b6..b7887f101c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6557,6 +6557,63 @@ ] } }, + "/api/v2/templatebuilder/compose/template": { + "post": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["TemplateBuilder"], + "summary": "Compose and create a template", + "operationId": "compose-and-create-a-template", + "parameters": [ + { + "description": "Create template request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.TemplateBuilderCreateTemplateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.TemplateBuilderCreateTemplateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "504": { + "description": "Gateway Timeout", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/templatebuilder/modules": { "get": { "produces": ["application/json"], @@ -21817,6 +21874,51 @@ } } }, + "codersdk.TemplateBuilderCreateTemplateRequest": { + "type": "object", + "required": ["name", "organization_id"], + "properties": { + "base_template_id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateBuilderComposeModule" + } + }, + "name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "provisioner_tags": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "codersdk.TemplateBuilderCreateTemplateResponse": { + "type": "object", + "properties": { + "template": { + "$ref": "#/definitions/codersdk.Template" + } + } + }, "codersdk.TemplateBuilderModule": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index d492dfc960..3af81d65df 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1623,6 +1623,7 @@ func New(options *Options) *API { r.Get("/bases", api.templateBuilderBases) r.Get("/modules", api.templateBuilderModules) r.Post("/compose", api.templateBuilderCompose) + r.Post("/compose/template", api.templateBuilderCreateTemplate) }) } diff --git a/coderd/templatebuilder/compose.go b/coderd/templatebuilder/compose.go index b56ab5e7e6..e3af4db4a4 100644 --- a/coderd/templatebuilder/compose.go +++ b/coderd/templatebuilder/compose.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/hashicorp/hcl/v2/hclwrite" "golang.org/x/xerrors" ) @@ -54,7 +55,7 @@ func Compose(req ComposeRequest) (*ComposeResult, error) { } if len(req.Modules) == 0 { - return &ComposeResult{MainTF: mainTF}, nil + return &ComposeResult{MainTF: formatHCL(mainTF)}, nil } agentName, err := ExtractAgentResourceName(mainTF) @@ -78,11 +79,20 @@ func Compose(req ComposeRequest) (*ComposeResult, error) { } return &ComposeResult{ - MainTF: mainTF, - ModulesTF: modulesTF, + MainTF: formatHCL(mainTF), + ModulesTF: formatHCL(modulesTF), }, nil } +// formatHCL applies canonical HCL formatting to src. If src is not valid +// HCL the input is returned unchanged. +func formatHCL(src []byte) []byte { + if len(src) == 0 { + return src + } + return hclwrite.Format(src) +} + // renderBase renders the base template for the given example ID. func renderBase(baseTemplateID string) ([]byte, error) { renderCtx := DefaultBaseRenderContext(baseTemplateID) diff --git a/coderd/templatebuilder/compose_test.go b/coderd/templatebuilder/compose_test.go index 04690d4157..cbacf73657 100644 --- a/coderd/templatebuilder/compose_test.go +++ b/coderd/templatebuilder/compose_test.go @@ -49,7 +49,7 @@ func TestCompose(t *testing.T) { require.Contains(t, modules, `module "code-server"`) require.Contains(t, modules, `coder_agent.main.id`) require.Contains(t, modules, `registry.coder.com`) - require.Contains(t, modules, `port = 9999`) + require.Regexp(t, `port\s+=\s+9999`, modules) }) t.Run("AWSLinuxAgentName", func(t *testing.T) { diff --git a/coderd/templatebuilder/errors.go b/coderd/templatebuilder/errors.go new file mode 100644 index 0000000000..e8996cde9d --- /dev/null +++ b/coderd/templatebuilder/errors.go @@ -0,0 +1,32 @@ +package templatebuilder + +import "strings" + +// networkErrorPatterns are substrings found in provisioner job output when +// the Terraform registry or provider endpoints are unreachable. +var networkErrorPatterns = []string{ + "no such host", + "connection refused", + "i/o timeout", + "dial tcp: lookup", + "network is unreachable", + "no route to host", + "TLS handshake timeout", +} + +// ClassifyProvisionerError inspects a provisioner job error and its log +// lines, returning a user-friendly message for known failure modes. +// If the error is not recognized, the raw jobError is returned unchanged. +func ClassifyProvisionerError(jobError string, logs []string) string { + combined := jobError + "\n" + strings.Join(logs, "\n") + + for _, pattern := range networkErrorPatterns { + if strings.Contains(combined, pattern) { + return "The Terraform registry is unreachable from your provisioner. " + + "Check network configuration and ensure registry.terraform.io " + + "is accessible." + } + } + + return jobError +} diff --git a/coderd/templatebuilder/errors_test.go b/coderd/templatebuilder/errors_test.go new file mode 100644 index 0000000000..053ece995f --- /dev/null +++ b/coderd/templatebuilder/errors_test.go @@ -0,0 +1,72 @@ +package templatebuilder_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/templatebuilder" +) + +func TestClassifyProvisionerError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + jobError string + logs []string + contains string + exact bool + }{ + { + name: "DNSFailure", + jobError: "init failed", + logs: []string{"Error: Failed to query available provider packages", "dial tcp: lookup registry.terraform.io: no such host"}, + contains: "unreachable from your provisioner", + }, + { + name: "ConnectionRefused", + jobError: "terraform init: connection refused", + logs: nil, + contains: "unreachable from your provisioner", + }, + { + name: "IOTimeout", + jobError: "context deadline exceeded", + logs: []string{"dial tcp 1.2.3.4:443: i/o timeout"}, + contains: "unreachable from your provisioner", + }, + { + name: "TLSTimeout", + jobError: "init error", + logs: []string{"net/http: TLS handshake timeout"}, + contains: "unreachable from your provisioner", + }, + { + name: "UnknownError", + jobError: "Error: Unsupported block type", + logs: []string{"on main.tf line 5"}, + contains: "Unsupported block type", + exact: true, + }, + { + name: "EmptyErrorPassthrough", + jobError: "", + logs: nil, + contains: "", + exact: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := templatebuilder.ClassifyProvisionerError(tc.jobError, tc.logs) + if tc.exact { + require.Equal(t, tc.jobError, result) + } else { + require.Contains(t, result, tc.contains) + } + }) + } +} diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index 1074f8e05d..b0c23fe0e2 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -1,16 +1,38 @@ package coderd import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" "net/http" "sort" + "time" + + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "golang.org/x/xerrors" "cdr.dev/slog/v3" + "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/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/templatebuilder" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/coderd/util/namesgenerator" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/examples" + "github.com/coder/coder/v2/provisionersdk" ) // @Summary List template builder base templates @@ -181,3 +203,432 @@ func (api *API) templateBuilderCompose(rw http.ResponseWriter, r *http.Request) rw.WriteHeader(http.StatusOK) _, _ = rw.Write(tarData) } + +// templateBuilderCreateTemplateTimeout is the maximum time the handler waits +// for the provisioner import job to complete. +const templateBuilderCreateTemplateTimeout = 2 * time.Minute + +// @Summary Compose and create a template +// @ID compose-and-create-a-template +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags TemplateBuilder +// @Param request body codersdk.TemplateBuilderCreateTemplateRequest true "Create template request" +// @Success 201 {object} codersdk.TemplateBuilderCreateTemplateResponse +// @Failure 400 {object} codersdk.Response +// @Failure 404 {object} codersdk.Response +// @Failure 409 {object} codersdk.Response +// @Failure 504 {object} codersdk.Response +// @Router /api/v2/templatebuilder/compose/template [post] +func (api *API) templateBuilderCreateTemplate(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + var req codersdk.TemplateBuilderCreateTemplateRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if req.BaseTemplateID == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Missing base_template_id.", + }) + return + } + + // Resolve and authorize against the organization. + organization, err := api.Database.GetOrganizationByID(ctx, req.OrganizationID) + if httpapi.Is404Error(err) { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Organization not found.", + }) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching organization.", + Detail: err.Error(), + }) + return + } + + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceTemplate.InOrg(organization.ID)) { + httpapi.ResourceNotFound(rw) + return + } + + // Check template name uniqueness early. + _, err = api.Database.GetTemplateByOrganizationAndName(ctx, database.GetTemplateByOrganizationAndNameParams{ + OrganizationID: organization.ID, + Name: req.Name, + Deleted: false, + }) + if err == nil { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "A template with this name already exists in the organization.", + }) + return + } + if !xerrors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error checking template name.", + Detail: err.Error(), + }) + return + } + + // Compose the template. + composeReq := templatebuilder.ComposeRequest{ + BaseTemplateID: req.BaseTemplateID, + RegistryURL: api.DeploymentValues.TemplateBuilder.RegistryURL.String(), + } + for _, m := range req.Modules { + composeReq.Modules = append(composeReq.Modules, templatebuilder.ComposeModule{ + ID: m.ID, + Variables: m.Variables, + }) + } + + result, err := templatebuilder.Compose(composeReq) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to compose template.", + Detail: err.Error(), + }) + return + } + + tarData, err := templatebuilder.BundleTar(result) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error bundling template.", + Detail: err.Error(), + }) + return + } + + // Insert the tar as a file with hash-based dedup. + hashBytes := sha256.Sum256(tarData) + hash := hex.EncodeToString(hashBytes[:]) + + file, err := api.Database.GetFileByHashAndCreator(ctx, database.GetFileByHashAndCreatorParams{ + Hash: hash, + CreatedBy: apiKey.UserID, + }) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error checking file.", + Detail: err.Error(), + }) + return + } + if xerrors.Is(err, sql.ErrNoRows) { + file, err = api.Database.InsertFile(ctx, database.InsertFileParams{ + ID: uuid.New(), + Hash: hash, + CreatedAt: dbtime.Now(), + CreatedBy: apiKey.UserID, + Mimetype: codersdk.ContentTypeTar, + Data: tarData, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error saving file.", + Detail: err.Error(), + }) + return + } + } + + tags := provisionersdk.MutateTags(apiKey.UserID, nil, req.ProvisionerTags) + traceMetadataRaw, err := json.Marshal(tracing.MetadataFromContext(ctx)) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error marshaling trace metadata.", + Detail: err.Error(), + }) + return + } + + // Create template version and provisioner import job. + var ( + provisionerJob database.ProvisionerJob + templateVersion database.TemplateVersion + ) + err = api.Database.InTx(func(tx database.Store) error { + jobID := uuid.New() + templateVersionID := uuid.New() + + jobInput, err := json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: templateVersionID, + }) + if err != nil { + return xerrors.Errorf("marshal job input: %w", err) + } + + provisionerJob, err = tx.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: jobID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + OrganizationID: organization.ID, + InitiatorID: apiKey.UserID, + Provisioner: database.ProvisionerTypeTerraform, + StorageMethod: database.ProvisionerStorageMethodFile, + FileID: file.ID, + Type: database.ProvisionerJobTypeTemplateVersionImport, + Input: jobInput, + Tags: tags, + TraceMetadata: pqtype.NullRawMessage{ + Valid: true, + RawMessage: traceMetadataRaw, + }, + LogsOverflowed: false, + }) + if err != nil { + return xerrors.Errorf("insert provisioner job: %w", err) + } + + versionName := namesgenerator.NameDigitWith("_") + err = tx.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ + ID: templateVersionID, + TemplateID: uuid.NullUUID{}, + OrganizationID: organization.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Name: versionName, + Message: "", + Readme: "", + JobID: provisionerJob.ID, + CreatedBy: apiKey.UserID, + SourceExampleID: sql.NullString{}, + }) + if err != nil { + return xerrors.Errorf("insert template version: %w", err) + } + + templateVersion, err = tx.GetTemplateVersionByID(ctx, templateVersionID) + if err != nil { + return xerrors.Errorf("get template version: %w", err) + } + + return nil + }, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating template version.", + Detail: err.Error(), + }) + return + } + + // Notify provisioner of the new job. + err = provisionerjobs.PostJob(api.Pubsub, provisionerJob) + if err != nil { + api.Logger.Error(ctx, "failed to post provisioner job", + slog.F("job_id", provisionerJob.ID), + slog.Error(err)) + } + + // Wait for the import job to complete. + jobCtx, jobCancel := context.WithTimeout(ctx, templateBuilderCreateTemplateTimeout) + defer jobCancel() + + completedJob, err := api.waitForProvisionerJob(jobCtx, provisionerJob.ID, nil) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + httpapi.Write(ctx, rw, http.StatusGatewayTimeout, codersdk.Response{ + Message: "Timed out waiting for template import to complete.", + Detail: "The template version is still being imported. You can check its status manually.", + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error waiting for template import.", + Detail: err.Error(), + }) + return + } + + // Check if the job was canceled. + if completedJob.CanceledAt.Valid { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Template import was canceled.", + }) + return + } + + // Check if the job failed. + if completedJob.Error.Valid { + // Fetch logs to help classify the error. + jobLogs, logErr := api.Database.GetProvisionerLogsAfterID(ctx, database.GetProvisionerLogsAfterIDParams{ + JobID: completedJob.ID, + CreatedAfter: 0, + }) + var logLines []string + if logErr == nil { + for _, l := range jobLogs { + logLines = append(logLines, l.Output) + } + } + + classified := templatebuilder.ClassifyProvisionerError(completedJob.Error.String, logLines) + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Template import failed.", + Detail: classified, + }) + return + } + + // Audit logging for template and template version creation. + var ( + auditor = *api.Auditor.Load() + templateAudit, commitTemplateAudit = audit.InitRequest[database.Template](rw, &audit.RequestParams{ + Audit: auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + OrganizationID: organization.ID, + }) + templateVersionAudit, commitTemplateVersionAudit = audit.InitRequest[database.TemplateVersion](rw, &audit.RequestParams{ + Audit: auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + OrganizationID: organization.ID, + }) + ) + defer commitTemplateAudit() + defer commitTemplateVersionAudit() + + // Import succeeded. Create the template. + defaultGroups := database.TemplateACL{ + organization.ID.String(): db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse), + } + + var dbTemplate database.Template + err = api.Database.InTx(func(tx database.Store) error { + now := dbtime.Now() + templateID := uuid.New() + + err = tx.InsertTemplate(ctx, database.InsertTemplateParams{ + ID: templateID, + CreatedAt: now, + UpdatedAt: now, + OrganizationID: organization.ID, + Name: req.Name, + Provisioner: database.ProvisionerTypeTerraform, + ActiveVersionID: templateVersion.ID, + Description: req.Description, + CreatedBy: apiKey.UserID, + UserACL: database.TemplateACL{}, + GroupACL: defaultGroups, + DisplayName: req.DisplayName, + Icon: req.Icon, + AllowUserCancelWorkspaceJobs: false, + MaxPortSharingLevel: database.AppSharingLevelOwner, + UseClassicParameterFlow: false, + CorsBehavior: database.CorsBehaviorSimple, + }) + if err != nil { + if database.IsUniqueViolation(err, database.UniqueTemplatesOrganizationIDNameIndex) { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "A template with this name already exists in the organization.", + }) + return nil + } + return xerrors.Errorf("insert template: %w", err) + } + + dbTemplate, err = tx.GetTemplateByID(ctx, templateID) + if err != nil { + return xerrors.Errorf("get template: %w", err) + } + + dbTemplate, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{ + UserAutostartEnabled: true, + UserAutostopEnabled: true, + }) + if err != nil { + return xerrors.Errorf("set template schedule options: %w", err) + } + + err = tx.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ + ID: templateVersion.ID, + TemplateID: uuid.NullUUID{ + UUID: dbTemplate.ID, + Valid: true, + }, + UpdatedAt: dbtime.Now(), + Name: templateVersion.Name, + Message: templateVersion.Message, + }) + if err != nil { + return xerrors.Errorf("link template version to template: %w", err) + } + + templateAudit.New = dbTemplate + newTemplateVersion := templateVersion + newTemplateVersion.TemplateID = uuid.NullUUID{ + UUID: dbTemplate.ID, + Valid: true, + } + templateVersionAudit.New = newTemplateVersion + + return nil + }, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating template.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusCreated, codersdk.TemplateBuilderCreateTemplateResponse{ + Template: api.convertTemplate(dbTemplate), + }) +} + +// waitForProvisionerJob polls until the job completes or the context expires. +// If onUpdate is non-nil, it is called after each poll with the latest job state. +func (api *API) waitForProvisionerJob( + ctx context.Context, + jobID uuid.UUID, + onUpdate func(database.ProvisionerJob), +) (database.ProvisionerJob, error) { + initialIntervals := []time.Duration{ + 100 * time.Millisecond, + 200 * time.Millisecond, + 500 * time.Millisecond, + } + const steadyInterval = time.Second + + for i := 0; ; i++ { + var delay time.Duration + if i < len(initialIntervals) { + delay = initialIntervals[i] + } else { + delay = steadyInterval + } + + select { + case <-ctx.Done(): + return database.ProvisionerJob{}, ctx.Err() + case <-time.After(delay): + } + + job, err := api.Database.GetProvisionerJobByID(ctx, jobID) + if err != nil { + return database.ProvisionerJob{}, xerrors.Errorf("get provisioner job: %w", err) + } + + if onUpdate != nil { + onUpdate(job) + } + + if job.CompletedAt.Valid { + return job, nil + } + } +} diff --git a/codersdk/templatebuilder.go b/codersdk/templatebuilder.go index 098250fd5d..90e7068520 100644 --- a/codersdk/templatebuilder.go +++ b/codersdk/templatebuilder.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "net/url" + + "github.com/google/uuid" ) // TemplateBuilderVariableType enumerates the variable types @@ -124,3 +126,37 @@ func (c *Client) TemplateBuilderCompose(ctx context.Context, req TemplateBuilder } return io.ReadAll(res.Body) } + +// TemplateBuilderCreateTemplateRequest is the request body for +// POST /api/v2/templatebuilder/compose/template. +type TemplateBuilderCreateTemplateRequest struct { + BaseTemplateID string `json:"base_template_id"` + Modules []TemplateBuilderComposeModule `json:"modules"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid" validate:"required"` + Name string `json:"name" validate:"required,template_name"` + DisplayName string `json:"display_name,omitempty" validate:"template_display_name"` + Description string `json:"description,omitempty" validate:"lt=128"` + Icon string `json:"icon,omitempty"` + ProvisionerTags map[string]string `json:"provisioner_tags,omitempty"` +} + +// TemplateBuilderCreateTemplateResponse is the response body for +// POST /api/v2/templatebuilder/compose/template. +type TemplateBuilderCreateTemplateResponse struct { + Template Template `json:"template"` +} + +// TemplateBuilderCreateTemplate composes a template from a base and modules, +// validates it via a provisioner import job, and creates the template. +func (c *Client) TemplateBuilderCreateTemplate(ctx context.Context, req TemplateBuilderCreateTemplateRequest) (TemplateBuilderCreateTemplateResponse, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/templatebuilder/compose/template", req) + if err != nil { + return TemplateBuilderCreateTemplateResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return TemplateBuilderCreateTemplateResponse{}, ReadBodyAsError(res) + } + var resp TemplateBuilderCreateTemplateResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 28e6971423..4999854660 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12150,6 +12150,114 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `disabled` | boolean | false | | | | `registry_url` | string | false | | | +## codersdk.TemplateBuilderCreateTemplateRequest + +```json +{ + "base_template_id": "string", + "description": "string", + "display_name": "string", + "icon": "string", + "modules": [ + { + "id": "string", + "variables": { + "property1": "string", + "property2": "string" + } + } + ], + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "provisioner_tags": { + "property1": "string", + "property2": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|-----------------------------------------------------------------------------------------|----------|--------------|-------------| +| `base_template_id` | string | false | | | +| `description` | string | false | | | +| `display_name` | string | false | | | +| `icon` | string | false | | | +| `modules` | array of [codersdk.TemplateBuilderComposeModule](#codersdktemplatebuildercomposemodule) | false | | | +| `name` | string | true | | | +| `organization_id` | string | true | | | +| `provisioner_tags` | object | false | | | +| » `[any property]` | string | false | | | + +## codersdk.TemplateBuilderCreateTemplateResponse + +```json +{ + "template": { + "active_user_count": 0, + "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", + "activity_bump_ms": 0, + "allow_user_autostart": true, + "allow_user_autostop": true, + "allow_user_cancel_workspace_jobs": true, + "autostart_requirement": { + "days_of_week": [ + "monday" + ] + }, + "autostop_requirement": { + "days_of_week": [ + "monday" + ], + "weeks": 0 + }, + "build_time_stats": { + "property1": { + "p50": 123, + "p95": 146 + }, + "property2": { + "p50": 123, + "p95": 146 + } + }, + "cors_behavior": "simple", + "created_at": "2019-08-24T14:15:22Z", + "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", + "created_by_name": "string", + "default_ttl_ms": 0, + "deleted": true, + "deprecated": true, + "deprecation_message": "string", + "description": "string", + "disable_module_cache": true, + "display_name": "string", + "failure_ttl_ms": 0, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_port_share_level": "owner", + "name": "string", + "organization_display_name": "string", + "organization_icon": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "provisioner": "terraform", + "require_active_version": true, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, + "updated_at": "2019-08-24T14:15:22Z", + "use_classic_parameter_flow": true + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------|----------------------------------------|----------|--------------|-------------| +| `template` | [codersdk.Template](#codersdktemplate) | false | | | + ## codersdk.TemplateBuilderModule ```json diff --git a/docs/reference/api/templatebuilder.md b/docs/reference/api/templatebuilder.md index 46b30d77c2..8c55750264 100644 --- a/docs/reference/api/templatebuilder.md +++ b/docs/reference/api/templatebuilder.md @@ -83,6 +83,128 @@ curl -X POST http://coder-server:8080/api/v2/templatebuilder/compose \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Compose and create a template + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/templatebuilder/compose/template \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /api/v2/templatebuilder/compose/template` + +> Body parameter + +```json +{ + "base_template_id": "string", + "description": "string", + "display_name": "string", + "icon": "string", + "modules": [ + { + "id": "string", + "variables": { + "property1": "string", + "property2": "string" + } + } + ], + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "provisioner_tags": { + "property1": "string", + "property2": "string" + } +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|----------------------------------------------------------------------------------------------------------|----------|-------------------------| +| `body` | body | [codersdk.TemplateBuilderCreateTemplateRequest](schemas.md#codersdktemplatebuildercreatetemplaterequest) | true | Create template request | + +### Example responses + +> 201 Response + +```json +{ + "template": { + "active_user_count": 0, + "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", + "activity_bump_ms": 0, + "allow_user_autostart": true, + "allow_user_autostop": true, + "allow_user_cancel_workspace_jobs": true, + "autostart_requirement": { + "days_of_week": [ + "monday" + ] + }, + "autostop_requirement": { + "days_of_week": [ + "monday" + ], + "weeks": 0 + }, + "build_time_stats": { + "property1": { + "p50": 123, + "p95": 146 + }, + "property2": { + "p50": 123, + "p95": 146 + } + }, + "cors_behavior": "simple", + "created_at": "2019-08-24T14:15:22Z", + "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", + "created_by_name": "string", + "default_ttl_ms": 0, + "deleted": true, + "deprecated": true, + "deprecation_message": "string", + "description": "string", + "disable_module_cache": true, + "display_name": "string", + "failure_ttl_ms": 0, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_port_share_level": "owner", + "name": "string", + "organization_display_name": "string", + "organization_icon": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "provisioner": "terraform", + "require_active_version": true, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, + "updated_at": "2019-08-24T14:15:22Z", + "use_classic_parameter_flow": true + } +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------------|-----------------|------------------------------------------------------------------------------------------------------------| +| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.TemplateBuilderCreateTemplateResponse](schemas.md#codersdktemplatebuildercreatetemplateresponse) | +| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) | +| 404 | [Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4) | Not Found | [codersdk.Response](schemas.md#codersdkresponse) | +| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) | +| 504 | [Gateway Time-out](https://tools.ietf.org/html/rfc7231#section-6.6.5) | Gateway Timeout | [codersdk.Response](schemas.md#codersdkresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## List template builder modules ### Code samples diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f63f476f7d..fe2e574f4f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8198,6 +8198,31 @@ export interface TemplateBuilderConfig { readonly registry_url?: string; } +// From codersdk/templatebuilder.go +/** + * TemplateBuilderCreateTemplateRequest is the request body for + * POST /api/v2/templatebuilder/compose/template. + */ +export interface TemplateBuilderCreateTemplateRequest { + readonly base_template_id: string; + readonly modules: readonly TemplateBuilderComposeModule[]; + readonly organization_id: string; + readonly name: string; + readonly display_name?: string; + readonly description?: string; + readonly icon?: string; + readonly provisioner_tags?: Record; +} + +// From codersdk/templatebuilder.go +/** + * TemplateBuilderCreateTemplateResponse is the response body for + * POST /api/v2/templatebuilder/compose/template. + */ +export interface TemplateBuilderCreateTemplateResponse { + readonly template: Template; +} + // From codersdk/templatebuilder.go /** * TemplateBuilderModule is the API response type returned by