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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user