feat: cli: allow editing template metadata (#2159)

This PR adds a CLI command template edit which allows updating the following metadata fields of a template:
- Description
- Max TTL
- Min Autostart Interval
This commit is contained in:
Cian Johnston 2022-06-08 15:14:57 +01:00 committed by GitHub
parent b65259f95e
commit 8cfe223192
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 447 additions and 3 deletions

61
cli/templateedit.go Normal file
View File

@ -0,0 +1,61 @@
package cli
import (
"fmt"
"time"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func templateEdit() *cobra.Command {
var (
description string
maxTTL time.Duration
minAutostartInterval time.Duration
)
cmd := &cobra.Command{
Use: "edit <template> [flags]",
Args: cobra.ExactArgs(1),
Short: "Edit the metadata of a template by name.",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return xerrors.Errorf("create client: %w", err)
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}
template, err := client.TemplateByName(cmd.Context(), organization.ID, args[0])
if err != nil {
return xerrors.Errorf("get workspace template: %w", err)
}
// NOTE: coderd will ignore empty fields.
req := codersdk.UpdateTemplateMeta{
Description: description,
MaxTTLMillis: maxTTL.Milliseconds(),
MinAutostartIntervalMillis: minAutostartInterval.Milliseconds(),
}
_, err = client.UpdateTemplateMeta(cmd.Context(), template.ID, req)
if err != nil {
return xerrors.Errorf("update template metadata: %w", err)
}
_, _ = fmt.Printf("Updated template metadata!\n")
return nil
},
}
cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description")
cmd.Flags().DurationVarP(&maxTTL, "max_ttl", "", 0, "Edit the template maximum time before shutdown")
cmd.Flags().DurationVarP(&minAutostartInterval, "min_autostart_interval", "", 0, "Edit the template minimum autostart interval")
cliui.AllowSkipPrompt(cmd)
return cmd
}

94
cli/templateedit_test.go Normal file
View File

@ -0,0 +1,94 @@
package cli_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
)
func TestTemplateEdit(t *testing.T) {
t.Parallel()
t.Run("Modified", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.Description = "original description"
ctr.MaxTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
})
// Test the cli command.
desc := "lorem ipsum dolor sit amet et cetera"
maxTTL := 12 * time.Hour
minAutostartInterval := time.Minute
cmdArgs := []string{
"templates",
"edit",
template.Name,
"--description", desc,
"--max_ttl", maxTTL.String(),
"--min_autostart_interval", minAutostartInterval.String(),
}
cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
err := cmd.Execute()
require.NoError(t, err)
// Assert that the template metadata changed.
updated, err := client.Template(context.Background(), template.ID)
require.NoError(t, err)
assert.Equal(t, desc, updated.Description)
assert.Equal(t, maxTTL.Milliseconds(), updated.MaxTTLMillis)
assert.Equal(t, minAutostartInterval.Milliseconds(), updated.MinAutostartIntervalMillis)
})
t.Run("NotModified", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.Description = "original description"
ctr.MaxTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
})
// Test the cli command.
cmdArgs := []string{
"templates",
"edit",
template.Name,
"--description", template.Description,
"--max_ttl", (time.Duration(template.MaxTTLMillis) * time.Millisecond).String(),
"--min_autostart_interval", (time.Duration(template.MinAutostartIntervalMillis) * time.Millisecond).String(),
}
cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
err := cmd.Execute()
require.ErrorContains(t, err, "not modified")
// Assert that the template metadata did not change.
updated, err := client.Template(context.Background(), template.ID)
require.NoError(t, err)
assert.Equal(t, template.Description, updated.Description)
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
assert.Equal(t, template.MinAutostartIntervalMillis, updated.MinAutostartIntervalMillis)
})
}

View File

@ -26,6 +26,7 @@ func templates() *cobra.Command {
}
cmd.AddCommand(
templateCreate(),
templateEdit(),
templateInit(),
templateList(),
templatePlan(),

View File

@ -199,6 +199,7 @@ func New(options *Options) *API {
r.Get("/", api.template)
r.Delete("/", api.deleteTemplate)
r.Patch("/", api.patchTemplateMeta)
r.Route("/versions", func(r chi.Router) {
r.Get("/", api.templateVersionsByTemplate)
r.Patch("/", api.patchActiveTemplateVersion)

View File

@ -742,6 +742,25 @@ func (q *fakeQuerier) GetTemplateByOrganizationAndName(_ context.Context, arg da
return database.Template{}, sql.ErrNoRows
}
func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.UpdateTemplateMetaByIDParams) error {
q.mutex.RLock()
defer q.mutex.RUnlock()
for idx, tpl := range q.templates {
if tpl.ID != arg.ID {
continue
}
tpl.UpdatedAt = database.Now()
tpl.Description = arg.Description
tpl.MaxTtl = arg.MaxTtl
tpl.MinAutostartInterval = arg.MinAutostartInterval
q.templates[idx] = tpl
return nil
}
return sql.ErrNoRows
}
func (q *fakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg database.GetTemplateVersionsByTemplateIDParams) (version []database.TemplateVersion, err error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

View File

@ -108,6 +108,7 @@ type querier interface {
UpdateProvisionerJobWithCompleteByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteByIDParams) error
UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error
UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error
UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error
UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error
UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error
UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error

View File

@ -1862,6 +1862,39 @@ func (q *sqlQuerier) UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTe
return err
}
const updateTemplateMetaByID = `-- name: UpdateTemplateMetaByID :exec
UPDATE
templates
SET
updated_at = $2,
description = $3,
max_ttl = $4,
min_autostart_interval = $5
WHERE
id = $1
RETURNING
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval
`
type UpdateTemplateMetaByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Description string `db:"description" json:"description"`
MaxTtl int64 `db:"max_ttl" json:"max_ttl"`
MinAutostartInterval int64 `db:"min_autostart_interval" json:"min_autostart_interval"`
}
func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error {
_, err := q.db.ExecContext(ctx, updateTemplateMetaByID,
arg.ID,
arg.UpdatedAt,
arg.Description,
arg.MaxTtl,
arg.MinAutostartInterval,
)
return err
}
const getTemplateVersionByID = `-- name: GetTemplateVersionByID :one
SELECT
id, template_id, organization_id, created_at, updated_at, name, readme, job_id

View File

@ -69,3 +69,16 @@ SET
deleted = $2
WHERE
id = $1;
-- name: UpdateTemplateMetaByID :exec
UPDATE
templates
SET
updated_at = $2,
description = $3,
max_ttl = $4,
min_autostart_interval = $5
WHERE
id = $1
RETURNING
*;

View File

@ -307,6 +307,102 @@ func (api *API) templateByOrganizationAndName(rw http.ResponseWriter, r *http.Re
httpapi.Write(rw, http.StatusOK, convertTemplate(template, count))
}
func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
template := httpmw.TemplateParam(r)
if !api.Authorize(rw, r, rbac.ActionUpdate, template) {
return
}
var req codersdk.UpdateTemplateMeta
if !httpapi.Read(rw, r, &req) {
return
}
var validErrs []httpapi.Error
if req.MaxTTLMillis < 0 {
validErrs = append(validErrs, httpapi.Error{Field: "max_ttl_ms", Detail: "Must be a positive integer."})
}
if req.MinAutostartIntervalMillis < 0 {
validErrs = append(validErrs, httpapi.Error{Field: "min_autostart_interval_ms", Detail: "Must be a positive integer."})
}
if len(validErrs) > 0 {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "Invalid request to update template metadata!",
Validations: validErrs,
})
return
}
count := uint32(0)
var updated database.Template
err := api.Database.InTx(func(s database.Store) error {
// Fetch workspace counts
workspaceCounts, err := s.GetWorkspaceOwnerCountsByTemplateIDs(r.Context(), []uuid.UUID{template.ID})
if xerrors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
return err
}
if len(workspaceCounts) > 0 {
count = uint32(workspaceCounts[0].Count)
}
if req.Description == template.Description &&
req.MaxTTLMillis == time.Duration(template.MaxTtl).Milliseconds() &&
req.MinAutostartIntervalMillis == time.Duration(template.MinAutostartInterval).Milliseconds() {
return nil
}
// Update template metadata -- empty fields are not overwritten.
desc := req.Description
maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond
minAutostartInterval := time.Duration(req.MinAutostartIntervalMillis) * time.Millisecond
if desc == "" {
desc = template.Description
}
if maxTTL == 0 {
maxTTL = time.Duration(template.MaxTtl)
}
if minAutostartInterval == 0 {
minAutostartInterval = time.Duration(template.MinAutostartInterval)
}
if err := s.UpdateTemplateMetaByID(r.Context(), database.UpdateTemplateMetaByIDParams{
ID: template.ID,
UpdatedAt: database.Now(),
Description: desc,
MaxTtl: int64(maxTTL),
MinAutostartInterval: int64(minAutostartInterval),
}); err != nil {
return err
}
updated, err = s.GetTemplateByID(r.Context(), template.ID)
if err != nil {
return err
}
return nil
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: "Internal error updating template metadata.",
Detail: err.Error(),
})
return
}
if updated.UpdatedAt.IsZero() {
httpapi.Write(rw, http.StatusNotModified, nil)
return
}
httpapi.Write(rw, http.StatusOK, convertTemplate(updated, count))
}
func convertTemplates(templates []database.Template, workspaceCounts []database.GetWorkspaceOwnerCountsByTemplateIDsRow) []codersdk.Template {
apiTemplates := make([]codersdk.Template, 0, len(templates))
for _, template := range templates {

View File

@ -4,12 +4,14 @@ import (
"context"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
)
@ -145,6 +147,99 @@ func TestTemplateByOrganizationAndName(t *testing.T) {
})
}
func TestPatchTemplateMeta(t *testing.T) {
t.Parallel()
t.Run("Modified", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.Description = "original description"
ctr.MaxTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
})
req := codersdk.UpdateTemplateMeta{
Description: "lorem ipsum dolor sit amet et cetera",
MaxTTLMillis: 12 * time.Hour.Milliseconds(),
MinAutostartIntervalMillis: time.Minute.Milliseconds(),
}
updated, err := client.UpdateTemplateMeta(ctx, template.ID, req)
require.NoError(t, err)
assert.Greater(t, updated.UpdatedAt, template.UpdatedAt)
assert.Equal(t, req.Description, updated.Description)
assert.Equal(t, req.MaxTTLMillis, updated.MaxTTLMillis)
assert.Equal(t, req.MinAutostartIntervalMillis, updated.MinAutostartIntervalMillis)
// Extra paranoid: did it _really_ happen?
updated, err = client.Template(ctx, template.ID)
require.NoError(t, err)
assert.Greater(t, updated.UpdatedAt, template.UpdatedAt)
assert.Equal(t, req.Description, updated.Description)
assert.Equal(t, req.MaxTTLMillis, updated.MaxTTLMillis)
assert.Equal(t, req.MinAutostartIntervalMillis, updated.MinAutostartIntervalMillis)
})
t.Run("NotModified", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.Description = "original description"
ctr.MaxTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
})
req := codersdk.UpdateTemplateMeta{
Description: template.Description,
MaxTTLMillis: template.MaxTTLMillis,
MinAutostartIntervalMillis: template.MinAutostartIntervalMillis,
}
_, err := client.UpdateTemplateMeta(ctx, template.ID, req)
require.ErrorContains(t, err, "not modified")
updated, err := client.Template(ctx, template.ID)
require.NoError(t, err)
assert.Equal(t, updated.UpdatedAt, template.UpdatedAt)
assert.Equal(t, template.Description, updated.Description)
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
assert.Equal(t, template.MinAutostartIntervalMillis, updated.MinAutostartIntervalMillis)
})
t.Run("Invalid", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.Description = "original description"
ctr.MaxTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
})
req := codersdk.UpdateTemplateMeta{
MaxTTLMillis: -int64(time.Hour),
MinAutostartIntervalMillis: -int64(time.Hour),
}
_, err := client.UpdateTemplateMeta(ctx, template.ID, req)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Contains(t, apiErr.Message, "Invalid request")
require.Len(t, apiErr.Validations, 2)
assert.Equal(t, apiErr.Validations[0].Field, "max_ttl_ms")
assert.Equal(t, apiErr.Validations[1].Field, "min_autostart_interval_ms")
updated, err := client.Template(ctx, template.ID)
require.NoError(t, err)
assert.WithinDuration(t, template.UpdatedAt, updated.UpdatedAt, time.Minute)
assert.Equal(t, template.Description, updated.Description)
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
assert.Equal(t, template.MinAutostartIntervalMillis, updated.MinAutostartIntervalMillis)
})
}
func TestDeleteTemplate(t *testing.T) {
t.Parallel()

View File

@ -8,6 +8,7 @@ import (
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
)
// Template is the JSON representation of a Coder template. This type matches the
@ -30,6 +31,12 @@ type UpdateActiveTemplateVersion struct {
ID uuid.UUID `json:"id" validate:"required"`
}
type UpdateTemplateMeta struct {
Description string `json:"description,omitempty"`
MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"`
MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms,omitempty"`
}
// Template returns a single template.
func (c *Client) Template(ctx context.Context, template uuid.UUID) (Template, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s", template), nil)
@ -56,6 +63,22 @@ func (c *Client) DeleteTemplate(ctx context.Context, template uuid.UUID) error {
return nil
}
func (c *Client) UpdateTemplateMeta(ctx context.Context, templateID uuid.UUID, req UpdateTemplateMeta) (Template, error) {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/templates/%s", templateID), req)
if err != nil {
return Template{}, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotModified {
return Template{}, xerrors.New("template metadata not modified")
}
if res.StatusCode != http.StatusOK {
return Template{}, readBodyAsError(res)
}
var updated Template
return updated, json.NewDecoder(res.Body).Decode(&updated)
}
// UpdateActiveTemplateVersion updates the active template version to the ID provided.
// The template version must be attached to the template.
func (c *Client) UpdateActiveTemplateVersion(ctx context.Context, template uuid.UUID, req UpdateActiveTemplateVersion) error {

View File

@ -234,7 +234,7 @@ export interface Role {
readonly display_name: string
}
// From codersdk/templates.go:15:6
// From codersdk/templates.go:16:6
export interface Template {
readonly id: string
readonly created_at: string
@ -276,12 +276,12 @@ export interface TemplateVersionParameter {
readonly default_source_value: boolean
}
// From codersdk/templates.go:75:6
// From codersdk/templates.go:98:6
export interface TemplateVersionsByTemplateRequest extends Pagination {
readonly template_id: string
}
// From codersdk/templates.go:29:6
// From codersdk/templates.go:30:6
export interface UpdateActiveTemplateVersion {
readonly id: string
}
@ -291,6 +291,13 @@ export interface UpdateRoles {
readonly roles: string[]
}
// From codersdk/templates.go:34:6
export interface UpdateTemplateMeta {
readonly description?: string
readonly max_ttl_ms?: number
readonly min_autostart_interval_ms?: number
}
// From codersdk/users.go:66:6
export interface UpdateUserPasswordRequest {
readonly old_password: string