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
+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)
}