diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 55661ec6f2..046c1db027 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7333,6 +7333,31 @@ const docTemplate = `{ ] } }, + "/api/v2/templatebuilder/bases": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "TemplateBuilder" + ], + "summary": "List template builder base templates", + "operationId": "list-template-builder-base-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.TemplateBuilderBasesResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/templates": { "get": { "description": "Returns a list of templates.\nBy default, only non-deprecated templates are returned.\nTo include deprecated templates, specify ` + "`" + `deprecated:true` + "`" + ` in the search query.", @@ -23597,6 +23622,37 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.TransitionStats" } }, + "codersdk.TemplateBuilderBase": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "os": { + "type": "string" + } + } + }, + "codersdk.TemplateBuilderBasesResponse": { + "type": "object", + "properties": { + "bases": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateBuilderBase" + } + } + } + }, "codersdk.TemplateBuilderConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8a542d8024..66fd8b9859 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6506,6 +6506,27 @@ ] } }, + "/api/v2/templatebuilder/bases": { + "get": { + "produces": ["application/json"], + "tags": ["TemplateBuilder"], + "summary": "List template builder base templates", + "operationId": "list-template-builder-base-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.TemplateBuilderBasesResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/templates": { "get": { "description": "Returns a list of templates.\nBy default, only non-deprecated templates are returned.\nTo include deprecated templates, specify `deprecated:true` in the search query.", @@ -21667,6 +21688,37 @@ "$ref": "#/definitions/codersdk.TransitionStats" } }, + "codersdk.TemplateBuilderBase": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "os": { + "type": "string" + } + } + }, + "codersdk.TemplateBuilderBasesResponse": { + "type": "object", + "properties": { + "bases": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateBuilderBase" + } + } + } + }, "codersdk.TemplateBuilderConfig": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 537d024e77..60f2d7e5ca 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1620,8 +1620,7 @@ func New(options *Options) *API { r.Use( apiKeyMiddleware, ) - // Endpoints added by DEVEX-275 (bases), DEVEX-276 - // (modules), DEVEX-277/279 (compose). + r.Get("/bases", api.templateBuilderBases) }) } diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go new file mode 100644 index 0000000000..6fdb5be068 --- /dev/null +++ b/coderd/templatebuilder_handler.go @@ -0,0 +1,69 @@ +package coderd + +import ( + "net/http" + "sort" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/templatebuilder" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/examples" +) + +// @Summary List template builder base templates +// @ID list-template-builder-base-templates +// @Security CoderSessionToken +// @Produce json +// @Tags TemplateBuilder +// @Success 200 {object} codersdk.TemplateBuilderBasesResponse +// @Router /api/v2/templatebuilder/bases [get] +func (api *API) templateBuilderBases(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if !api.Authorize(r, policy.ActionRead, rbac.ResourceTemplate.AnyOrganization()) { + httpapi.ResourceNotFound(rw) + return + } + + exampleList, err := examples.List() + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error listing examples.", + Detail: err.Error(), + }) + return + } + + examplesByID := make(map[string]codersdk.TemplateExample, len(exampleList)) + for _, ex := range exampleList { + examplesByID[ex.ID] = ex + } + + bases := make([]codersdk.TemplateBuilderBase, 0, len(templatebuilder.BaseTemplateIDs())) + for _, id := range templatebuilder.BaseTemplateIDs() { + ex, ok := examplesByID[id] + if !ok { + api.Logger.Warn(ctx, "base template has no matching example", + slog.F("base_template_id", id)) + continue + } + bases = append(bases, codersdk.TemplateBuilderBase{ + ID: ex.ID, + Name: ex.Name, + Description: ex.Description, + Icon: ex.Icon, + OS: string(templatebuilder.BaseTemplateOS(id)), + }) + } + + sort.Slice(bases, func(i, j int) bool { + return bases[i].Name < bases[j].Name + }) + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.TemplateBuilderBasesResponse{ + Bases: bases, + }) +} diff --git a/coderd/templatebuilder_handler_test.go b/coderd/templatebuilder_handler_test.go new file mode 100644 index 0000000000..1571d1f574 --- /dev/null +++ b/coderd/templatebuilder_handler_test.go @@ -0,0 +1,83 @@ +package coderd_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/templatebuilder" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestTemplateBuilderBases(t *testing.T) { + t.Parallel() + + t.Run("OK", 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() + + resp, err := client.TemplateBuilderBases(ctx) + require.NoError(t, err) + require.NotEmpty(t, resp.Bases) + require.Len(t, resp.Bases, len(templatebuilder.BaseTemplateIDs())) + + basesByID := make(map[string]codersdk.TemplateBuilderBase, len(resp.Bases)) + for _, b := range resp.Bases { + basesByID[b.ID] = b + } + + for _, id := range templatebuilder.BaseTemplateIDs() { + b, ok := basesByID[id] + require.True(t, ok, "base %q missing from response", id) + require.NotEmpty(t, b.Name) + require.NotEmpty(t, b.Icon) + require.Equal(t, string(templatebuilder.BaseTemplateOS(id)), b.OS) + } + }) + + t.Run("Sorted", 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() + + resp, err := client.TemplateBuilderBases(ctx) + require.NoError(t, err) + + for i := 1; i < len(resp.Bases); i++ { + require.LessOrEqual(t, resp.Bases[i-1].Name, resp.Bases[i].Name, + "bases should be sorted by name") + } + }) + + 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.TemplateBuilderBases(ctx) + require.Error(t, err) + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} diff --git a/codersdk/templatebuilder.go b/codersdk/templatebuilder.go index 0d44645f30..d203689b3a 100644 --- a/codersdk/templatebuilder.go +++ b/codersdk/templatebuilder.go @@ -1,6 +1,10 @@ package codersdk -import "encoding/json" +import ( + "context" + "encoding/json" + "net/http" +) // TemplateBuilderVariableType enumerates the variable types // supported by template builder module manifests. @@ -40,3 +44,33 @@ type TemplateBuilderModule struct { type TemplateBuilderModulesResponse struct { Modules []TemplateBuilderModule `json:"modules"` } + +// TemplateBuilderBase is the API response type for a base template +// returned by GET /api/v2/templatebuilder/bases. +type TemplateBuilderBase struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + OS string `json:"os"` +} + +// TemplateBuilderBasesResponse is the response body for listing template builder bases. +type TemplateBuilderBasesResponse struct { + Bases []TemplateBuilderBase `json:"bases"` +} + +// TemplateBuilderBases returns the list of base templates available +// in the template builder. +func (c *Client) TemplateBuilderBases(ctx context.Context) (TemplateBuilderBasesResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/templatebuilder/bases", nil) + if err != nil { + return TemplateBuilderBasesResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return TemplateBuilderBasesResponse{}, ReadBodyAsError(res) + } + var resp TemplateBuilderBasesResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} diff --git a/docs/manifest.json b/docs/manifest.json index 0dac15fbd8..70640e7c96 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1611,6 +1611,10 @@ "title": "Tasks", "path": "./reference/api/tasks.md" }, + { + "title": "TemplateBuilder", + "path": "./reference/api/templatebuilder.md" + }, { "title": "Templates", "path": "./reference/api/templates.md" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 220d0c3e8f..e7fc7e0401 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12046,6 +12046,50 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |------------------|------------------------------------------------------|----------|--------------|-------------| | `[any property]` | [codersdk.TransitionStats](#codersdktransitionstats) | false | | | +## codersdk.TemplateBuilderBase + +```json +{ + "description": "string", + "icon": "string", + "id": "string", + "name": "string", + "os": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `description` | string | false | | | +| `icon` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `os` | string | false | | | + +## codersdk.TemplateBuilderBasesResponse + +```json +{ + "bases": [ + { + "description": "string", + "icon": "string", + "id": "string", + "name": "string", + "os": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|-----------------------------------------------------------------------|----------|--------------|-------------| +| `bases` | array of [codersdk.TemplateBuilderBase](#codersdktemplatebuilderbase) | false | | | + ## codersdk.TemplateBuilderConfig ```json diff --git a/docs/reference/api/templatebuilder.md b/docs/reference/api/templatebuilder.md new file mode 100644 index 0000000000..c9e99dd714 --- /dev/null +++ b/docs/reference/api/templatebuilder.md @@ -0,0 +1,40 @@ +# TemplateBuilder + +## List template builder base templates + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/templatebuilder/bases \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/templatebuilder/bases` + +### Example responses + +> 200 Response + +```json +{ + "bases": [ + { + "description": "string", + "icon": "string", + "id": "string", + "name": "string", + "os": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TemplateBuilderBasesResponse](schemas.md#codersdktemplatebuilderbasesresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ea91175c32..b006feccec 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8151,6 +8151,27 @@ export type TemplateBuildTimeStats = Record< TransitionStats >; +// From codersdk/templatebuilder.go +/** + * TemplateBuilderBase is the API response type for a base template + * returned by GET /api/v2/templatebuilder/bases. + */ +export interface TemplateBuilderBase { + readonly id: string; + readonly name: string; + readonly description: string; + readonly icon: string; + readonly os: string; +} + +// From codersdk/templatebuilder.go +/** + * TemplateBuilderBasesResponse is the response body for listing template builder bases. + */ +export interface TemplateBuilderBasesResponse { + readonly bases: readonly TemplateBuilderBase[]; +} + // From codersdk/deployment.go export interface TemplateBuilderConfig { readonly disabled?: boolean;