feat: add POST /api/v2/templatebuilder/compose endpoint (#26351)

> [!NOTE]
> This PR was authored by Coder Agents on behalf of @jeremyruppel.

Part 4 of DEVEX-277 (POST /api/v2/templatebuilder/compose).

Adds the HTTP handler, route wiring, and integration tests for the
compose endpoint.

The handler accepts a JSON request with a base template ID and optional
modules with variable overrides, renders them via `Compose`/`BundleTar`,
and returns the tar archive directly with `Content-Type:
application/x-tar`. The registry URL comes from the deployment config
(`CODER_TEMPLATE_BUILDER_REGISTRY_URL`).

RBAC uses `policy.ActionCreate` on
`rbac.ResourceTemplate.AnyOrganization()`.

Integration tests cover: base-only compose, base with modules, unknown
base/module errors, missing base template ID, and feature-disabled 404.
This commit is contained in:
Jeremy Ruppel
2026-06-15 11:07:16 -04:00
committed by GitHub
parent c50cef5ae1
commit b61b62f4b3
8 changed files with 438 additions and 1 deletions
+64
View File
@@ -7358,6 +7358,42 @@ const docTemplate = `{
]
}
},
"/api/v2/templatebuilder/compose": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/x-tar"
],
"tags": [
"TemplateBuilder"
],
"summary": "Compose template from base and modules",
"operationId": "compose-template-from-base-and-modules",
"parameters": [
{
"description": "Compose request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.TemplateBuilderComposeRequest"
}
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/templatebuilder/modules": {
"get": {
"produces": [
@@ -23686,6 +23722,34 @@ const docTemplate = `{
}
}
},
"codersdk.TemplateBuilderComposeModule": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"variables": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"codersdk.TemplateBuilderComposeRequest": {
"type": "object",
"properties": {
"base_template_id": {
"type": "string"
},
"modules": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.TemplateBuilderComposeModule"
}
}
}
},
"codersdk.TemplateBuilderConfig": {
"type": "object",
"properties": {
+58
View File
@@ -6527,6 +6527,36 @@
]
}
},
"/api/v2/templatebuilder/compose": {
"post": {
"consumes": ["application/json"],
"produces": ["application/x-tar"],
"tags": ["TemplateBuilder"],
"summary": "Compose template from base and modules",
"operationId": "compose-template-from-base-and-modules",
"parameters": [
{
"description": "Compose request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.TemplateBuilderComposeRequest"
}
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/templatebuilder/modules": {
"get": {
"produces": ["application/json"],
@@ -21748,6 +21778,34 @@
}
}
},
"codersdk.TemplateBuilderComposeModule": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"variables": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"codersdk.TemplateBuilderComposeRequest": {
"type": "object",
"properties": {
"base_template_id": {
"type": "string"
},
"modules": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.TemplateBuilderComposeModule"
}
}
}
},
"codersdk.TemplateBuilderConfig": {
"type": "object",
"properties": {
+1
View File
@@ -1622,6 +1622,7 @@ func New(options *Options) *API {
)
r.Get("/bases", api.templateBuilderBases)
r.Get("/modules", api.templateBuilderModules)
r.Post("/compose", api.templateBuilderCompose)
})
}
+2 -1
View File
@@ -406,7 +406,8 @@ func assertProduce(t *testing.T, comment SwaggerComment) {
(comment.router == "/api/v2/debug/coordinator" && comment.method == "get") ||
(comment.router == "/api/v2/debug/tailnet" && comment.method == "get") ||
(comment.router == "/api/v2/workspaces/{workspace}/acl" && comment.method == "patch") ||
(comment.router == "/api/v2/init-script/{os}/{arch}" && comment.method == "get") {
(comment.router == "/api/v2/init-script/{os}/{arch}" && comment.method == "get") ||
(comment.router == "/api/v2/templatebuilder/compose" && comment.method == "post") {
return // Exception: HTTP 200 is returned without response entity
}
+162
View File
@@ -0,0 +1,162 @@
package coderd_test
import (
"archive/tar"
"bytes"
"context"
"errors"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestTemplateBuilderCompose(t *testing.T) {
t.Parallel()
t.Run("BaseOnly", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
tarData, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "docker",
})
require.NoError(t, err)
require.NotEmpty(t, tarData)
files := extractTarFiles(t, tarData)
require.Contains(t, files, "main.tf")
require.NotContains(t, files, "modules.tf")
require.Contains(t, files["main.tf"], `resource "coder_agent"`)
})
t.Run("BaseWithModules", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
tarData, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "docker",
Modules: []codersdk.TemplateBuilderComposeModule{
{ID: "code-server"},
{
ID: "git-clone",
Variables: map[string]string{
"url": `"https://github.com/coder/coder"`,
},
},
},
})
require.NoError(t, err)
files := extractTarFiles(t, tarData)
require.Contains(t, files, "main.tf")
require.Contains(t, files, "modules.tf")
require.Contains(t, files["modules.tf"], `module "code-server"`)
require.Contains(t, files["modules.tf"], `module "git-clone"`)
require.Contains(t, files["modules.tf"], `coder_agent.main.id`)
})
t.Run("UnknownBase", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "nonexistent",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("UnknownModule", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "docker",
Modules: []codersdk.TemplateBuilderComposeModule{
{ID: "nonexistent-module"},
},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("MissingBaseTemplateID", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("DisabledReturns404", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.TemplateBuilder.Disabled = true
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: dv,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "docker",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}
func extractTarFiles(t *testing.T, data []byte) map[string]string {
t.Helper()
tr := tar.NewReader(bytes.NewReader(data))
files := make(map[string]string)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
require.NoError(t, err)
body, err := io.ReadAll(tr)
require.NoError(t, err)
files[hdr.Name] = string(body)
}
return files
}
+63
View File
@@ -118,3 +118,66 @@ func (api *API) templateBuilderModules(rw http.ResponseWriter, r *http.Request)
Modules: modules,
})
}
// @Summary Compose template from base and modules
// @ID compose-template-from-base-and-modules
// @Security CoderSessionToken
// @Accept json
// @Produce application/x-tar
// @Tags TemplateBuilder
// @Param request body codersdk.TemplateBuilderComposeRequest true "Compose request"
// @Success 200
// @Router /api/v2/templatebuilder/compose [post]
func (api *API) templateBuilderCompose(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceTemplate.AnyOrganization()) {
httpapi.ResourceNotFound(rw)
return
}
var req codersdk.TemplateBuilderComposeRequest
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
}
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
}
rw.Header().Set("Content-Type", "application/x-tar")
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(tarData)
}
+44
View File
@@ -12090,6 +12090,50 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
|---------|-----------------------------------------------------------------------|----------|--------------|-------------|
| `bases` | array of [codersdk.TemplateBuilderBase](#codersdktemplatebuilderbase) | false | | |
## codersdk.TemplateBuilderComposeModule
```json
{
"id": "string",
"variables": {
"property1": "string",
"property2": "string"
}
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------|--------|----------|--------------|-------------|
| `id` | string | false | | |
| `variables` | object | false | | |
| » `[any property]` | string | false | | |
## codersdk.TemplateBuilderComposeRequest
```json
{
"base_template_id": "string",
"modules": [
{
"id": "string",
"variables": {
"property1": "string",
"property2": "string"
}
}
]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------|-----------------------------------------------------------------------------------------|----------|--------------|-------------|
| `base_template_id` | string | false | | |
| `modules` | array of [codersdk.TemplateBuilderComposeModule](#codersdktemplatebuildercomposemodule) | false | | |
## codersdk.TemplateBuilderConfig
```json
+44
View File
@@ -39,6 +39,50 @@ curl -X GET http://coder-server:8080/api/v2/templatebuilder/bases \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Compose template from base and modules
### Code samples
```shell
# Example request using curl
curl -X POST http://coder-server:8080/api/v2/templatebuilder/compose \
-H 'Content-Type: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`POST /api/v2/templatebuilder/compose`
> Body parameter
```json
{
"base_template_id": "string",
"modules": [
{
"id": "string",
"variables": {
"property1": "string",
"property2": "string"
}
}
]
}
```
### Parameters
| Name | In | Type | Required | Description |
|--------|------|--------------------------------------------------------------------------------------------|----------|-----------------|
| `body` | body | [codersdk.TemplateBuilderComposeRequest](schemas.md#codersdktemplatebuildercomposerequest) | true | Compose request |
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|--------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## List template builder modules
### Code samples