diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7ff97bba2968d..c0127c92be752 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3101,6 +3101,34 @@ const docTemplate = `{ } } }, + "/templates": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Templates" + ], + "summary": "Get all templates", + "operationId": "get-all-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Template" + } + } + } + } + } + }, "/templates/{template}": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index bc6fcc19142a9..cc3e37a0433ca 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2725,6 +2725,30 @@ } } }, + "/templates": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Templates"], + "summary": "Get all templates", + "operationId": "get-all-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Template" + } + } + } + } + } + }, "/templates/{template}": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index f92e3008604c3..288eca9a4dbaf 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -827,7 +827,7 @@ func New(options *Options) *API { r.Post("/templateversions", api.postTemplateVersionsByOrganization) r.Route("/templates", func(r chi.Router) { r.Post("/", api.postTemplateByOrganization) - r.Get("/", api.templatesByOrganization) + r.Get("/", api.templatesByOrganization()) r.Get("/examples", api.templateExamples) r.Route("/{templatename}", func(r chi.Router) { r.Get("/", api.templateByOrganizationAndName) @@ -869,20 +869,25 @@ func New(options *Options) *API { }) }) }) - r.Route("/templates/{template}", func(r chi.Router) { + r.Route("/templates", func(r chi.Router) { r.Use( apiKeyMiddleware, - httpmw.ExtractTemplateParam(options.Database), ) - r.Get("/daus", api.templateDAUs) - r.Get("/", api.template) - r.Delete("/", api.deleteTemplate) - r.Patch("/", api.patchTemplateMeta) - r.Route("/versions", func(r chi.Router) { - r.Post("/archive", api.postArchiveTemplateVersions) - r.Get("/", api.templateVersionsByTemplate) - r.Patch("/", api.patchActiveTemplateVersion) - r.Get("/{templateversionname}", api.templateVersionByName) + r.Get("/", api.fetchTemplates(nil)) + r.Route("/{template}", func(r chi.Router) { + r.Use( + httpmw.ExtractTemplateParam(options.Database), + ) + r.Get("/daus", api.templateDAUs) + r.Get("/", api.template) + r.Delete("/", api.deleteTemplate) + r.Patch("/", api.patchTemplateMeta) + r.Route("/versions", func(r chi.Router) { + r.Post("/archive", api.postArchiveTemplateVersions) + r.Get("/", api.templateVersionsByTemplate) + r.Patch("/", api.patchActiveTemplateVersion) + r.Get("/{templateversionname}", api.templateVersionByName) + }) }) }) r.Route("/templateversions/{templateversion}", func(r chi.Router) { diff --git a/coderd/templates.go b/coderd/templates.go index b4c546814737e..3027321fdbba2 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -435,55 +435,78 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque // @Param organization path string true "Organization ID" format(uuid) // @Success 200 {array} codersdk.Template // @Router /organizations/{organization}/templates [get] -func (api *API) templatesByOrganization(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - organization := httpmw.OrganizationParam(r) +func (api *API) templatesByOrganization() http.HandlerFunc { + // TODO: Should deprecate this endpoint and make it akin to /workspaces with + // a filter. There isn't a need to make the organization filter argument + // part of the query url. + // mutate the filter to only include templates from the given organization. + return api.fetchTemplates(func(r *http.Request, arg *database.GetTemplatesWithFilterParams) { + organization := httpmw.OrganizationParam(r) + arg.OrganizationID = organization.ID + }) +} - p := httpapi.NewQueryParamParser() - values := r.URL.Query() +// @Summary Get all templates +// @ID get-all-templates +// @Security CoderSessionToken +// @Produce json +// @Tags Templates +// @Success 200 {array} codersdk.Template +// @Router /templates [get] +func (api *API) fetchTemplates(mutate func(r *http.Request, arg *database.GetTemplatesWithFilterParams)) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + p := httpapi.NewQueryParamParser() + values := r.URL.Query() + + deprecated := sql.NullBool{} + if values.Has("deprecated") { + deprecated = sql.NullBool{ + Bool: p.Boolean(values, false, "deprecated"), + Valid: true, + } + } + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid query params.", + Validations: p.Errors, + }) + return + } - deprecated := sql.NullBool{} - if values.Has("deprecated") { - deprecated = sql.NullBool{ - Bool: p.Boolean(values, false, "deprecated"), - Valid: true, + prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceTemplate.Type) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error preparing sql filter.", + Detail: err.Error(), + }) + return } - } - if len(p.Errors) > 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid query params.", - Validations: p.Errors, - }) - return - } - prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceTemplate.Type) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error preparing sql filter.", - Detail: err.Error(), - }) - return - } + args := database.GetTemplatesWithFilterParams{ + Deprecated: deprecated, + } + if mutate != nil { + mutate(r, &args) + } - // Filter templates based on rbac permissions - templates, err := api.Database.GetAuthorizedTemplates(ctx, database.GetTemplatesWithFilterParams{ - OrganizationID: organization.ID, - Deprecated: deprecated, - }, prepared) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } + // Filter templates based on rbac permissions + templates, err := api.Database.GetAuthorizedTemplates(ctx, args, prepared) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching templates in organization.", - Detail: err.Error(), - }) - return - } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching templates in organization.", + Detail: err.Error(), + }) + return + } - httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplates(templates)) + httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplates(templates)) + } } // @Summary Get templates by organization and template name diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 7aebaf41b1e1b..2813f713f5ea2 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -438,6 +438,42 @@ func TestTemplatesByOrganization(t *testing.T) { templates, err := client.TemplatesByOrganization(ctx, user.OrganizationID) require.NoError(t, err) require.Len(t, templates, 2) + + // Listing all should match + templates, err = client.Templates(ctx) + require.NoError(t, err) + require.Len(t, templates, 2) + }) + t.Run("MultipleOrganizations", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + org2 := coderdtest.CreateOrganization(t, client, coderdtest.CreateOrganizationOptions{}) + user, _ := coderdtest.CreateAnotherUser(t, client, org2.ID) + + // 2 templates in first organization + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + coderdtest.CreateTemplate(t, client, owner.OrganizationID, version2.ID) + + // 2 in the second organization + version3 := coderdtest.CreateTemplateVersion(t, client, org2.ID, nil) + version4 := coderdtest.CreateTemplateVersion(t, client, org2.ID, nil) + coderdtest.CreateTemplate(t, client, org2.ID, version3.ID) + coderdtest.CreateTemplate(t, client, org2.ID, version4.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // All 4 are viewable by the owner + templates, err := client.Templates(ctx) + require.NoError(t, err) + require.Len(t, templates, 4) + + // Only 2 are viewable by the org user + templates, err = user.Templates(ctx) + require.NoError(t, err) + require.Len(t, templates, 2) }) } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index bc9e2514b2c15..e494018258e48 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -362,6 +362,25 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui return templates, json.NewDecoder(res.Body).Decode(&templates) } +// Templates lists all viewable templates +func (c *Client) Templates(ctx context.Context) ([]Template, error) { + res, err := c.Request(ctx, http.MethodGet, + "/api/v2/templates", + nil, + ) + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var templates []Template + return templates, json.NewDecoder(res.Body).Decode(&templates) +} + // TemplateByName finds a template inside the organization provided with a case-insensitive name. func (c *Client) TemplateByName(ctx context.Context, organizationID uuid.UUID, name string) (Template, error) { if name == "" { diff --git a/docs/api/templates.md b/docs/api/templates.md index de0498c3de87b..b85811f41d0b8 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -617,6 +617,132 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get all templates + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/templates \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /templates` + +### Example responses + +> 200 Response + +```json +[ + { + "active_user_count": 0, + "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", + "activity_bump_ms": 0, + "allow_user_autostart": true, + "allow_user_autostop": true, + "allow_user_cancel_workspace_jobs": true, + "autostart_requirement": { + "days_of_week": ["monday"] + }, + "autostop_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, + "build_time_stats": { + "property1": { + "p50": 123, + "p95": 146 + }, + "property2": { + "p50": 123, + "p95": 146 + } + }, + "created_at": "2019-08-24T14:15:22Z", + "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", + "created_by_name": "string", + "default_ttl_ms": 0, + "deprecated": true, + "deprecation_message": "string", + "description": "string", + "display_name": "string", + "failure_ttl_ms": 0, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_port_share_level": "owner", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "provisioner": "terraform", + "require_active_version": true, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, + "updated_at": "2019-08-24T14:15:22Z" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Template](schemas.md#codersdktemplate) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | +| `» active_version_id` | string(uuid) | false | | | +| `» activity_bump_ms` | integer | false | | | +| `» allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | +| `» allow_user_autostop` | boolean | false | | | +| `» allow_user_cancel_workspace_jobs` | boolean | false | | | +| `» autostart_requirement` | [codersdk.TemplateAutostartRequirement](schemas.md#codersdktemplateautostartrequirement) | false | | | +| `»» days_of_week` | array | false | | Days of week is a list of days of the week in which autostart is allowed to happen. If no days are specified, autostart is not allowed. | +| `» autostop_requirement` | [codersdk.TemplateAutostopRequirement](schemas.md#codersdktemplateautostoprequirement) | false | | Autostop requirement and AutostartRequirement are enterprise features. Its value is only used if your license is entitled to use the advanced template scheduling feature. | +| `»» days_of_week` | array | false | | Days of week is a list of days of the week on which restarts are required. Restarts happen within the user's quiet hours (in their configured timezone). If no days are specified, restarts are not required. Weekdays cannot be specified twice. | +| Restarts will only happen on weekdays in this list on weeks which line up with Weeks. | +| `»» weeks` | integer | false | | Weeks is the number of weeks between required restarts. Weeks are synced across all workspaces (and Coder deployments) using modulo math on a hardcoded epoch week of January 2nd, 2023 (the first Monday of 2023). Values of 0 or 1 indicate weekly restarts. Values of 2 indicate fortnightly restarts, etc. | +| `» build_time_stats` | [codersdk.TemplateBuildTimeStats](schemas.md#codersdktemplatebuildtimestats) | false | | | +| `»» [any property]` | [codersdk.TransitionStats](schemas.md#codersdktransitionstats) | false | | | +| `»»» p50` | integer | false | | | +| `»»» p95` | integer | false | | | +| `» created_at` | string(date-time) | false | | | +| `» created_by_id` | string(uuid) | false | | | +| `» created_by_name` | string | false | | | +| `» default_ttl_ms` | integer | false | | | +| `» deprecated` | boolean | false | | | +| `» deprecation_message` | string | false | | | +| `» description` | string | false | | | +| `» display_name` | string | false | | | +| `» failure_ttl_ms` | integer | false | | Failure ttl ms TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | +| `» icon` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» max_port_share_level` | [codersdk.WorkspaceAgentPortShareLevel](schemas.md#codersdkworkspaceagentportsharelevel) | false | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» provisioner` | string | false | | | +| `» require_active_version` | boolean | false | | Require active version mandates that workspaces are built with the active template version. | +| `» time_til_dormant_autodelete_ms` | integer | false | | | +| `» time_til_dormant_ms` | integer | false | | | +| `» updated_at` | string(date-time) | false | | | + +#### Enumerated Values + +| Property | Value | +| ---------------------- | --------------- | +| `max_port_share_level` | `owner` | +| `max_port_share_level` | `authenticated` | +| `max_port_share_level` | `public` | +| `provisioner` | `terraform` | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get template metadata by ID ### Code samples