mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
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:
Generated
+64
@@ -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": {
|
||||
|
||||
Generated
+58
@@ -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": {
|
||||
|
||||
@@ -1622,6 +1622,7 @@ func New(options *Options) *API {
|
||||
)
|
||||
r.Get("/bases", api.templateBuilderBases)
|
||||
r.Get("/modules", api.templateBuilderModules)
|
||||
r.Post("/compose", api.templateBuilderCompose)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+44
@@ -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
|
||||
|
||||
Generated
+44
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user