mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user