diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 046c1db027..62bc719a3b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7358,6 +7358,39 @@ const docTemplate = `{ ] } }, + "/api/v2/templatebuilder/modules": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "TemplateBuilder" + ], + "summary": "List template builder modules", + "operationId": "list-template-builder-modules", + "parameters": [ + { + "type": "string", + "description": "Base template example ID for OS-compatibility filtering", + "name": "base", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.TemplateBuilderModulesResponse" + } + } + }, + "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.", @@ -23664,6 +23697,97 @@ const docTemplate = `{ } } }, + "codersdk.TemplateBuilderModule": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "compatible_os": { + "type": "array", + "items": { + "type": "string" + } + }, + "conflicts_with": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateBuilderModuleVariable" + } + }, + "version": { + "type": "string" + } + } + }, + "codersdk.TemplateBuilderModuleVariable": { + "type": "object", + "properties": { + "default": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "sensitive": { + "type": "boolean" + }, + "type": { + "$ref": "#/definitions/codersdk.TemplateBuilderVariableType" + } + } + }, + "codersdk.TemplateBuilderModulesResponse": { + "type": "object", + "properties": { + "modules": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateBuilderModule" + } + } + } + }, + "codersdk.TemplateBuilderVariableType": { + "type": "string", + "enum": [ + "string", + "number", + "bool" + ], + "x-enum-varnames": [ + "TemplateBuilderVariableTypeString", + "TemplateBuilderVariableTypeNumber", + "TemplateBuilderVariableTypeBool" + ] + }, "codersdk.TemplateExample": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 66fd8b9859..6579327ee6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6527,6 +6527,35 @@ ] } }, + "/api/v2/templatebuilder/modules": { + "get": { + "produces": ["application/json"], + "tags": ["TemplateBuilder"], + "summary": "List template builder modules", + "operationId": "list-template-builder-modules", + "parameters": [ + { + "type": "string", + "description": "Base template example ID for OS-compatibility filtering", + "name": "base", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.TemplateBuilderModulesResponse" + } + } + }, + "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.", @@ -21730,6 +21759,93 @@ } } }, + "codersdk.TemplateBuilderModule": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "compatible_os": { + "type": "array", + "items": { + "type": "string" + } + }, + "conflicts_with": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateBuilderModuleVariable" + } + }, + "version": { + "type": "string" + } + } + }, + "codersdk.TemplateBuilderModuleVariable": { + "type": "object", + "properties": { + "default": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "sensitive": { + "type": "boolean" + }, + "type": { + "$ref": "#/definitions/codersdk.TemplateBuilderVariableType" + } + } + }, + "codersdk.TemplateBuilderModulesResponse": { + "type": "object", + "properties": { + "modules": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateBuilderModule" + } + } + } + }, + "codersdk.TemplateBuilderVariableType": { + "type": "string", + "enum": ["string", "number", "bool"], + "x-enum-varnames": [ + "TemplateBuilderVariableTypeString", + "TemplateBuilderVariableTypeNumber", + "TemplateBuilderVariableTypeBool" + ] + }, "codersdk.TemplateExample": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 60f2d7e5ca..96a80b656d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1621,6 +1621,7 @@ func New(options *Options) *API { apiKeyMiddleware, ) r.Get("/bases", api.templateBuilderBases) + r.Get("/modules", api.templateBuilderModules) }) } diff --git a/coderd/templatebuilder/catalog.go b/coderd/templatebuilder/catalog.go index 11d8b59da6..316316e5c4 100644 --- a/coderd/templatebuilder/catalog.go +++ b/coderd/templatebuilder/catalog.go @@ -137,11 +137,31 @@ func parseModulesFromFS(fsys fs.FS) ([]ModuleManifest, error) { return modules, nil } +// CompatibleWithOS reports whether the module is compatible with the given OS. +// Modules with an empty CompatibleOS list are compatible with all platforms. +func (m ModuleManifest) CompatibleWithOS(os string) bool { + if len(m.CompatibleOS) == 0 { + return true + } + for _, supported := range m.CompatibleOS { + if supported == os { + return true + } + } + return false +} + // ToSDK converts a ModuleManifest to the API response type. // PinnedVersion is mapped to Version; tags are not part of the API surface. +// Computed variables are excluded from the output. func (m ModuleManifest) ToSDK() codersdk.TemplateBuilderModule { variables := make([]codersdk.TemplateBuilderModuleVariable, 0, len(m.Variables)) for _, v := range m.Variables { + // Computed variables (e.g. agent_id) are wired by the builder + // automatically and must not be surfaced to the user. + if v.Computed { + continue + } variables = append(variables, codersdk.TemplateBuilderModuleVariable{ Name: v.Name, Type: validVariableTypes[v.Type], @@ -149,7 +169,6 @@ func (m ModuleManifest) ToSDK() codersdk.TemplateBuilderModule { Default: v.Default, Required: v.Required, Sensitive: v.Sensitive, - Computed: v.Computed, }) } diff --git a/coderd/templatebuilder/catalog_test.go b/coderd/templatebuilder/catalog_test.go index 0fe2cd45c0..5c938c65ca 100644 --- a/coderd/templatebuilder/catalog_test.go +++ b/coderd/templatebuilder/catalog_test.go @@ -93,18 +93,10 @@ func TestToSDK(t *testing.T) { t.Run("AllVariableFields", func(t *testing.T) { t.Parallel() - require.Len(t, sdk.Variables, 3) + // Computed variables (agent_id) are excluded from SDK output. + require.Len(t, sdk.Variables, 2) - agent := sdk.Variables[0] - require.Equal(t, "agent_id", agent.Name) - require.Equal(t, codersdk.TemplateBuilderVariableTypeString, agent.Type) - require.Equal(t, "The Coder agent ID.", agent.Description) - require.Nil(t, agent.Default) - require.True(t, agent.Required) - require.False(t, agent.Sensitive) - require.True(t, agent.Computed) - - port := sdk.Variables[1] + port := sdk.Variables[0] require.Equal(t, "port", port.Name) require.Equal(t, codersdk.TemplateBuilderVariableTypeNumber, port.Type) require.Equal(t, "Port to listen on.", port.Description) @@ -112,16 +104,14 @@ func TestToSDK(t *testing.T) { require.JSONEq(t, "8080", string(port.Default)) require.False(t, port.Required) require.False(t, port.Sensitive) - require.False(t, port.Computed) - secret := sdk.Variables[2] + secret := sdk.Variables[1] require.Equal(t, "secret_key", secret.Name) require.Equal(t, codersdk.TemplateBuilderVariableTypeString, secret.Type) require.Equal(t, "A sensitive value.", secret.Description) require.Nil(t, secret.Default) require.True(t, secret.Required) require.True(t, secret.Sensitive) - require.False(t, secret.Computed) }) t.Run("NilSlicesNormalizedToEmpty", func(t *testing.T) { @@ -141,3 +131,32 @@ func TestToSDK(t *testing.T) { require.Empty(t, s.Variables) }) } + +func TestCompatibleWithOS(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + compatibleOS []string + os string + want bool + }{ + {"EmptyListMatchesAll", nil, "linux", true}, + {"ExactMatch", []string{"linux"}, "linux", true}, + {"MultipleMatch", []string{"linux", "windows"}, "windows", true}, + {"NoMatch", []string{"linux"}, "windows", false}, + {"CaseSensitive", []string{"linux"}, "Linux", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := templatebuilder.ModuleManifest{ + ID: "test", + PinnedVersion: "1.0.0", + CompatibleOS: tc.compatibleOS, + } + require.Equal(t, tc.want, m.CompatibleWithOS(tc.os)) + }) + } +} diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index 6fdb5be068..bc7732af5a 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -67,3 +67,54 @@ func (api *API) templateBuilderBases(rw http.ResponseWriter, r *http.Request) { Bases: bases, }) } + +// @Summary List template builder modules +// @ID list-template-builder-modules +// @Security CoderSessionToken +// @Produce json +// @Tags TemplateBuilder +// @Param base query string false "Base template example ID for OS-compatibility filtering" +// @Success 200 {object} codersdk.TemplateBuilderModulesResponse +// @Router /api/v2/templatebuilder/modules [get] +func (api *API) templateBuilderModules(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if !api.Authorize(r, policy.ActionRead, rbac.ResourceTemplate.AnyOrganization()) { + httpapi.ResourceNotFound(rw) + return + } + + manifests, err := templatebuilder.LoadModules() + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error loading module catalog.", + Detail: err.Error(), + }) + return + } + + // Resolve OS filter from the base query param. + var filterOS templatebuilder.BaseOS + if base := r.URL.Query().Get("base"); base != "" { + filterOS = templatebuilder.BaseTemplateOS(base) + if filterOS == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Unknown base template.", + Detail: "The \"base\" query parameter must be a valid base template ID.", + }) + return + } + } + + modules := make([]codersdk.TemplateBuilderModule, 0, len(manifests)) + for _, m := range manifests { + if filterOS != "" && !m.CompatibleWithOS(string(filterOS)) { + continue + } + modules = append(modules, m.ToSDK()) + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.TemplateBuilderModulesResponse{ + Modules: modules, + }) +} diff --git a/coderd/templatebuilder_handler_test.go b/coderd/templatebuilder_handler_test.go index 1571d1f574..fdcf651606 100644 --- a/coderd/templatebuilder_handler_test.go +++ b/coderd/templatebuilder_handler_test.go @@ -81,3 +81,107 @@ func TestTemplateBuilderBases(t *testing.T) { require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) } + +func TestTemplateBuilderModules(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.TemplateBuilderModules(ctx, "") + require.NoError(t, err) + require.NotEmpty(t, resp.Modules) + + for _, m := range resp.Modules { + require.NotEmpty(t, m.ID) + require.NotEmpty(t, m.Version) + } + }) + + t.Run("FilteredByBase", 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.TemplateBuilderModules(ctx, "docker") + require.NoError(t, err) + + for _, m := range resp.Modules { + if len(m.CompatibleOS) > 0 { + require.Contains(t, m.CompatibleOS, "linux", + "module %q should be compatible with linux when filtered by docker base", m.ID) + } + } + }) + + t.Run("ComputedVariablesExcluded", 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.TemplateBuilderModules(ctx, "") + require.NoError(t, err) + + // The embedded code-server module has agent_id with computed=true. + // It must not appear in the API response. + var found bool + for _, m := range resp.Modules { + if m.ID == "code-server" { + found = true + for _, v := range m.Variables { + require.NotEqual(t, "agent_id", v.Name, + "computed variable agent_id must not appear in API response") + } + } + } + require.True(t, found, "code-server module must be in the catalog") + }) + + t.Run("UnknownBaseReturns400", 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.TemplateBuilderModules(ctx, "nonexistent") + 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.TemplateBuilderModules(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 d203689b3a..8b41b00773 100644 --- a/codersdk/templatebuilder.go +++ b/codersdk/templatebuilder.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "net/url" ) // TemplateBuilderVariableType enumerates the variable types @@ -23,7 +24,6 @@ type TemplateBuilderModuleVariable struct { Default json.RawMessage `json:"default,omitempty"` Required bool `json:"required"` Sensitive bool `json:"sensitive"` - Computed bool `json:"computed"` } // TemplateBuilderModule is the API response type returned by @@ -41,6 +41,7 @@ type TemplateBuilderModule struct { Variables []TemplateBuilderModuleVariable `json:"variables"` } +// TemplateBuilderModulesResponse is the response body for listing template builder modules. type TemplateBuilderModulesResponse struct { Modules []TemplateBuilderModule `json:"modules"` } @@ -74,3 +75,23 @@ func (c *Client) TemplateBuilderBases(ctx context.Context) (TemplateBuilderBases var resp TemplateBuilderBasesResponse return resp, json.NewDecoder(res.Body).Decode(&resp) } + +// TemplateBuilderModules returns the list of modules available for a given +// base template. If base is empty, all modules are returned. +func (c *Client) TemplateBuilderModules(ctx context.Context, base string) (TemplateBuilderModulesResponse, error) { + path := "/api/v2/templatebuilder/modules" + if base != "" { + q := url.Values{"base": {base}} + path += "?" + q.Encode() + } + res, err := c.Request(ctx, http.MethodGet, path, nil) + if err != nil { + return TemplateBuilderModulesResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return TemplateBuilderModulesResponse{}, ReadBodyAsError(res) + } + var resp TemplateBuilderModulesResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index e7fc7e0401..86b565e4fb 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12106,6 +12106,132 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `disabled` | boolean | false | | | | `registry_url` | string | false | | | +## codersdk.TemplateBuilderModule + +```json +{ + "category": "string", + "compatible_os": [ + "string" + ], + "conflicts_with": [ + "string" + ], + "description": "string", + "display_name": "string", + "icon": "string", + "id": "string", + "variables": [ + { + "default": [ + 0 + ], + "description": "string", + "name": "string", + "required": true, + "sensitive": true, + "type": "string" + } + ], + "version": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------------|-------------------------------------------------------------------------------------------|----------|--------------|-------------| +| `category` | string | false | | | +| `compatible_os` | array of string | false | | | +| `conflicts_with` | array of string | false | | | +| `description` | string | false | | | +| `display_name` | string | false | | | +| `icon` | string | false | | | +| `id` | string | false | | | +| `variables` | array of [codersdk.TemplateBuilderModuleVariable](#codersdktemplatebuildermodulevariable) | false | | | +| `version` | string | false | | | + +## codersdk.TemplateBuilderModuleVariable + +```json +{ + "default": [ + 0 + ], + "description": "string", + "name": "string", + "required": true, + "sensitive": true, + "type": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|------------------------------------------------------------------------------|----------|--------------|-------------| +| `default` | array of integer | false | | | +| `description` | string | false | | | +| `name` | string | false | | | +| `required` | boolean | false | | | +| `sensitive` | boolean | false | | | +| `type` | [codersdk.TemplateBuilderVariableType](#codersdktemplatebuildervariabletype) | false | | | + +## codersdk.TemplateBuilderModulesResponse + +```json +{ + "modules": [ + { + "category": "string", + "compatible_os": [ + "string" + ], + "conflicts_with": [ + "string" + ], + "description": "string", + "display_name": "string", + "icon": "string", + "id": "string", + "variables": [ + { + "default": [ + 0 + ], + "description": "string", + "name": "string", + "required": true, + "sensitive": true, + "type": "string" + } + ], + "version": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|---------------------------------------------------------------------------|----------|--------------|-------------| +| `modules` | array of [codersdk.TemplateBuilderModule](#codersdktemplatebuildermodule) | false | | | + +## codersdk.TemplateBuilderVariableType + +```json +"string" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|----------------------------| +| `bool`, `number`, `string` | + ## codersdk.TemplateExample ```json diff --git a/docs/reference/api/templatebuilder.md b/docs/reference/api/templatebuilder.md index c9e99dd714..2149a35b93 100644 --- a/docs/reference/api/templatebuilder.md +++ b/docs/reference/api/templatebuilder.md @@ -38,3 +38,67 @@ curl -X GET http://coder-server:8080/api/v2/templatebuilder/bases \ | 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). + +## List template builder modules + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/templatebuilder/modules \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/templatebuilder/modules` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|-------|--------|----------|---------------------------------------------------------| +| `base` | query | string | false | Base template example ID for OS-compatibility filtering | + +### Example responses + +> 200 Response + +```json +{ + "modules": [ + { + "category": "string", + "compatible_os": [ + "string" + ], + "conflicts_with": [ + "string" + ], + "description": "string", + "display_name": "string", + "icon": "string", + "id": "string", + "variables": [ + { + "default": [ + 0 + ], + "description": "string", + "name": "string", + "required": true, + "sensitive": true, + "type": "string" + } + ], + "version": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TemplateBuilderModulesResponse](schemas.md#codersdktemplatebuildermodulesresponse) | + +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 b006feccec..dce48dfb84 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8204,10 +8204,12 @@ export interface TemplateBuilderModuleVariable { readonly default?: Record; readonly required: boolean; readonly sensitive: boolean; - readonly computed: boolean; } // From codersdk/templatebuilder.go +/** + * TemplateBuilderModulesResponse is the response body for listing template builder modules. + */ export interface TemplateBuilderModulesResponse { readonly modules: readonly TemplateBuilderModule[]; }