mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add TemplateBuilderCreateTemplate SDK types and client method (#26360)
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 <details> <summary>Implementation notes</summary> - 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) </details> > 🤖 Generated by Coder Agents
This commit is contained in:
Generated
+111
@@ -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": {
|
||||
|
||||
Generated
+102
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+108
@@ -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
|
||||
|
||||
Generated
+122
@@ -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
|
||||
|
||||
Generated
+25
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user