mirror of https://github.com/coder/coder.git
feat: add template max_ttl (#6114)
Co-authored-by: Bruno Quaresma <bruno@coder.com>
This commit is contained in:
parent
248c53d68d
commit
66a6b590a1
|
@ -186,8 +186,9 @@ jobs:
|
|||
|
||||
- name: Install Protoc
|
||||
run: |
|
||||
# protoc must be in lockstep with our dogfood Dockerfile
|
||||
# or the version in the comments will differ.
|
||||
# protoc must be in lockstep with our dogfood Dockerfile or the
|
||||
# version in the comments will differ. This is also defined in
|
||||
# security.yaml
|
||||
set -x
|
||||
cd dogfood
|
||||
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
|
||||
|
|
|
@ -99,6 +99,22 @@ jobs:
|
|||
|
||||
- name: Install yq
|
||||
run: go run github.com/mikefarah/yq/v4@v4.30.6
|
||||
- name: Install protoc-gen-go
|
||||
run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
|
||||
- name: Install protoc-gen-go-drpc
|
||||
run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26
|
||||
- name: Install Protoc
|
||||
run: |
|
||||
# protoc must be in lockstep with our dogfood Dockerfile or the
|
||||
# version in the comments will differ. This is also defined in
|
||||
# ci.yaml.
|
||||
set -x
|
||||
cd dogfood
|
||||
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
|
||||
protoc_path=/usr/local/bin/protoc
|
||||
docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path
|
||||
chmod +x $protoc_path
|
||||
protoc --version
|
||||
|
||||
- name: Build Coder linux amd64 Docker image
|
||||
id: build
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
|
|
@ -10,7 +10,7 @@ import (
|
|||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/coderd/util/tz"
|
||||
"github.com/coder/coder/codersdk"
|
||||
|
|
|
@ -308,13 +308,16 @@ func TestScheduleOverride(t *testing.T) {
|
|||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.TTLMillis = nil
|
||||
})
|
||||
cmdArgs = []string{"schedule", "override-stop", workspace.Name, "1h"}
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
require.Zero(t, template.DefaultTTLMillis)
|
||||
require.Zero(t, template.MaxTTLMillis)
|
||||
|
||||
// Unset the workspace TTL
|
||||
err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil})
|
||||
require.NoError(t, err)
|
||||
|
|
|
@ -2,6 +2,7 @@ package cli
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
@ -18,6 +19,7 @@ func templateEdit() *cobra.Command {
|
|||
description string
|
||||
icon string
|
||||
defaultTTL time.Duration
|
||||
maxTTL time.Duration
|
||||
allowUserCancelWorkspaceJobs bool
|
||||
)
|
||||
|
||||
|
@ -30,6 +32,21 @@ func templateEdit() *cobra.Command {
|
|||
if err != nil {
|
||||
return xerrors.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
if maxTTL != 0 {
|
||||
entitlements, err := client.Entitlements(cmd.Context())
|
||||
var sdkErr *codersdk.Error
|
||||
if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
|
||||
return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl")
|
||||
} else if err != nil {
|
||||
return xerrors.Errorf("get entitlements: %w", err)
|
||||
}
|
||||
|
||||
if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled {
|
||||
return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl")
|
||||
}
|
||||
}
|
||||
|
||||
organization, err := CurrentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current organization: %w", err)
|
||||
|
@ -46,6 +63,7 @@ func templateEdit() *cobra.Command {
|
|||
Description: description,
|
||||
Icon: icon,
|
||||
DefaultTTLMillis: defaultTTL.Milliseconds(),
|
||||
MaxTTLMillis: maxTTL.Milliseconds(),
|
||||
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
|
||||
}
|
||||
|
||||
|
@ -58,11 +76,12 @@ func templateEdit() *cobra.Command {
|
|||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name")
|
||||
cmd.Flags().StringVarP(&displayName, "display-name", "", "", "Edit the template display name")
|
||||
cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description")
|
||||
cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path")
|
||||
cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template to this value.")
|
||||
cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name.")
|
||||
cmd.Flags().StringVarP(&displayName, "display-name", "", "", "Edit the template display name.")
|
||||
cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description.")
|
||||
cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path.")
|
||||
cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template default to this value.")
|
||||
cmd.Flags().DurationVarP(&maxTTL, "max-ttl", "", 0, "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.")
|
||||
cmd.Flags().BoolVarP(&allowUserCancelWorkspaceJobs, "allow-user-cancel-workspace-jobs", "", true, "Allow users to cancel in-progress workspace jobs.")
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
|
|
|
@ -1,8 +1,17 @@
|
|||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
@ -11,6 +20,7 @@ import (
|
|||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
@ -230,4 +240,205 @@ func TestTemplateEdit(t *testing.T) {
|
|||
assert.Equal(t, "", updated.Icon)
|
||||
assert.Equal(t, "", updated.DisplayName)
|
||||
})
|
||||
t.Run("MaxTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("BlockedAGPL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: 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.DefaultTTLMillis = nil
|
||||
ctr.MaxTTLMillis = nil
|
||||
})
|
||||
|
||||
// Test the cli command.
|
||||
cmdArgs := []string{
|
||||
"templates",
|
||||
"edit",
|
||||
template.Name,
|
||||
"--max-ttl", "1h",
|
||||
}
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx, _ := testutil.Context(t)
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "appears to be an AGPL deployment")
|
||||
|
||||
// Assert that the template metadata did not change.
|
||||
updated, err := client.Template(context.Background(), template.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, template.Name, updated.Name)
|
||||
assert.Equal(t, template.Description, updated.Description)
|
||||
assert.Equal(t, template.Icon, updated.Icon)
|
||||
assert.Equal(t, template.DisplayName, updated.DisplayName)
|
||||
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
|
||||
})
|
||||
|
||||
t.Run("BlockedNotEntitled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: 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.DefaultTTLMillis = nil
|
||||
ctr.MaxTTLMillis = nil
|
||||
})
|
||||
|
||||
// Make a proxy server that will return a valid entitlements
|
||||
// response, but without advanced scheduling entitlement.
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v2/entitlements" {
|
||||
res := codersdk.Entitlements{
|
||||
Features: map[codersdk.FeatureName]codersdk.Feature{},
|
||||
Warnings: []string{},
|
||||
Errors: []string{},
|
||||
HasLicense: true,
|
||||
Trial: true,
|
||||
RequireTelemetry: false,
|
||||
Experimental: false,
|
||||
}
|
||||
for _, feature := range codersdk.FeatureNames {
|
||||
res.Features[feature] = codersdk.Feature{
|
||||
Entitlement: codersdk.EntitlementNotEntitled,
|
||||
Enabled: false,
|
||||
Limit: nil,
|
||||
Actual: nil,
|
||||
}
|
||||
}
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, res)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, proxy the request to the real API server.
|
||||
httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
// Create a new client that uses the proxy server.
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
require.NoError(t, err)
|
||||
proxyClient := codersdk.New(proxyURL)
|
||||
proxyClient.SetSessionToken(client.SessionToken())
|
||||
|
||||
// Test the cli command.
|
||||
cmdArgs := []string{
|
||||
"templates",
|
||||
"edit",
|
||||
template.Name,
|
||||
"--max-ttl", "1h",
|
||||
}
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, proxyClient, root)
|
||||
|
||||
ctx, _ := testutil.Context(t)
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "license is not entitled")
|
||||
|
||||
// Assert that the template metadata did not change.
|
||||
updated, err := client.Template(context.Background(), template.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, template.Name, updated.Name)
|
||||
assert.Equal(t, template.Description, updated.Description)
|
||||
assert.Equal(t, template.Icon, updated.Icon)
|
||||
assert.Equal(t, template.DisplayName, updated.DisplayName)
|
||||
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
|
||||
})
|
||||
t.Run("Entitled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: 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.DefaultTTLMillis = nil
|
||||
ctr.MaxTTLMillis = nil
|
||||
})
|
||||
|
||||
// Make a proxy server that will return a valid entitlements
|
||||
// response, including a valid advanced scheduling entitlement.
|
||||
var updateTemplateCalled int64
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v2/entitlements" {
|
||||
res := codersdk.Entitlements{
|
||||
Features: map[codersdk.FeatureName]codersdk.Feature{},
|
||||
Warnings: []string{},
|
||||
Errors: []string{},
|
||||
HasLicense: true,
|
||||
Trial: true,
|
||||
RequireTelemetry: false,
|
||||
Experimental: false,
|
||||
}
|
||||
for _, feature := range codersdk.FeatureNames {
|
||||
var one int64 = 1
|
||||
res.Features[feature] = codersdk.Feature{
|
||||
Entitlement: codersdk.EntitlementNotEntitled,
|
||||
Enabled: true,
|
||||
Limit: &one,
|
||||
Actual: &one,
|
||||
}
|
||||
}
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, res)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/templates/") {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
_ = r.Body.Close()
|
||||
|
||||
var req codersdk.UpdateTemplateMeta
|
||||
err = json.Unmarshal(body, &req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, time.Hour.Milliseconds(), req.MaxTTLMillis)
|
||||
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
atomic.AddInt64(&updateTemplateCalled, 1)
|
||||
// We still want to call the real route.
|
||||
}
|
||||
|
||||
// Otherwise, proxy the request to the real API server.
|
||||
httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
// Create a new client that uses the proxy server.
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
require.NoError(t, err)
|
||||
proxyClient := codersdk.New(proxyURL)
|
||||
proxyClient.SetSessionToken(client.SessionToken())
|
||||
|
||||
// Test the cli command.
|
||||
cmdArgs := []string{
|
||||
"templates",
|
||||
"edit",
|
||||
template.Name,
|
||||
"--max-ttl", "1h",
|
||||
}
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, proxyClient, root)
|
||||
|
||||
ctx, _ := testutil.Context(t)
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled))
|
||||
|
||||
// Assert that the template metadata did not change.
|
||||
updated, err := client.Template(context.Background(), template.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, template.Name, updated.Name)
|
||||
assert.Equal(t, template.Description, updated.Description)
|
||||
assert.Equal(t, template.Icon, updated.Icon)
|
||||
assert.Equal(t, template.DisplayName, updated.DisplayName)
|
||||
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
"reason": "initiator",
|
||||
"resources": [],
|
||||
"deadline": "[timestamp]",
|
||||
"max_deadline": null,
|
||||
"status": "running",
|
||||
"daily_cost": 0
|
||||
},
|
||||
|
|
|
@ -7,12 +7,17 @@ Flags:
|
|||
--allow-user-cancel-workspace-jobs Allow users to cancel in-progress workspace jobs.
|
||||
(default true)
|
||||
--default-ttl duration Edit the template default time before shutdown -
|
||||
workspaces created from this template to this value.
|
||||
--description string Edit the template description
|
||||
--display-name string Edit the template display name
|
||||
workspaces created from this template default to
|
||||
this value.
|
||||
--description string Edit the template description.
|
||||
--display-name string Edit the template display name.
|
||||
-h, --help help for edit
|
||||
--icon string Edit the template icon path
|
||||
--name string Edit the template name
|
||||
--icon string Edit the template icon path.
|
||||
--max-ttl duration Edit the template maximum time before shutdown -
|
||||
workspaces created from this template must shutdown
|
||||
within the given duration after starting. This is
|
||||
an enterprise-only feature.
|
||||
--name string Edit the template name.
|
||||
-y, --yes Bypass prompts
|
||||
|
||||
Global Flags:
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/util/tz"
|
||||
)
|
||||
|
||||
|
|
|
@ -70,12 +70,16 @@ func activityBumpWorkspace(ctx context.Context, log slog.Logger, db database.Sto
|
|||
}
|
||||
|
||||
newDeadline := database.Now().Add(bumpAmount)
|
||||
if !build.MaxDeadline.IsZero() && newDeadline.After(build.MaxDeadline) {
|
||||
newDeadline = build.MaxDeadline
|
||||
}
|
||||
|
||||
if _, err := s.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
|
||||
ID: build.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
ProvisionerState: build.ProvisionerState,
|
||||
Deadline: newDeadline,
|
||||
MaxDeadline: build.MaxDeadline,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("update workspace build: %w", err)
|
||||
}
|
||||
|
|
|
@ -5,24 +5,51 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
type mockTemplateScheduleStore struct {
|
||||
getFn func(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error)
|
||||
setFn func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error)
|
||||
}
|
||||
|
||||
var _ schedule.TemplateScheduleStore = mockTemplateScheduleStore{}
|
||||
|
||||
func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
||||
if m.getFn != nil {
|
||||
return m.getFn(ctx, db, templateID)
|
||||
}
|
||||
|
||||
return schedule.NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID)
|
||||
}
|
||||
|
||||
func (m mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
|
||||
if m.setFn != nil {
|
||||
return m.setFn(ctx, db, template, options)
|
||||
}
|
||||
|
||||
return schedule.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options)
|
||||
}
|
||||
|
||||
func TestWorkspaceActivityBump(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
const ttl = time.Minute
|
||||
|
||||
setupActivityTest := func(t *testing.T) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) {
|
||||
ttlMillis := int64(ttl / time.Millisecond)
|
||||
setupActivityTest := func(t *testing.T, maxDeadline ...time.Duration) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) {
|
||||
const ttl = time.Minute
|
||||
maxTTL := time.Duration(0)
|
||||
if len(maxDeadline) > 0 {
|
||||
maxTTL = maxDeadline[0]
|
||||
}
|
||||
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
AppHostname: proxyTestSubdomainRaw,
|
||||
|
@ -30,9 +57,19 @@ func TestWorkspaceActivityBump(t *testing.T) {
|
|||
// Agent stats trigger the activity bump, so we want to report
|
||||
// very frequently in tests.
|
||||
AgentStatsRefreshInterval: time.Millisecond * 100,
|
||||
TemplateScheduleStore: mockTemplateScheduleStore{
|
||||
getFn: func(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
||||
return schedule.TemplateScheduleOptions{
|
||||
UserSchedulingEnabled: true,
|
||||
DefaultTTL: ttl,
|
||||
MaxTTL: maxTTL,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
ttlMillis := int64(ttl / time.Millisecond)
|
||||
workspace = createWorkspaceWithApps(t, client, user.OrganizationID, "", 1234, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.TTLMillis = &ttlMillis
|
||||
})
|
||||
|
@ -42,10 +79,21 @@ func TestWorkspaceActivityBump(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.WithinDuration(t,
|
||||
time.Now().Add(time.Duration(ttlMillis)*time.Millisecond),
|
||||
workspace.LatestBuild.Deadline.Time, testutil.WaitMedium,
|
||||
workspace.LatestBuild.Deadline.Time,
|
||||
testutil.WaitMedium,
|
||||
)
|
||||
firstDeadline := workspace.LatestBuild.Deadline.Time
|
||||
|
||||
if maxTTL != 0 {
|
||||
require.WithinDuration(t,
|
||||
time.Now().Add(maxTTL),
|
||||
workspace.LatestBuild.MaxDeadline.Time,
|
||||
testutil.WaitMedium,
|
||||
)
|
||||
} else {
|
||||
require.True(t, workspace.LatestBuild.MaxDeadline.Time.IsZero())
|
||||
}
|
||||
|
||||
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
return client, workspace, func(want bool) {
|
||||
|
@ -74,6 +122,12 @@ func TestWorkspaceActivityBump(t *testing.T) {
|
|||
"deadline %v never updated", firstDeadline,
|
||||
)
|
||||
|
||||
// If the workspace has a max deadline, the deadline must not exceed
|
||||
// it.
|
||||
if maxTTL != 0 && database.Now().Add(ttl).After(workspace.LatestBuild.MaxDeadline.Time) {
|
||||
require.Equal(t, workspace.LatestBuild.Deadline.Time, workspace.LatestBuild.MaxDeadline.Time)
|
||||
return
|
||||
}
|
||||
require.WithinDuration(t, database.Now().Add(ttl), workspace.LatestBuild.Deadline.Time, 3*time.Second)
|
||||
}
|
||||
}
|
||||
|
@ -111,4 +165,34 @@ func TestWorkspaceActivityBump(t *testing.T) {
|
|||
|
||||
assertBumped(false)
|
||||
})
|
||||
|
||||
t.Run("NotExceedMaxDeadline", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Set the max deadline to be in 61 seconds. We bump by 1 minute, so we
|
||||
// should expect the deadline to match the max deadline exactly.
|
||||
client, workspace, assertBumped := setupActivityTest(t, 61*time.Second)
|
||||
|
||||
// Bump by dialing the workspace and sending traffic.
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
// Must send network traffic after a few seconds to surpass bump threshold.
|
||||
time.Sleep(time.Second * 3)
|
||||
sshConn, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
_ = sshConn.Close()
|
||||
|
||||
assertBumped(true)
|
||||
|
||||
// Double check that the workspace build's deadline is equal to the
|
||||
// max deadline.
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, workspace.LatestBuild.Deadline.Time, workspace.LatestBuild.MaxDeadline.Time)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -5875,6 +5875,10 @@ const docTemplate = `{
|
|||
"description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.",
|
||||
"type": "string"
|
||||
},
|
||||
"max_ttl_ms": {
|
||||
"description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name is the name of the template.",
|
||||
"type": "string"
|
||||
|
@ -7729,6 +7733,10 @@ const docTemplate = `{
|
|||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"max_ttl_ms": {
|
||||
"description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
@ -8653,6 +8661,10 @@ const docTemplate = `{
|
|||
"job": {
|
||||
"$ref": "#/definitions/codersdk.ProvisionerJob"
|
||||
},
|
||||
"max_deadline": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"initiator",
|
||||
|
|
|
@ -5215,6 +5215,10 @@
|
|||
"description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.",
|
||||
"type": "string"
|
||||
},
|
||||
"max_ttl_ms": {
|
||||
"description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name is the name of the template.",
|
||||
"type": "string"
|
||||
|
@ -6950,6 +6954,10 @@
|
|||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"max_ttl_ms": {
|
||||
"description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
@ -7802,6 +7810,10 @@
|
|||
"job": {
|
||||
"$ref": "#/definitions/codersdk.ProvisionerJob"
|
||||
},
|
||||
"max_deadline": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"reason": {
|
||||
"enum": ["initiator", "autostart", "autostop"],
|
||||
"allOf": [
|
||||
|
|
|
@ -10,10 +10,10 @@ import (
|
|||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/coderd/provisionerdserver"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
)
|
||||
|
||||
// Executor automatically starts or stops workspaces.
|
||||
|
|
|
@ -11,9 +11,9 @@ import (
|
|||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/coderd/autobuild/executor"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
|
|
|
@ -51,6 +51,7 @@ import (
|
|||
"github.com/coder/coder/coderd/metricscache"
|
||||
"github.com/coder/coder/coderd/provisionerdserver"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/telemetry"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
"github.com/coder/coder/coderd/updatecheck"
|
||||
|
@ -112,12 +113,13 @@ type Options struct {
|
|||
RealIPConfig *httpmw.RealIPConfig
|
||||
TrialGenerator func(ctx context.Context, email string) error
|
||||
// TLSCertificates is used to mesh DERP servers securely.
|
||||
TLSCertificates []tls.Certificate
|
||||
TailnetCoordinator tailnet.Coordinator
|
||||
DERPServer *derp.Server
|
||||
DERPMap *tailcfg.DERPMap
|
||||
SwaggerEndpoint bool
|
||||
SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error
|
||||
TLSCertificates []tls.Certificate
|
||||
TailnetCoordinator tailnet.Coordinator
|
||||
DERPServer *derp.Server
|
||||
DERPMap *tailcfg.DERPMap
|
||||
SwaggerEndpoint bool
|
||||
SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error
|
||||
TemplateScheduleStore schedule.TemplateScheduleStore
|
||||
|
||||
// APIRateLimit is the minutely throughput rate limit per user or ip.
|
||||
// Setting a rate limit <0 will disable the rate limiter across the entire
|
||||
|
@ -209,6 +211,9 @@ func New(options *Options) *API {
|
|||
if options.SetUserGroups == nil {
|
||||
options.SetUserGroups = func(context.Context, database.Store, uuid.UUID, []string) error { return nil }
|
||||
}
|
||||
if options.TemplateScheduleStore == nil {
|
||||
options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore()
|
||||
}
|
||||
|
||||
siteCacheDir := options.CacheDir
|
||||
if siteCacheDir != "" {
|
||||
|
@ -245,9 +250,10 @@ func New(options *Options) *API {
|
|||
Authorizer: options.Authorizer,
|
||||
Logger: options.Logger,
|
||||
},
|
||||
metricsCache: metricsCache,
|
||||
Auditor: atomic.Pointer[audit.Auditor]{},
|
||||
Experiments: experiments,
|
||||
metricsCache: metricsCache,
|
||||
Auditor: atomic.Pointer[audit.Auditor]{},
|
||||
TemplateScheduleStore: atomic.Pointer[schedule.TemplateScheduleStore]{},
|
||||
Experiments: experiments,
|
||||
}
|
||||
if options.UpdateCheckOptions != nil {
|
||||
api.updateChecker = updatecheck.New(
|
||||
|
@ -257,6 +263,7 @@ func New(options *Options) *API {
|
|||
)
|
||||
}
|
||||
api.Auditor.Store(&options.Auditor)
|
||||
api.TemplateScheduleStore.Store(&options.TemplateScheduleStore)
|
||||
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
|
||||
api.TailnetCoordinator.Store(&options.TailnetCoordinator)
|
||||
oauthConfigs := &httpmw.OAuth2Configs{
|
||||
|
@ -720,6 +727,7 @@ type API struct {
|
|||
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
|
||||
TailnetCoordinator atomic.Pointer[tailnet.Coordinator]
|
||||
QuotaCommitter atomic.Pointer[proto.QuotaCommitter]
|
||||
TemplateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore]
|
||||
|
||||
HTTPAuth *HTTPAuthorizer
|
||||
|
||||
|
@ -820,18 +828,19 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti
|
|||
gitAuthProviders = append(gitAuthProviders, cfg.ID)
|
||||
}
|
||||
err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{
|
||||
AccessURL: api.AccessURL,
|
||||
ID: daemon.ID,
|
||||
Database: api.Database,
|
||||
Pubsub: api.Pubsub,
|
||||
Provisioners: daemon.Provisioners,
|
||||
GitAuthProviders: gitAuthProviders,
|
||||
Telemetry: api.Telemetry,
|
||||
Tags: tags,
|
||||
QuotaCommitter: &api.QuotaCommitter,
|
||||
Auditor: &api.Auditor,
|
||||
AcquireJobDebounce: debounce,
|
||||
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
|
||||
AccessURL: api.AccessURL,
|
||||
ID: daemon.ID,
|
||||
Database: api.Database,
|
||||
Pubsub: api.Pubsub,
|
||||
Provisioners: daemon.Provisioners,
|
||||
GitAuthProviders: gitAuthProviders,
|
||||
Telemetry: api.Telemetry,
|
||||
Tags: tags,
|
||||
QuotaCommitter: &api.QuotaCommitter,
|
||||
Auditor: &api.Auditor,
|
||||
TemplateScheduleStore: &api.TemplateScheduleStore,
|
||||
AcquireJobDebounce: debounce,
|
||||
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -66,6 +66,7 @@ import (
|
|||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/telemetry"
|
||||
"github.com/coder/coder/coderd/updatecheck"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
|
@ -85,22 +86,23 @@ type Options struct {
|
|||
// AccessURL denotes a custom access URL. By default we use the httptest
|
||||
// server's URL. Setting this may result in unexpected behavior (especially
|
||||
// with running agents).
|
||||
AccessURL *url.URL
|
||||
AppHostname string
|
||||
AWSCertificates awsidentity.Certificates
|
||||
Authorizer rbac.Authorizer
|
||||
AzureCertificates x509.VerifyOptions
|
||||
GithubOAuth2Config *coderd.GithubOAuth2Config
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
OIDCConfig *coderd.OIDCConfig
|
||||
GoogleTokenValidator *idtoken.Validator
|
||||
SSHKeygenAlgorithm gitsshkey.Algorithm
|
||||
AutobuildTicker <-chan time.Time
|
||||
AutobuildStats chan<- executor.Stats
|
||||
Auditor audit.Auditor
|
||||
TLSCertificates []tls.Certificate
|
||||
GitAuthConfigs []*gitauth.Config
|
||||
TrialGenerator func(context.Context, string) error
|
||||
AccessURL *url.URL
|
||||
AppHostname string
|
||||
AWSCertificates awsidentity.Certificates
|
||||
Authorizer rbac.Authorizer
|
||||
AzureCertificates x509.VerifyOptions
|
||||
GithubOAuth2Config *coderd.GithubOAuth2Config
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
OIDCConfig *coderd.OIDCConfig
|
||||
GoogleTokenValidator *idtoken.Validator
|
||||
SSHKeygenAlgorithm gitsshkey.Algorithm
|
||||
AutobuildTicker <-chan time.Time
|
||||
AutobuildStats chan<- executor.Stats
|
||||
Auditor audit.Auditor
|
||||
TLSCertificates []tls.Certificate
|
||||
GitAuthConfigs []*gitauth.Config
|
||||
TrialGenerator func(context.Context, string) error
|
||||
TemplateScheduleStore schedule.TemplateScheduleStore
|
||||
|
||||
// All rate limits default to -1 (unlimited) in tests if not set.
|
||||
APIRateLimit int
|
||||
|
@ -287,22 +289,23 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can
|
|||
Pubsub: options.Pubsub,
|
||||
GitAuthConfigs: options.GitAuthConfigs,
|
||||
|
||||
Auditor: options.Auditor,
|
||||
AWSCertificates: options.AWSCertificates,
|
||||
AzureCertificates: options.AzureCertificates,
|
||||
GithubOAuth2Config: options.GithubOAuth2Config,
|
||||
RealIPConfig: options.RealIPConfig,
|
||||
OIDCConfig: options.OIDCConfig,
|
||||
GoogleTokenValidator: options.GoogleTokenValidator,
|
||||
SSHKeygenAlgorithm: options.SSHKeygenAlgorithm,
|
||||
DERPServer: derpServer,
|
||||
APIRateLimit: options.APIRateLimit,
|
||||
LoginRateLimit: options.LoginRateLimit,
|
||||
FilesRateLimit: options.FilesRateLimit,
|
||||
Authorizer: options.Authorizer,
|
||||
Telemetry: telemetry.NewNoop(),
|
||||
TLSCertificates: options.TLSCertificates,
|
||||
TrialGenerator: options.TrialGenerator,
|
||||
Auditor: options.Auditor,
|
||||
AWSCertificates: options.AWSCertificates,
|
||||
AzureCertificates: options.AzureCertificates,
|
||||
GithubOAuth2Config: options.GithubOAuth2Config,
|
||||
RealIPConfig: options.RealIPConfig,
|
||||
OIDCConfig: options.OIDCConfig,
|
||||
GoogleTokenValidator: options.GoogleTokenValidator,
|
||||
SSHKeygenAlgorithm: options.SSHKeygenAlgorithm,
|
||||
DERPServer: derpServer,
|
||||
APIRateLimit: options.APIRateLimit,
|
||||
LoginRateLimit: options.LoginRateLimit,
|
||||
FilesRateLimit: options.FilesRateLimit,
|
||||
Authorizer: options.Authorizer,
|
||||
Telemetry: telemetry.NewNoop(),
|
||||
TemplateScheduleStore: options.TemplateScheduleStore,
|
||||
TLSCertificates: options.TLSCertificates,
|
||||
TrialGenerator: options.TrialGenerator,
|
||||
DERPMap: &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{
|
||||
1: {
|
||||
|
|
|
@ -825,6 +825,13 @@ func (q *querier) UpdateTemplateMetaByID(ctx context.Context, arg database.Updat
|
|||
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateTemplateMetaByID)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateTemplateScheduleByID(ctx context.Context, arg database.UpdateTemplateScheduleByIDParams) (database.Template, error) {
|
||||
fetch := func(ctx context.Context, arg database.UpdateTemplateScheduleByIDParams) (database.Template, error) {
|
||||
return q.db.GetTemplateByID(ctx, arg.ID)
|
||||
}
|
||||
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateTemplateScheduleByID)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error {
|
||||
template, err := q.db.GetTemplateByID(ctx, arg.TemplateID.UUID)
|
||||
if err != nil {
|
||||
|
@ -1496,6 +1503,13 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up
|
|||
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error {
|
||||
fetch := func(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) (database.Template, error) {
|
||||
return q.db.GetTemplateByID(ctx, arg.TemplateID)
|
||||
}
|
||||
return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspaceTTLToBeWithinTemplateMax)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWorkspaceTTLParams) error {
|
||||
fetch := func(ctx context.Context, arg database.UpdateWorkspaceTTLParams) (database.Workspace, error) {
|
||||
return q.db.GetWorkspaceByID(ctx, arg.ID)
|
||||
|
|
|
@ -1686,8 +1686,8 @@ func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd
|
|||
return database.Template{}, err
|
||||
}
|
||||
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for idx, tpl := range q.templates {
|
||||
if tpl.ID != arg.ID {
|
||||
|
@ -1698,7 +1698,28 @@ func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd
|
|||
tpl.DisplayName = arg.DisplayName
|
||||
tpl.Description = arg.Description
|
||||
tpl.Icon = arg.Icon
|
||||
q.templates[idx] = tpl
|
||||
return tpl, nil
|
||||
}
|
||||
|
||||
return database.Template{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database.UpdateTemplateScheduleByIDParams) (database.Template, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.Template{}, err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for idx, tpl := range q.templates {
|
||||
if tpl.ID != arg.ID {
|
||||
continue
|
||||
}
|
||||
tpl.UpdatedAt = database.Now()
|
||||
tpl.DefaultTTL = arg.DefaultTTL
|
||||
tpl.MaxTTL = arg.MaxTTL
|
||||
q.templates[idx] = tpl
|
||||
return tpl, nil
|
||||
}
|
||||
|
@ -2637,7 +2658,6 @@ func (q *fakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl
|
|||
Provisioner: arg.Provisioner,
|
||||
ActiveVersionID: arg.ActiveVersionID,
|
||||
Description: arg.Description,
|
||||
DefaultTTL: arg.DefaultTTL,
|
||||
CreatedBy: arg.CreatedBy,
|
||||
UserACL: arg.UserACL,
|
||||
GroupACL: arg.GroupACL,
|
||||
|
@ -3532,6 +3552,26 @@ func (q *fakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.
|
|||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(_ context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for index, workspace := range q.workspaces {
|
||||
if workspace.TemplateID != arg.TemplateID || !workspace.Ttl.Valid || workspace.Ttl.Int64 < arg.TemplateMaxTTL {
|
||||
continue
|
||||
}
|
||||
|
||||
workspace.Ttl = sql.NullInt64{Int64: arg.TemplateMaxTTL, Valid: true}
|
||||
q.workspaces[index] = workspace
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.UpdateWorkspaceBuildByIDParams) (database.WorkspaceBuild, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.WorkspaceBuild{}, err
|
||||
|
@ -3547,6 +3587,7 @@ func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.U
|
|||
workspaceBuild.UpdatedAt = arg.UpdatedAt
|
||||
workspaceBuild.ProvisionerState = arg.ProvisionerState
|
||||
workspaceBuild.Deadline = arg.Deadline
|
||||
workspaceBuild.MaxDeadline = arg.MaxDeadline
|
||||
q.workspaceBuilds[index] = workspaceBuild
|
||||
return workspaceBuild, nil
|
||||
}
|
||||
|
|
|
@ -60,7 +60,6 @@ func Template(t testing.TB, db database.Store, seed database.Template) database.
|
|||
Provisioner: takeFirst(seed.Provisioner, database.ProvisionerTypeEcho),
|
||||
ActiveVersionID: takeFirst(seed.ActiveVersionID, uuid.New()),
|
||||
Description: takeFirst(seed.Description, namesgenerator.GetRandomName(1)),
|
||||
DefaultTTL: takeFirst(seed.DefaultTTL, 3600),
|
||||
CreatedBy: takeFirst(seed.CreatedBy, uuid.New()),
|
||||
Icon: takeFirst(seed.Icon, namesgenerator.GetRandomName(1)),
|
||||
UserACL: seed.UserACL,
|
||||
|
|
|
@ -434,7 +434,8 @@ CREATE TABLE templates (
|
|||
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
display_name character varying(64) DEFAULT ''::character varying NOT NULL,
|
||||
allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL
|
||||
allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL,
|
||||
max_ttl bigint DEFAULT '0'::bigint NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.';
|
||||
|
@ -579,7 +580,8 @@ CREATE TABLE workspace_builds (
|
|||
job_id uuid NOT NULL,
|
||||
deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL,
|
||||
reason build_reason DEFAULT 'initiator'::build_reason NOT NULL,
|
||||
daily_cost integer DEFAULT 0 NOT NULL
|
||||
daily_cost integer DEFAULT 0 NOT NULL,
|
||||
max_deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE workspace_resource_metadata (
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "workspace_builds" DROP COLUMN "max_deadline";
|
||||
|
||||
ALTER TABLE "templates" DROP COLUMN "max_ttl";
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "templates" ADD COLUMN "max_ttl" bigint DEFAULT '0'::bigint NOT NULL;
|
||||
|
||||
ALTER TABLE "workspace_builds" ADD COLUMN "max_deadline" timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL;
|
|
@ -77,6 +77,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate
|
|||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
&i.MaxTTL,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -1423,7 +1423,8 @@ type Template struct {
|
|||
// Display name is a custom, human-friendly template name that user can set.
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
// Allow users to cancel in-progress workspace jobs.
|
||||
AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"`
|
||||
AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"`
|
||||
MaxTTL int64 `db:"max_ttl" json:"max_ttl"`
|
||||
}
|
||||
|
||||
type TemplateVersion struct {
|
||||
|
@ -1617,6 +1618,7 @@ type WorkspaceBuild struct {
|
|||
Deadline time.Time `db:"deadline" json:"deadline"`
|
||||
Reason BuildReason `db:"reason" json:"reason"`
|
||||
DailyCost int32 `db:"daily_cost" json:"daily_cost"`
|
||||
MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"`
|
||||
}
|
||||
|
||||
type WorkspaceBuildParameter struct {
|
||||
|
|
|
@ -190,6 +190,7 @@ type sqlcQuerier interface {
|
|||
UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error
|
||||
UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error
|
||||
UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error)
|
||||
UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) (Template, error)
|
||||
UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error
|
||||
UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error
|
||||
UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionGitAuthProvidersByJobIDParams) error
|
||||
|
@ -212,6 +213,7 @@ type sqlcQuerier interface {
|
|||
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
|
||||
UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error
|
||||
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
|
||||
UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error
|
||||
}
|
||||
|
||||
var _ sqlcQuerier = (*sqlQuerier)(nil)
|
||||
|
|
|
@ -2387,7 +2387,7 @@ WHERE
|
|||
-- Ensure the caller has the correct provisioner.
|
||||
AND nested.provisioner = ANY($3 :: provisioner_type [ ])
|
||||
-- Ensure the caller satisfies all job tags.
|
||||
AND nested.tags <@ $4 :: jsonb
|
||||
AND nested.tags <@ $4 :: jsonb
|
||||
ORDER BY
|
||||
nested.created_at
|
||||
FOR UPDATE
|
||||
|
@ -3074,7 +3074,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem
|
|||
|
||||
const getTemplateByID = `-- name: GetTemplateByID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
|
||||
FROM
|
||||
templates
|
||||
WHERE
|
||||
|
@ -3103,13 +3103,14 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat
|
|||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
&i.MaxTTL,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one
|
||||
SELECT
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
|
||||
FROM
|
||||
templates
|
||||
WHERE
|
||||
|
@ -3146,12 +3147,13 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G
|
|||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
&i.MaxTTL,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTemplates = `-- name: GetTemplates :many
|
||||
SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs FROM templates
|
||||
SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl FROM templates
|
||||
ORDER BY (name, id) ASC
|
||||
`
|
||||
|
||||
|
@ -3181,6 +3183,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) {
|
|||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
&i.MaxTTL,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -3197,7 +3200,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) {
|
|||
|
||||
const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many
|
||||
SELECT
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
|
||||
FROM
|
||||
templates
|
||||
WHERE
|
||||
|
@ -3264,6 +3267,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate
|
|||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
&i.MaxTTL,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -3289,7 +3293,6 @@ INSERT INTO
|
|||
provisioner,
|
||||
active_version_id,
|
||||
description,
|
||||
default_ttl,
|
||||
created_by,
|
||||
icon,
|
||||
user_acl,
|
||||
|
@ -3298,7 +3301,7 @@ INSERT INTO
|
|||
allow_user_cancel_workspace_jobs
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
|
||||
`
|
||||
|
||||
type InsertTemplateParams struct {
|
||||
|
@ -3310,7 +3313,6 @@ type InsertTemplateParams struct {
|
|||
Provisioner ProvisionerType `db:"provisioner" json:"provisioner"`
|
||||
ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"`
|
||||
Description string `db:"description" json:"description"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
UserACL TemplateACL `db:"user_acl" json:"user_acl"`
|
||||
|
@ -3329,7 +3331,6 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam
|
|||
arg.Provisioner,
|
||||
arg.ActiveVersionID,
|
||||
arg.Description,
|
||||
arg.DefaultTTL,
|
||||
arg.CreatedBy,
|
||||
arg.Icon,
|
||||
arg.UserACL,
|
||||
|
@ -3355,6 +3356,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam
|
|||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
&i.MaxTTL,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -3368,7 +3370,7 @@ SET
|
|||
WHERE
|
||||
id = $3
|
||||
RETURNING
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
|
||||
`
|
||||
|
||||
type UpdateTemplateACLByIDParams struct {
|
||||
|
@ -3397,6 +3399,7 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla
|
|||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
&i.MaxTTL,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -3449,22 +3452,20 @@ UPDATE
|
|||
SET
|
||||
updated_at = $2,
|
||||
description = $3,
|
||||
default_ttl = $4,
|
||||
name = $5,
|
||||
icon = $6,
|
||||
display_name = $7,
|
||||
allow_user_cancel_workspace_jobs = $8
|
||||
name = $4,
|
||||
icon = $5,
|
||||
display_name = $6,
|
||||
allow_user_cancel_workspace_jobs = $7
|
||||
WHERE
|
||||
id = $1
|
||||
RETURNING
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
|
||||
`
|
||||
|
||||
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"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
|
@ -3476,7 +3477,6 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl
|
|||
arg.ID,
|
||||
arg.UpdatedAt,
|
||||
arg.Description,
|
||||
arg.DefaultTTL,
|
||||
arg.Name,
|
||||
arg.Icon,
|
||||
arg.DisplayName,
|
||||
|
@ -3500,6 +3500,57 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl
|
|||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
&i.MaxTTL,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateTemplateScheduleByID = `-- name: UpdateTemplateScheduleByID :one
|
||||
UPDATE
|
||||
templates
|
||||
SET
|
||||
updated_at = $2,
|
||||
default_ttl = $3,
|
||||
max_ttl = $4
|
||||
WHERE
|
||||
id = $1
|
||||
RETURNING
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
|
||||
`
|
||||
|
||||
type UpdateTemplateScheduleByIDParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
MaxTTL int64 `db:"max_ttl" json:"max_ttl"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) (Template, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateTemplateScheduleByID,
|
||||
arg.ID,
|
||||
arg.UpdatedAt,
|
||||
arg.DefaultTTL,
|
||||
arg.MaxTTL,
|
||||
)
|
||||
var i Template
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.OrganizationID,
|
||||
&i.Deleted,
|
||||
&i.Name,
|
||||
&i.Provisioner,
|
||||
&i.ActiveVersionID,
|
||||
&i.Description,
|
||||
&i.DefaultTTL,
|
||||
&i.CreatedBy,
|
||||
&i.Icon,
|
||||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
&i.MaxTTL,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -5870,7 +5921,7 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins
|
|||
|
||||
const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline
|
||||
FROM
|
||||
workspace_builds
|
||||
WHERE
|
||||
|
@ -5898,12 +5949,13 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLatestWorkspaceBuilds = `-- name: GetLatestWorkspaceBuilds :many
|
||||
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost
|
||||
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline
|
||||
FROM (
|
||||
SELECT
|
||||
workspace_id, MAX(build_number) as max_build_number
|
||||
|
@ -5940,6 +5992,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -5955,7 +6008,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB
|
|||
}
|
||||
|
||||
const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many
|
||||
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost
|
||||
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline
|
||||
FROM (
|
||||
SELECT
|
||||
workspace_id, MAX(build_number) as max_build_number
|
||||
|
@ -5994,6 +6047,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context,
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -6010,7 +6064,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context,
|
|||
|
||||
const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline
|
||||
FROM
|
||||
workspace_builds
|
||||
WHERE
|
||||
|
@ -6036,13 +6090,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline
|
||||
FROM
|
||||
workspace_builds
|
||||
WHERE
|
||||
|
@ -6068,13 +6123,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceBuildByWorkspaceIDAndBuildNumber = `-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline
|
||||
FROM
|
||||
workspace_builds
|
||||
WHERE
|
||||
|
@ -6104,13 +6160,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline
|
||||
FROM
|
||||
workspace_builds
|
||||
WHERE
|
||||
|
@ -6179,6 +6236,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -6194,7 +6252,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge
|
|||
}
|
||||
|
||||
const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many
|
||||
SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost FROM workspace_builds WHERE created_at > $1
|
||||
SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE created_at > $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) {
|
||||
|
@ -6220,6 +6278,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -6248,10 +6307,11 @@ INSERT INTO
|
|||
job_id,
|
||||
provisioner_state,
|
||||
deadline,
|
||||
max_deadline,
|
||||
reason
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline
|
||||
`
|
||||
|
||||
type InsertWorkspaceBuildParams struct {
|
||||
|
@ -6266,6 +6326,7 @@ type InsertWorkspaceBuildParams struct {
|
|||
JobID uuid.UUID `db:"job_id" json:"job_id"`
|
||||
ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"`
|
||||
Deadline time.Time `db:"deadline" json:"deadline"`
|
||||
MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"`
|
||||
Reason BuildReason `db:"reason" json:"reason"`
|
||||
}
|
||||
|
||||
|
@ -6282,6 +6343,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa
|
|||
arg.JobID,
|
||||
arg.ProvisionerState,
|
||||
arg.Deadline,
|
||||
arg.MaxDeadline,
|
||||
arg.Reason,
|
||||
)
|
||||
var i WorkspaceBuild
|
||||
|
@ -6299,6 +6361,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -6309,9 +6372,10 @@ UPDATE
|
|||
SET
|
||||
updated_at = $2,
|
||||
provisioner_state = $3,
|
||||
deadline = $4
|
||||
deadline = $4,
|
||||
max_deadline = $5
|
||||
WHERE
|
||||
id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
|
||||
id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline
|
||||
`
|
||||
|
||||
type UpdateWorkspaceBuildByIDParams struct {
|
||||
|
@ -6319,6 +6383,7 @@ type UpdateWorkspaceBuildByIDParams struct {
|
|||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"`
|
||||
Deadline time.Time `db:"deadline" json:"deadline"`
|
||||
MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) (WorkspaceBuild, error) {
|
||||
|
@ -6327,6 +6392,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor
|
|||
arg.UpdatedAt,
|
||||
arg.ProvisionerState,
|
||||
arg.Deadline,
|
||||
arg.MaxDeadline,
|
||||
)
|
||||
var i WorkspaceBuild
|
||||
err := row.Scan(
|
||||
|
@ -6343,6 +6409,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -6353,7 +6420,7 @@ UPDATE
|
|||
SET
|
||||
daily_cost = $2
|
||||
WHERE
|
||||
id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost
|
||||
id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline
|
||||
`
|
||||
|
||||
type UpdateWorkspaceBuildCostByIDParams struct {
|
||||
|
@ -6378,6 +6445,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildCostByID(ctx context.Context, arg Updat
|
|||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -7298,3 +7366,28 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace
|
|||
_, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceTTLToBeWithinTemplateMax = `-- name: UpdateWorkspaceTTLToBeWithinTemplateMax :exec
|
||||
UPDATE
|
||||
workspaces
|
||||
SET
|
||||
ttl = LEAST(ttl, $1::bigint)
|
||||
WHERE
|
||||
template_id = $2
|
||||
-- LEAST() does not pick NULL, so filter it out as we don't want to set a
|
||||
-- TTL on the workspace if it's unset.
|
||||
--
|
||||
-- During build time, the template max TTL will still be used if the
|
||||
-- workspace TTL is NULL.
|
||||
AND ttl IS NOT NULL
|
||||
`
|
||||
|
||||
type UpdateWorkspaceTTLToBeWithinTemplateMaxParams struct {
|
||||
TemplateMaxTTL int64 `db:"template_max_ttl" json:"template_max_ttl"`
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateWorkspaceTTLToBeWithinTemplateMax, arg.TemplateMaxTTL, arg.TemplateID)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ WHERE
|
|||
-- Ensure the caller has the correct provisioner.
|
||||
AND nested.provisioner = ANY(@types :: provisioner_type [ ])
|
||||
-- Ensure the caller satisfies all job tags.
|
||||
AND nested.tags <@ @tags :: jsonb
|
||||
AND nested.tags <@ @tags :: jsonb
|
||||
ORDER BY
|
||||
nested.created_at
|
||||
FOR UPDATE
|
||||
|
|
|
@ -67,7 +67,6 @@ INSERT INTO
|
|||
provisioner,
|
||||
active_version_id,
|
||||
description,
|
||||
default_ttl,
|
||||
created_by,
|
||||
icon,
|
||||
user_acl,
|
||||
|
@ -76,7 +75,7 @@ INSERT INTO
|
|||
allow_user_cancel_workspace_jobs
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *;
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *;
|
||||
|
||||
-- name: UpdateTemplateActiveVersionByID :exec
|
||||
UPDATE
|
||||
|
@ -102,11 +101,22 @@ UPDATE
|
|||
SET
|
||||
updated_at = $2,
|
||||
description = $3,
|
||||
default_ttl = $4,
|
||||
name = $5,
|
||||
icon = $6,
|
||||
display_name = $7,
|
||||
allow_user_cancel_workspace_jobs = $8
|
||||
name = $4,
|
||||
icon = $5,
|
||||
display_name = $6,
|
||||
allow_user_cancel_workspace_jobs = $7
|
||||
WHERE
|
||||
id = $1
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: UpdateTemplateScheduleByID :one
|
||||
UPDATE
|
||||
templates
|
||||
SET
|
||||
updated_at = $2,
|
||||
default_ttl = $3,
|
||||
max_ttl = $4
|
||||
WHERE
|
||||
id = $1
|
||||
RETURNING
|
||||
|
|
|
@ -119,10 +119,11 @@ INSERT INTO
|
|||
job_id,
|
||||
provisioner_state,
|
||||
deadline,
|
||||
max_deadline,
|
||||
reason
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *;
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *;
|
||||
|
||||
-- name: UpdateWorkspaceBuildByID :one
|
||||
UPDATE
|
||||
|
@ -130,7 +131,8 @@ UPDATE
|
|||
SET
|
||||
updated_at = $2,
|
||||
provisioner_state = $3,
|
||||
deadline = $4
|
||||
deadline = $4,
|
||||
max_deadline = $5
|
||||
WHERE
|
||||
id = $1 RETURNING *;
|
||||
|
||||
|
|
|
@ -316,3 +316,17 @@ SET
|
|||
last_used_at = $2
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: UpdateWorkspaceTTLToBeWithinTemplateMax :exec
|
||||
UPDATE
|
||||
workspaces
|
||||
SET
|
||||
ttl = LEAST(ttl, @template_max_ttl::bigint)
|
||||
WHERE
|
||||
template_id = @template_id
|
||||
-- LEAST() does not pick NULL, so filter it out as we don't want to set a
|
||||
-- TTL on the workspace if it's unset.
|
||||
--
|
||||
-- During build time, the template max TTL will still be used if the
|
||||
-- workspace TTL is NULL.
|
||||
AND ttl IS NOT NULL;
|
||||
|
|
|
@ -47,6 +47,8 @@ overrides:
|
|||
group_acl: GroupACL
|
||||
troubleshooting_url: TroubleshootingURL
|
||||
default_ttl: DefaultTTL
|
||||
max_ttl: MaxTTL
|
||||
template_max_ttl: TemplateMaxTTL
|
||||
motd_file: MOTDFile
|
||||
uuid: UUID
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import (
|
|||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/coderd/parameter"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/telemetry"
|
||||
"github.com/coder/coder/coderd/util/slice"
|
||||
"github.com/coder/coder/codersdk"
|
||||
|
@ -43,17 +44,18 @@ var (
|
|||
)
|
||||
|
||||
type Server struct {
|
||||
AccessURL *url.URL
|
||||
ID uuid.UUID
|
||||
Logger slog.Logger
|
||||
Provisioners []database.ProvisionerType
|
||||
GitAuthProviders []string
|
||||
Tags json.RawMessage
|
||||
Database database.Store
|
||||
Pubsub database.Pubsub
|
||||
Telemetry telemetry.Reporter
|
||||
QuotaCommitter *atomic.Pointer[proto.QuotaCommitter]
|
||||
Auditor *atomic.Pointer[audit.Auditor]
|
||||
AccessURL *url.URL
|
||||
ID uuid.UUID
|
||||
Logger slog.Logger
|
||||
Provisioners []database.ProvisionerType
|
||||
GitAuthProviders []string
|
||||
Tags json.RawMessage
|
||||
Database database.Store
|
||||
Pubsub database.Pubsub
|
||||
Telemetry telemetry.Reporter
|
||||
QuotaCommitter *atomic.Pointer[proto.QuotaCommitter]
|
||||
Auditor *atomic.Pointer[audit.Auditor]
|
||||
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
||||
|
||||
AcquireJobDebounce time.Duration
|
||||
}
|
||||
|
@ -661,15 +663,31 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p
|
|||
if err != nil {
|
||||
return nil, xerrors.Errorf("unmarshal workspace provision input: %w", err)
|
||||
}
|
||||
build, err := server.Database.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
|
||||
ID: input.WorkspaceBuildID,
|
||||
UpdatedAt: database.Now(),
|
||||
ProvisionerState: jobType.WorkspaceBuild.State,
|
||||
// We are explicitly not updating deadline here.
|
||||
})
|
||||
|
||||
var build database.WorkspaceBuild
|
||||
err := server.Database.InTx(func(db database.Store) error {
|
||||
workspaceBuild, err := db.GetWorkspaceBuildByID(ctx, input.WorkspaceBuildID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace build: %w", err)
|
||||
}
|
||||
|
||||
build, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
|
||||
ID: input.WorkspaceBuildID,
|
||||
UpdatedAt: database.Now(),
|
||||
ProvisionerState: jobType.WorkspaceBuild.State,
|
||||
Deadline: workspaceBuild.Deadline,
|
||||
MaxDeadline: workspaceBuild.MaxDeadline,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace build state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("update workspace build state: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = server.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), []byte{})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("update workspace: %w", err)
|
||||
|
@ -739,6 +757,8 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p
|
|||
}
|
||||
|
||||
// CompleteJob is triggered by a provision daemon to mark a provisioner job as completed.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func (server *Server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) (*proto.Empty, error) {
|
||||
//nolint:gocritic // Provisionerd has specific authz rules.
|
||||
ctx = dbauthz.AsProvisionerd(ctx)
|
||||
|
@ -867,18 +887,48 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete
|
|||
var getWorkspaceError error
|
||||
|
||||
err = server.Database.InTx(func(db database.Store) error {
|
||||
now := database.Now()
|
||||
var workspaceDeadline time.Time
|
||||
var (
|
||||
now = database.Now()
|
||||
// deadline is the time when the workspace will be stopped. The
|
||||
// value can be bumped by user activity or manually by the user
|
||||
// via the UI.
|
||||
deadline time.Time
|
||||
// maxDeadline is the maximum value for deadline.
|
||||
maxDeadline time.Time
|
||||
)
|
||||
|
||||
workspace, getWorkspaceError = db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
|
||||
if getWorkspaceError == nil {
|
||||
if workspace.Ttl.Valid {
|
||||
workspaceDeadline = now.Add(time.Duration(workspace.Ttl.Int64))
|
||||
}
|
||||
} else {
|
||||
// Huh? Did the workspace get deleted?
|
||||
// In any case, since this is just for the TTL, try and continue anyway.
|
||||
server.Logger.Error(ctx, "fetch workspace for build", slog.F("workspace_build_id", workspaceBuild.ID), slog.F("workspace_id", workspaceBuild.WorkspaceID))
|
||||
if getWorkspaceError != nil {
|
||||
server.Logger.Error(ctx,
|
||||
"fetch workspace for build",
|
||||
slog.F("workspace_build_id", workspaceBuild.ID),
|
||||
slog.F("workspace_id", workspaceBuild.WorkspaceID),
|
||||
)
|
||||
return getWorkspaceError
|
||||
}
|
||||
if workspace.Ttl.Valid {
|
||||
deadline = now.Add(time.Duration(workspace.Ttl.Int64))
|
||||
}
|
||||
|
||||
templateSchedule, err := (*server.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, db, workspace.TemplateID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template schedule options: %w", err)
|
||||
}
|
||||
if !templateSchedule.UserSchedulingEnabled {
|
||||
// The user is not permitted to set their own TTL.
|
||||
deadline = time.Time{}
|
||||
}
|
||||
if templateSchedule.MaxTTL > 0 {
|
||||
maxDeadline = now.Add(templateSchedule.MaxTTL)
|
||||
|
||||
if deadline.IsZero() || maxDeadline.Before(deadline) {
|
||||
// If the workspace doesn't have a deadline or the max
|
||||
// deadline is sooner than the workspace deadline, use the
|
||||
// max deadline as the actual deadline.
|
||||
deadline = maxDeadline
|
||||
}
|
||||
}
|
||||
|
||||
err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{
|
||||
ID: jobID,
|
||||
UpdatedAt: database.Now(),
|
||||
|
@ -892,7 +942,8 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete
|
|||
}
|
||||
_, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
|
||||
ID: workspaceBuild.ID,
|
||||
Deadline: workspaceDeadline,
|
||||
Deadline: deadline,
|
||||
MaxDeadline: maxDeadline,
|
||||
ProvisionerState: jobType.WorkspaceBuild.State,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
|
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/coder/coder/coderd/database/dbfake"
|
||||
"github.com/coder/coder/coderd/database/dbgen"
|
||||
"github.com/coder/coder/coderd/provisionerdserver"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/telemetry"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisionerd/proto"
|
||||
|
@ -32,6 +33,13 @@ func mockAuditor() *atomic.Pointer[audit.Auditor] {
|
|||
return ptr
|
||||
}
|
||||
|
||||
func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore] {
|
||||
ptr := &atomic.Pointer[schedule.TemplateScheduleStore]{}
|
||||
store := schedule.NewAGPLTemplateScheduleStore()
|
||||
ptr.Store(&store)
|
||||
return ptr
|
||||
}
|
||||
|
||||
func TestAcquireJob(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Debounce", func(t *testing.T) {
|
||||
|
@ -39,15 +47,16 @@ func TestAcquireJob(t *testing.T) {
|
|||
db := dbfake.New()
|
||||
pubsub := database.NewPubsubInMemory()
|
||||
srv := &provisionerdserver.Server{
|
||||
ID: uuid.New(),
|
||||
Logger: slogtest.Make(t, nil),
|
||||
AccessURL: &url.URL{},
|
||||
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
Telemetry: telemetry.NewNoop(),
|
||||
AcquireJobDebounce: time.Hour,
|
||||
Auditor: mockAuditor(),
|
||||
ID: uuid.New(),
|
||||
Logger: slogtest.Make(t, nil),
|
||||
AccessURL: &url.URL{},
|
||||
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
Telemetry: telemetry.NewNoop(),
|
||||
AcquireJobDebounce: time.Hour,
|
||||
Auditor: mockAuditor(),
|
||||
TemplateScheduleStore: testTemplateScheduleStore(),
|
||||
}
|
||||
job, err := srv.AcquireJob(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
|
@ -784,74 +793,226 @@ func TestCompleteJob(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.False(t, job.Error.Valid)
|
||||
})
|
||||
|
||||
t.Run("WorkspaceBuild", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := setup(t, false)
|
||||
workspace, err := srv.Database.InsertWorkspace(ctx, database.InsertWorkspaceParams{
|
||||
ID: uuid.New(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
build, err := srv.Database.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
|
||||
ID: uuid.New(),
|
||||
WorkspaceID: workspace.ID,
|
||||
Transition: database.WorkspaceTransitionDelete,
|
||||
Reason: database.BuildReasonInitiator,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
input, err := json.Marshal(provisionerdserver.WorkspaceProvisionJob{
|
||||
WorkspaceBuildID: build.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
job, err := srv.Database.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
|
||||
ID: uuid.New(),
|
||||
Provisioner: database.ProvisionerTypeEcho,
|
||||
Input: input,
|
||||
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
||||
StorageMethod: database.ProvisionerStorageMethodFile,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = srv.Database.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
|
||||
WorkerID: uuid.NullUUID{
|
||||
UUID: srv.ID,
|
||||
Valid: true,
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
templateDefaultTTL time.Duration
|
||||
templateMaxTTL time.Duration
|
||||
workspaceTTL time.Duration
|
||||
transition database.WorkspaceTransition
|
||||
// The TTL is actually a deadline time on the workspace_build row,
|
||||
// so during the test this will be compared to be within 15 seconds
|
||||
// of the expected value.
|
||||
expectedTTL time.Duration
|
||||
expectedMaxTTL time.Duration
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
templateDefaultTTL: 0,
|
||||
templateMaxTTL: 0,
|
||||
workspaceTTL: 0,
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
expectedTTL: 0,
|
||||
expectedMaxTTL: 0,
|
||||
},
|
||||
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
publishedWorkspace := make(chan struct{})
|
||||
closeWorkspaceSubscribe, err := srv.Pubsub.Subscribe(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), func(_ context.Context, _ []byte) {
|
||||
close(publishedWorkspace)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer closeWorkspaceSubscribe()
|
||||
publishedLogs := make(chan struct{})
|
||||
closeLogsSubscribe, err := srv.Pubsub.Subscribe(provisionerdserver.ProvisionerJobLogsNotifyChannel(job.ID), func(_ context.Context, _ []byte) {
|
||||
close(publishedLogs)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer closeLogsSubscribe()
|
||||
|
||||
_, err = srv.CompleteJob(ctx, &proto.CompletedJob{
|
||||
JobId: job.ID.String(),
|
||||
Type: &proto.CompletedJob_WorkspaceBuild_{
|
||||
WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{
|
||||
State: []byte{},
|
||||
Resources: []*sdkproto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "Delete",
|
||||
templateDefaultTTL: 0,
|
||||
templateMaxTTL: 0,
|
||||
workspaceTTL: 0,
|
||||
transition: database.WorkspaceTransitionDelete,
|
||||
expectedTTL: 0,
|
||||
expectedMaxTTL: 0,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
{
|
||||
name: "WorkspaceTTL",
|
||||
templateDefaultTTL: 0,
|
||||
templateMaxTTL: 0,
|
||||
workspaceTTL: time.Hour,
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
expectedTTL: time.Hour,
|
||||
expectedMaxTTL: 0,
|
||||
},
|
||||
{
|
||||
name: "TemplateDefaultTTLIgnored",
|
||||
templateDefaultTTL: time.Hour,
|
||||
templateMaxTTL: 0,
|
||||
workspaceTTL: 0,
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
expectedTTL: 0,
|
||||
expectedMaxTTL: 0,
|
||||
},
|
||||
{
|
||||
name: "WorkspaceTTLOverridesTemplateDefaultTTL",
|
||||
templateDefaultTTL: 2 * time.Hour,
|
||||
templateMaxTTL: 0,
|
||||
workspaceTTL: time.Hour,
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
expectedTTL: time.Hour,
|
||||
expectedMaxTTL: 0,
|
||||
},
|
||||
{
|
||||
name: "TemplateMaxTTL",
|
||||
templateDefaultTTL: 0,
|
||||
templateMaxTTL: time.Hour,
|
||||
workspaceTTL: 0,
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
expectedTTL: time.Hour,
|
||||
expectedMaxTTL: time.Hour,
|
||||
},
|
||||
{
|
||||
name: "TemplateMaxTTLOverridesWorkspaceTTL",
|
||||
templateDefaultTTL: 0,
|
||||
templateMaxTTL: 2 * time.Hour,
|
||||
workspaceTTL: 3 * time.Hour,
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
expectedTTL: 2 * time.Hour,
|
||||
expectedMaxTTL: 2 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "TemplateMaxTTLOverridesTemplateDefaultTTL",
|
||||
templateDefaultTTL: 3 * time.Hour,
|
||||
templateMaxTTL: 2 * time.Hour,
|
||||
workspaceTTL: 0,
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
expectedTTL: 2 * time.Hour,
|
||||
expectedMaxTTL: 2 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
<-publishedWorkspace
|
||||
<-publishedLogs
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
workspace, err = srv.Database.GetWorkspaceByID(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, workspace.Deleted)
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := setup(t, false)
|
||||
|
||||
var store schedule.TemplateScheduleStore = mockTemplateScheduleStore{
|
||||
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
||||
return schedule.TemplateScheduleOptions{
|
||||
UserSchedulingEnabled: true,
|
||||
DefaultTTL: c.templateDefaultTTL,
|
||||
MaxTTL: c.templateMaxTTL,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
srv.TemplateScheduleStore.Store(&store)
|
||||
|
||||
user := dbgen.User(t, srv.Database, database.User{})
|
||||
template := dbgen.Template(t, srv.Database, database.Template{
|
||||
Name: "template",
|
||||
Provisioner: database.ProvisionerTypeEcho,
|
||||
})
|
||||
template, err := srv.Database.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
|
||||
ID: template.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
DefaultTTL: int64(c.templateDefaultTTL),
|
||||
MaxTTL: int64(c.templateMaxTTL),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
file := dbgen.File(t, srv.Database, database.File{CreatedBy: user.ID})
|
||||
workspaceTTL := sql.NullInt64{}
|
||||
if c.workspaceTTL != 0 {
|
||||
workspaceTTL = sql.NullInt64{
|
||||
Int64: int64(c.workspaceTTL),
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
workspace, err := srv.Database.InsertWorkspace(ctx, database.InsertWorkspaceParams{
|
||||
ID: uuid.New(),
|
||||
TemplateID: template.ID,
|
||||
Ttl: workspaceTTL,
|
||||
})
|
||||
version := dbgen.TemplateVersion(t, srv.Database, database.TemplateVersion{
|
||||
TemplateID: uuid.NullUUID{
|
||||
UUID: template.ID,
|
||||
Valid: true,
|
||||
},
|
||||
JobID: uuid.New(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
build, err := srv.Database.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
|
||||
ID: uuid.New(),
|
||||
WorkspaceID: workspace.ID,
|
||||
TemplateVersionID: version.ID,
|
||||
Transition: c.transition,
|
||||
Reason: database.BuildReasonInitiator,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
job, err := srv.Database.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
|
||||
ID: uuid.New(),
|
||||
FileID: file.ID,
|
||||
Provisioner: database.ProvisionerTypeEcho,
|
||||
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
||||
StorageMethod: database.ProvisionerStorageMethodFile,
|
||||
Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{
|
||||
WorkspaceBuildID: build.ID,
|
||||
})),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = srv.Database.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
|
||||
WorkerID: uuid.NullUUID{
|
||||
UUID: srv.ID,
|
||||
Valid: true,
|
||||
},
|
||||
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
publishedWorkspace := make(chan struct{})
|
||||
closeWorkspaceSubscribe, err := srv.Pubsub.Subscribe(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), func(_ context.Context, _ []byte) {
|
||||
close(publishedWorkspace)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer closeWorkspaceSubscribe()
|
||||
publishedLogs := make(chan struct{})
|
||||
closeLogsSubscribe, err := srv.Pubsub.Subscribe(provisionerdserver.ProvisionerJobLogsNotifyChannel(job.ID), func(_ context.Context, _ []byte) {
|
||||
close(publishedLogs)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer closeLogsSubscribe()
|
||||
|
||||
_, err = srv.CompleteJob(ctx, &proto.CompletedJob{
|
||||
JobId: job.ID.String(),
|
||||
Type: &proto.CompletedJob_WorkspaceBuild_{
|
||||
WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{
|
||||
State: []byte{},
|
||||
Resources: []*sdkproto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
<-publishedWorkspace
|
||||
<-publishedLogs
|
||||
|
||||
workspace, err = srv.Database.GetWorkspaceByID(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.transition == database.WorkspaceTransitionDelete, workspace.Deleted)
|
||||
|
||||
workspaceBuild, err := srv.Database.GetWorkspaceBuildByID(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
if c.expectedTTL == 0 {
|
||||
require.True(t, workspaceBuild.Deadline.IsZero())
|
||||
} else {
|
||||
require.WithinDuration(t, time.Now().Add(c.expectedTTL), workspaceBuild.Deadline, 15*time.Second, "deadline does not match expected")
|
||||
}
|
||||
if c.expectedMaxTTL == 0 {
|
||||
require.True(t, workspaceBuild.MaxDeadline.IsZero())
|
||||
} else {
|
||||
require.WithinDuration(t, time.Now().Add(c.expectedMaxTTL), workspaceBuild.MaxDeadline, 15*time.Second, "max deadline does not match expected")
|
||||
require.GreaterOrEqual(t, workspaceBuild.MaxDeadline.Unix(), workspaceBuild.Deadline.Unix(), "max deadline is smaller than deadline")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TemplateDryRun", func(t *testing.T) {
|
||||
|
@ -989,14 +1150,15 @@ func setup(t *testing.T, ignoreLogErrors bool) *provisionerdserver.Server {
|
|||
pubsub := database.NewPubsubInMemory()
|
||||
|
||||
return &provisionerdserver.Server{
|
||||
ID: uuid.New(),
|
||||
Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}),
|
||||
AccessURL: &url.URL{},
|
||||
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
Telemetry: telemetry.NewNoop(),
|
||||
Auditor: mockAuditor(),
|
||||
ID: uuid.New(),
|
||||
Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}),
|
||||
AccessURL: &url.URL{},
|
||||
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
Telemetry: telemetry.NewNoop(),
|
||||
Auditor: mockAuditor(),
|
||||
TemplateScheduleStore: testTemplateScheduleStore(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1006,3 +1168,17 @@ func must[T any](value T, err error) T {
|
|||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type mockTemplateScheduleStore struct {
|
||||
GetFn func(ctx context.Context, db database.Store, id uuid.UUID) (schedule.TemplateScheduleOptions, error)
|
||||
}
|
||||
|
||||
var _ schedule.TemplateScheduleStore = mockTemplateScheduleStore{}
|
||||
|
||||
func (mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) {
|
||||
return schedule.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, opts)
|
||||
}
|
||||
|
||||
func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, id uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
||||
return m.GetFn(ctx, db, id)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
// package schedule provides utilities for parsing and deserializing
|
||||
// cron-style expressions.
|
||||
// package schedule provides utilities for managing template and workspace
|
||||
// auto-start and auto-stop schedules. This includes utilities for parsing and
|
||||
// deserializing cron-style expressions.
|
||||
package schedule
|
||||
|
||||
import (
|
|
@ -6,7 +6,7 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
)
|
||||
|
||||
func Test_Weekly(t *testing.T) {
|
|
@ -0,0 +1,61 @@
|
|||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
)
|
||||
|
||||
type TemplateScheduleOptions struct {
|
||||
UserSchedulingEnabled bool `json:"user_scheduling_enabled"`
|
||||
DefaultTTL time.Duration `json:"default_ttl"`
|
||||
// If MaxTTL is set, the workspace must be stopped before this time or it
|
||||
// will be stopped automatically.
|
||||
//
|
||||
// If set, users cannot disable automatic workspace shutdown.
|
||||
MaxTTL time.Duration `json:"max_ttl"`
|
||||
}
|
||||
|
||||
// TemplateScheduleStore provides an interface for retrieving template
|
||||
// scheduling options set by the template/site admin.
|
||||
type TemplateScheduleStore interface {
|
||||
GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error)
|
||||
SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts TemplateScheduleOptions) (database.Template, error)
|
||||
}
|
||||
|
||||
type agplTemplateScheduleStore struct{}
|
||||
|
||||
var _ TemplateScheduleStore = &agplTemplateScheduleStore{}
|
||||
|
||||
func NewAGPLTemplateScheduleStore() TemplateScheduleStore {
|
||||
return &agplTemplateScheduleStore{}
|
||||
}
|
||||
|
||||
func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
|
||||
tpl, err := db.GetTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
return TemplateScheduleOptions{}, err
|
||||
}
|
||||
|
||||
return TemplateScheduleOptions{
|
||||
UserSchedulingEnabled: true,
|
||||
DefaultTTL: time.Duration(tpl.DefaultTTL),
|
||||
// Disregard the value in the database, since MaxTTL is an enterprise
|
||||
// feature.
|
||||
MaxTTL: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) {
|
||||
return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
|
||||
ID: tpl.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
DefaultTTL: int64(opts.DefaultTTL),
|
||||
// Don't allow changing it, but keep the value in the DB (to avoid
|
||||
// clearing settings if the license has an issue).
|
||||
MaxTTL: tpl.MaxTTL,
|
||||
})
|
||||
}
|
|
@ -19,6 +19,7 @@ import (
|
|||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/telemetry"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/examples"
|
||||
|
@ -212,16 +213,31 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
|
|||
return
|
||||
}
|
||||
|
||||
var ttl time.Duration
|
||||
var (
|
||||
defaultTTL time.Duration
|
||||
maxTTL time.Duration
|
||||
)
|
||||
if createTemplate.DefaultTTLMillis != nil {
|
||||
ttl = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond
|
||||
defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond
|
||||
}
|
||||
if ttl < 0 {
|
||||
if createTemplate.MaxTTLMillis != nil {
|
||||
maxTTL = time.Duration(*createTemplate.MaxTTLMillis) * time.Millisecond
|
||||
}
|
||||
|
||||
var validErrs []codersdk.ValidationError
|
||||
if defaultTTL < 0 {
|
||||
validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."})
|
||||
}
|
||||
if maxTTL < 0 {
|
||||
validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."})
|
||||
}
|
||||
if maxTTL != 0 && defaultTTL > maxTTL {
|
||||
validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."})
|
||||
}
|
||||
if len(validErrs) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid create template request.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{Field: "default_ttl_ms", Detail: "Must be a positive integer."},
|
||||
},
|
||||
Message: "Invalid create template request.",
|
||||
Validations: validErrs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -244,7 +260,6 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
|
|||
Provisioner: importJob.Provisioner,
|
||||
ActiveVersionID: templateVersion.ID,
|
||||
Description: createTemplate.Description,
|
||||
DefaultTTL: int64(ttl),
|
||||
CreatedBy: apiKey.UserID,
|
||||
UserACL: database.TemplateACL{},
|
||||
GroupACL: database.TemplateACL{
|
||||
|
@ -258,6 +273,15 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
|
|||
return xerrors.Errorf("insert template: %s", err)
|
||||
}
|
||||
|
||||
dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{
|
||||
UserSchedulingEnabled: true,
|
||||
DefaultTTL: defaultTTL,
|
||||
MaxTTL: maxTTL,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("set template schedule options: %s", err)
|
||||
}
|
||||
|
||||
templateAudit.New = dbTemplate
|
||||
|
||||
err = tx.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{
|
||||
|
@ -452,6 +476,12 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
if req.DefaultTTLMillis < 0 {
|
||||
validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."})
|
||||
}
|
||||
if req.MaxTTLMillis < 0 {
|
||||
validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."})
|
||||
}
|
||||
if req.MaxTTLMillis != 0 && req.DefaultTTLMillis > req.MaxTTLMillis {
|
||||
validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."})
|
||||
}
|
||||
|
||||
if len(validErrs) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
|
@ -468,7 +498,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
req.DisplayName == template.DisplayName &&
|
||||
req.Icon == template.Icon &&
|
||||
req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs &&
|
||||
req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() {
|
||||
req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() &&
|
||||
req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -479,7 +510,6 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
displayName := req.DisplayName
|
||||
desc := req.Description
|
||||
icon := req.Icon
|
||||
maxTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond
|
||||
allowUserCancelWorkspaceJobs := req.AllowUserCancelWorkspaceJobs
|
||||
|
||||
if name == "" {
|
||||
|
@ -497,11 +527,23 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
DisplayName: displayName,
|
||||
Description: desc,
|
||||
Icon: icon,
|
||||
DefaultTTL: int64(maxTTL),
|
||||
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return xerrors.Errorf("update template metadata: %w", err)
|
||||
}
|
||||
|
||||
defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond
|
||||
maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond
|
||||
if defaultTTL != time.Duration(template.DefaultTTL) || maxTTL != time.Duration(template.MaxTTL) {
|
||||
updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, schedule.TemplateScheduleOptions{
|
||||
UserSchedulingEnabled: true,
|
||||
DefaultTTL: defaultTTL,
|
||||
MaxTTL: maxTTL,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("set template schedule options: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -635,6 +677,7 @@ func (api *API) convertTemplate(
|
|||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(),
|
||||
MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(),
|
||||
CreatedByID: template.CreatedBy,
|
||||
CreatedByName: createdByName,
|
||||
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||||
|
|
|
@ -3,6 +3,7 @@ package coderd_test
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
@ -15,6 +16,7 @@ import (
|
|||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
|
@ -87,7 +89,7 @@ func TestPostTemplateByOrganization(t *testing.T) {
|
|||
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("MaxTTLTooLow", func(t *testing.T) {
|
||||
t.Run("DefaultTTLTooLow", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
@ -107,7 +109,7 @@ func TestPostTemplateByOrganization(t *testing.T) {
|
|||
require.Contains(t, err.Error(), "default_ttl_ms: Must be a positive integer")
|
||||
})
|
||||
|
||||
t.Run("NoMaxTTL", func(t *testing.T) {
|
||||
t.Run("NoDefaultTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
@ -143,6 +145,95 @@ func TestPostTemplateByOrganization(t *testing.T) {
|
|||
require.Contains(t, err.Error(), "Try logging in using 'coder login <url>'.")
|
||||
})
|
||||
|
||||
t.Run("MaxTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
defaultTTL = 1 * time.Hour
|
||||
maxTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var setCalled int64
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
TemplateScheduleStore: mockTemplateScheduleStore{
|
||||
setFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
|
||||
atomic.AddInt64(&setCalled, 1)
|
||||
require.Equal(t, maxTTL, options.MaxTTL)
|
||||
template.DefaultTTL = int64(options.DefaultTTL)
|
||||
template.MaxTTL = int64(options.MaxTTL)
|
||||
return template, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{
|
||||
Name: "testing",
|
||||
VersionID: version.ID,
|
||||
DefaultTTLMillis: ptr.Ref(int64(0)),
|
||||
MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.EqualValues(t, 1, atomic.LoadInt64(&setCalled))
|
||||
require.EqualValues(t, 0, got.DefaultTTLMillis)
|
||||
require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis)
|
||||
})
|
||||
|
||||
t.Run("DefaultTTLBigger", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{
|
||||
Name: "testing",
|
||||
VersionID: version.ID,
|
||||
DefaultTTLMillis: ptr.Ref((maxTTL * 2).Milliseconds()),
|
||||
MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()),
|
||||
})
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, "default_ttl_ms", sdkErr.Validations[0].Field)
|
||||
require.Contains(t, sdkErr.Validations[0].Detail, "Must be less than or equal to max_ttl_ms")
|
||||
})
|
||||
|
||||
t.Run("IgnoredUnlicensed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{
|
||||
Name: "testing",
|
||||
VersionID: version.ID,
|
||||
DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()),
|
||||
MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis)
|
||||
require.Zero(t, got.MaxTTLMillis)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NoVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
|
@ -290,7 +381,7 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[4].Action)
|
||||
})
|
||||
|
||||
t.Run("NoMaxTTL", func(t *testing.T) {
|
||||
t.Run("NoDefaultTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
|
@ -319,7 +410,7 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
})
|
||||
|
||||
t.Run("MaxTTLTooLow", func(t *testing.T) {
|
||||
t.Run("DefaultTTLTooLow", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
|
@ -345,6 +436,114 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
assert.Equal(t, updated.DefaultTTLMillis, template.DefaultTTLMillis)
|
||||
})
|
||||
|
||||
t.Run("MaxTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
defaultTTL = 1 * time.Hour
|
||||
maxTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var setCalled int64
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
TemplateScheduleStore: mockTemplateScheduleStore{
|
||||
setFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
|
||||
if atomic.AddInt64(&setCalled, 1) == 2 {
|
||||
require.Equal(t, maxTTL, options.MaxTTL)
|
||||
}
|
||||
template.DefaultTTL = int64(options.DefaultTTL)
|
||||
template.MaxTTL = int64(options.MaxTTL)
|
||||
return template, 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.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||
Name: template.Name,
|
||||
DisplayName: template.DisplayName,
|
||||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
DefaultTTLMillis: 0,
|
||||
MaxTTLMillis: maxTTL.Milliseconds(),
|
||||
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.EqualValues(t, 2, atomic.LoadInt64(&setCalled))
|
||||
require.EqualValues(t, 0, got.DefaultTTLMillis)
|
||||
require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis)
|
||||
})
|
||||
|
||||
t.Run("DefaultTTLBigger", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||
Name: template.Name,
|
||||
DisplayName: template.DisplayName,
|
||||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
DefaultTTLMillis: (maxTTL * 2).Milliseconds(),
|
||||
MaxTTLMillis: maxTTL.Milliseconds(),
|
||||
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||||
})
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, "default_ttl_ms", sdkErr.Validations[0].Field)
|
||||
require.Contains(t, sdkErr.Validations[0].Detail, "Must be less than or equal to max_ttl_ms")
|
||||
})
|
||||
|
||||
t.Run("IgnoredUnlicensed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||
Name: template.Name,
|
||||
DisplayName: template.DisplayName,
|
||||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
DefaultTTLMillis: defaultTTL.Milliseconds(),
|
||||
MaxTTLMillis: maxTTL.Milliseconds(),
|
||||
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis)
|
||||
require.Zero(t, got.MaxTTLMillis)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NotModified", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
@ -430,6 +629,36 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.Equal(t, updated.Icon, "")
|
||||
})
|
||||
|
||||
t.Run("MaxTTLEnterpriseOnly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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)
|
||||
require.EqualValues(t, 0, template.MaxTTLMillis)
|
||||
req := codersdk.UpdateTemplateMeta{
|
||||
Name: template.Name,
|
||||
DisplayName: template.DisplayName,
|
||||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||||
DefaultTTLMillis: time.Hour.Milliseconds(),
|
||||
MaxTTLMillis: (2 * time.Hour).Milliseconds(),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
updated, err := client.UpdateTemplateMeta(ctx, template.ID, req)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, updated.MaxTTLMillis)
|
||||
|
||||
template, err = client.Template(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, template.MaxTTLMillis)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteTemplate(t *testing.T) {
|
||||
|
|
|
@ -1149,6 +1149,7 @@ func (api *API) convertWorkspaceBuild(
|
|||
InitiatorUsername: initiator.Username,
|
||||
Job: apiJob,
|
||||
Deadline: codersdk.NewNullTime(build.Deadline, !build.Deadline.IsZero()),
|
||||
MaxDeadline: codersdk.NewNullTime(build.MaxDeadline, !build.MaxDeadline.IsZero()),
|
||||
Reason: codersdk.BuildReason(build.Reason),
|
||||
Resources: apiResources,
|
||||
Status: convertWorkspaceStatus(apiJob.Status, transition),
|
||||
|
|
|
@ -18,12 +18,12 @@ import (
|
|||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/provisionerdserver"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/searchquery"
|
||||
"github.com/coder/coder/coderd/telemetry"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
|
@ -360,7 +360,16 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
|
|||
return
|
||||
}
|
||||
|
||||
dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, template.DefaultTTL)
|
||||
templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, template.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching template schedule.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL, templateSchedule.MaxTTL)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid Workspace Time to Shutdown.",
|
||||
|
@ -798,9 +807,15 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
|
|||
var dbTTL sql.NullInt64
|
||||
|
||||
err := api.Database.InTx(func(s database.Store) error {
|
||||
templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, s, workspace.TemplateID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template schedule: %w", err)
|
||||
}
|
||||
|
||||
// don't override 0 ttl with template default here because it indicates
|
||||
// disabled auto-stop
|
||||
var validityErr error
|
||||
// don't override 0 ttl with template default here because it indicates disabled auto-stop
|
||||
dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0)
|
||||
dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, templateSchedule.MaxTTL)
|
||||
if validityErr != nil {
|
||||
return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()}
|
||||
}
|
||||
|
@ -905,12 +920,18 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
|
|||
resp.Message = "Cannot extend workspace: " + err.Error()
|
||||
return err
|
||||
}
|
||||
if !build.MaxDeadline.IsZero() && newDeadline.After(build.MaxDeadline) {
|
||||
code = http.StatusBadRequest
|
||||
resp.Message = "Cannot extend workspace beyond max deadline."
|
||||
return xerrors.New("Cannot extend workspace: deadline is beyond max deadline imposed by template")
|
||||
}
|
||||
|
||||
if _, err := s.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
|
||||
ID: build.ID,
|
||||
UpdatedAt: build.UpdatedAt,
|
||||
ProvisionerState: build.ProvisionerState,
|
||||
Deadline: newDeadline,
|
||||
MaxDeadline: build.MaxDeadline,
|
||||
}); err != nil {
|
||||
code = http.StatusInternalServerError
|
||||
resp.Message = "Failed to extend workspace deadline."
|
||||
|
@ -1180,14 +1201,25 @@ func convertWorkspaceTTLMillis(i sql.NullInt64) *int64 {
|
|||
return &millis
|
||||
}
|
||||
|
||||
func validWorkspaceTTLMillis(millis *int64, def int64) (sql.NullInt64, error) {
|
||||
func validWorkspaceTTLMillis(millis *int64, templateDefault, templateMax time.Duration) (sql.NullInt64, error) {
|
||||
if templateDefault == 0 && templateMax != 0 || (templateMax > 0 && templateDefault > templateMax) {
|
||||
templateDefault = templateMax
|
||||
}
|
||||
|
||||
if ptr.NilOrZero(millis) {
|
||||
if def == 0 {
|
||||
if templateDefault == 0 {
|
||||
if templateMax > 0 {
|
||||
return sql.NullInt64{
|
||||
Int64: int64(templateMax),
|
||||
Valid: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return sql.NullInt64{}, nil
|
||||
}
|
||||
|
||||
return sql.NullInt64{
|
||||
Int64: def,
|
||||
Int64: int64(templateDefault),
|
||||
Valid: true,
|
||||
}, nil
|
||||
}
|
||||
|
@ -1202,6 +1234,10 @@ func validWorkspaceTTLMillis(millis *int64, def int64) (sql.NullInt64, error) {
|
|||
return sql.NullInt64{}, errTTLMax
|
||||
}
|
||||
|
||||
if templateMax > 0 && truncated > templateMax {
|
||||
return sql.NullInt64{}, xerrors.Errorf("time until shutdown must be less than or equal to the template's maximum TTL %q", templateMax.String())
|
||||
}
|
||||
|
||||
return sql.NullInt64{
|
||||
Valid: true,
|
||||
Int64: int64(truncated),
|
||||
|
|
|
@ -17,11 +17,11 @@ import (
|
|||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/parameter"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
|
@ -331,7 +331,7 @@ func TestPostWorkspacesByOrganization(t *testing.T) {
|
|||
})
|
||||
// TTL should be set by the template
|
||||
require.Equal(t, template.DefaultTTLMillis, templateTTL)
|
||||
require.Equal(t, template.DefaultTTLMillis, template.DefaultTTLMillis, workspace.TTLMillis)
|
||||
require.Equal(t, template.DefaultTTLMillis, *workspace.TTLMillis)
|
||||
})
|
||||
|
||||
t.Run("InvalidTTL", func(t *testing.T) {
|
||||
|
|
|
@ -35,6 +35,7 @@ const (
|
|||
FeatureMultipleGitAuth FeatureName = "multiple_git_auth"
|
||||
FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons"
|
||||
FeatureAppearance FeatureName = "appearance"
|
||||
FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling"
|
||||
)
|
||||
|
||||
// FeatureNames must be kept in-sync with the Feature enum above.
|
||||
|
@ -48,6 +49,7 @@ var FeatureNames = []FeatureName{
|
|||
FeatureMultipleGitAuth,
|
||||
FeatureExternalProvisionerDaemons,
|
||||
FeatureAppearance,
|
||||
FeatureAdvancedTemplateScheduling,
|
||||
}
|
||||
|
||||
// Humanize returns the feature name in a human-readable format.
|
||||
|
|
|
@ -88,6 +88,9 @@ type CreateTemplateRequest struct {
|
|||
// DefaultTTLMillis allows optionally specifying the default TTL
|
||||
// for all workspaces created from this template.
|
||||
DefaultTTLMillis *int64 `json:"default_ttl_ms,omitempty"`
|
||||
// MaxTTLMillis allows optionally specifying the max lifetime for
|
||||
// workspaces created from this template.
|
||||
MaxTTLMillis *int64 `json:"max_ttl_ms,omitempty"`
|
||||
|
||||
// Allow users to cancel in-progress workspace jobs.
|
||||
// *bool as the default value is "true".
|
||||
|
|
|
@ -28,8 +28,11 @@ type Template struct {
|
|||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
DefaultTTLMillis int64 `json:"default_ttl_ms"`
|
||||
CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"`
|
||||
CreatedByName string `json:"created_by_name"`
|
||||
// MaxTTLMillis is an enterprise feature. It's value is only used if your
|
||||
// license is entitled to use the advanced template scheduling feature.
|
||||
MaxTTLMillis int64 `json:"max_ttl_ms"`
|
||||
CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"`
|
||||
CreatedByName string `json:"created_by_name"`
|
||||
|
||||
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"`
|
||||
}
|
||||
|
@ -75,12 +78,16 @@ type UpdateTemplateACL struct {
|
|||
}
|
||||
|
||||
type UpdateTemplateMeta struct {
|
||||
Name string `json:"name,omitempty" validate:"omitempty,template_name"`
|
||||
DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"`
|
||||
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"`
|
||||
Name string `json:"name,omitempty" validate:"omitempty,template_name"`
|
||||
DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"`
|
||||
// MaxTTLMillis can only be set if your license includes the advanced
|
||||
// template scheduling feature. If you attempt to set this value while
|
||||
// unlicensed, it will be ignored.
|
||||
MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"`
|
||||
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateExample struct {
|
||||
|
|
|
@ -68,6 +68,7 @@ type WorkspaceBuild struct {
|
|||
Reason BuildReason `db:"reason" json:"reason" enums:"initiator,autostart,autostop"`
|
||||
Resources []WorkspaceResource `json:"resources"`
|
||||
Deadline NullTime `json:"deadline,omitempty" format:"date-time"`
|
||||
MaxDeadline NullTime `json:"max_deadline,omitempty" format:"date-time"`
|
||||
Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"`
|
||||
DailyCost int32 `json:"daily_cost"`
|
||||
}
|
||||
|
|
|
@ -9,17 +9,17 @@ We track the following resources:
|
|||
|
||||
<!-- Code generated by 'make docs/admin/audit-logs.md'. DO NOT EDIT -->
|
||||
|
||||
| <b>Resource<b> | |
|
||||
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| APIKey<br><i>write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>expires_at</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>false</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>false</td></tr></tbody></table> |
|
||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
|
||||
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
|
||||
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_private</td><td>true</td></tr><tr><td>min_autostart_interval</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>git_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
|
||||
| <b>Resource<b> | |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| APIKey<br><i>write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>expires_at</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>false</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>false</td></tr></tbody></table> |
|
||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
|
||||
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
|
||||
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_private</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>min_autostart_interval</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>git_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
|
||||
|
||||
<!-- End generated by 'make docs/admin/audit-logs.md'. -->
|
||||
|
||||
|
|
|
@ -49,6 +49,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
@ -197,6 +198,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
@ -720,6 +722,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
@ -873,6 +876,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
@ -999,6 +1003,7 @@ Status Code **200**
|
|||
| `»» tags` | object | false | | |
|
||||
| `»»» [any property]` | string | false | | |
|
||||
| `»» worker_id` | string(uuid) | false | | |
|
||||
| `» max_deadline` | string(date-time) | false | | |
|
||||
| `» reason` | [codersdk.BuildReason](schemas.md#codersdkbuildreason) | false | | |
|
||||
| `» resources` | array | false | | |
|
||||
| `»» agents` | array | false | | |
|
||||
|
@ -1197,6 +1202,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
|
|
@ -977,6 +977,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a
|
|||
"description": "string",
|
||||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"max_ttl_ms": 0,
|
||||
"name": "string",
|
||||
"parameter_values": [
|
||||
{
|
||||
|
@ -1000,6 +1001,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a
|
|||
| `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. |
|
||||
| `display_name` | string | false | | Display name is the displayed name of the template. |
|
||||
| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. |
|
||||
| `max_ttl_ms` | integer | false | | Max ttl ms allows optionally specifying the max lifetime for workspaces created from this template. |
|
||||
| `name` | string | true | | Name is the name of the template. |
|
||||
| `parameter_values` | array of [codersdk.CreateParameterRequest](#codersdkcreateparameterrequest) | false | | Parameter values is a structure used to create a new parameter value for a scope.] |
|
||||
| `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. |
|
||||
|
@ -4583,6 +4585,7 @@ Parameter represents a set value for the scope.
|
|||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"max_ttl_ms": 0,
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"provisioner": "terraform",
|
||||
|
@ -4592,24 +4595,25 @@ Parameter represents a set value for the scope.
|
|||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ---------------------------------- | ------------------------------------------------------------------ | -------- | ------------ | -------------------------------------------- |
|
||||
| `active_user_count` | integer | false | | Active user count is set to -1 when loading. |
|
||||
| `active_version_id` | string | false | | |
|
||||
| `allow_user_cancel_workspace_jobs` | boolean | false | | |
|
||||
| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | |
|
||||
| `created_at` | string | false | | |
|
||||
| `created_by_id` | string | false | | |
|
||||
| `created_by_name` | string | false | | |
|
||||
| `default_ttl_ms` | integer | false | | |
|
||||
| `description` | string | false | | |
|
||||
| `display_name` | string | false | | |
|
||||
| `icon` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `organization_id` | string | false | | |
|
||||
| `provisioner` | string | false | | |
|
||||
| `updated_at` | string | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ---------------------------------- | ------------------------------------------------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `active_user_count` | integer | false | | Active user count is set to -1 when loading. |
|
||||
| `active_version_id` | string | false | | |
|
||||
| `allow_user_cancel_workspace_jobs` | boolean | false | | |
|
||||
| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | |
|
||||
| `created_at` | string | false | | |
|
||||
| `created_by_id` | string | false | | |
|
||||
| `created_by_name` | string | false | | |
|
||||
| `default_ttl_ms` | integer | false | | |
|
||||
| `description` | string | false | | |
|
||||
| `display_name` | string | false | | |
|
||||
| `icon` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. |
|
||||
| `name` | string | false | | |
|
||||
| `organization_id` | string | false | | |
|
||||
| `provisioner` | string | false | | |
|
||||
| `updated_at` | string | false | | |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
|
@ -5308,6 +5312,7 @@ Parameter represents a set value for the scope.
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
@ -5776,6 +5781,7 @@ Parameter represents a set value for the scope.
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
@ -5881,6 +5887,7 @@ Parameter represents a set value for the scope.
|
|||
| `initiator_id` | string | false | | |
|
||||
| `initiator_name` | string | false | | |
|
||||
| `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | |
|
||||
| `max_deadline` | string | false | | |
|
||||
| `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | |
|
||||
| `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | |
|
||||
| `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | |
|
||||
|
@ -6144,6 +6151,7 @@ Parameter represents a set value for the scope.
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
|
|
@ -118,6 +118,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
|
|||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"max_ttl_ms": 0,
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"provisioner": "terraform",
|
||||
|
@ -136,28 +137,29 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
|
|||
|
||||
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 | | |
|
||||
| `» allow_user_cancel_workspace_jobs` | boolean | false | | |
|
||||
| `» 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 | | |
|
||||
| `» description` | string | false | | |
|
||||
| `» display_name` | string | false | | |
|
||||
| `» icon` | string | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» organization_id` | string(uuid) | false | | |
|
||||
| `» provisioner` | string | false | | |
|
||||
| `» updated_at` | string(date-time) | false | | |
|
||||
| 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 | | |
|
||||
| `» allow_user_cancel_workspace_jobs` | boolean | false | | |
|
||||
| `» 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 | | |
|
||||
| `» description` | string | false | | |
|
||||
| `» display_name` | string | false | | |
|
||||
| `» icon` | string | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. |
|
||||
| `» name` | string | false | | |
|
||||
| `» organization_id` | string(uuid) | false | | |
|
||||
| `» provisioner` | string | false | | |
|
||||
| `» updated_at` | string(date-time) | false | | |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
|
@ -190,6 +192,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
|
|||
"description": "string",
|
||||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"max_ttl_ms": 0,
|
||||
"name": "string",
|
||||
"parameter_values": [
|
||||
{
|
||||
|
@ -238,6 +241,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
|
|||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"max_ttl_ms": 0,
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"provisioner": "terraform",
|
||||
|
@ -360,6 +364,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
|
|||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"max_ttl_ms": 0,
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"provisioner": "terraform",
|
||||
|
@ -681,6 +686,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \
|
|||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"max_ttl_ms": 0,
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"provisioner": "terraform",
|
||||
|
@ -786,6 +792,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \
|
|||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"max_ttl_ms": 0,
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"provisioner": "terraform",
|
||||
|
|
|
@ -81,6 +81,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
@ -248,6 +249,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
@ -438,6 +440,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
@ -602,6 +605,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
|
|||
},
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
|
|
|
@ -22,7 +22,7 @@ Allow users to cancel in-progress workspace jobs.
|
|||
|
||||
### --default-ttl
|
||||
|
||||
Edit the template default time before shutdown - workspaces created from this template to this value.
|
||||
Edit the template default time before shutdown - workspaces created from this template default to this value.
|
||||
<br/>
|
||||
| | |
|
||||
| --- | --- |
|
||||
|
@ -30,28 +30,36 @@ Edit the template default time before shutdown - workspaces created from this te
|
|||
|
||||
### --description
|
||||
|
||||
Edit the template description
|
||||
Edit the template description.
|
||||
<br/>
|
||||
| | |
|
||||
| --- | --- |
|
||||
|
||||
### --display-name
|
||||
|
||||
Edit the template display name
|
||||
Edit the template display name.
|
||||
<br/>
|
||||
| | |
|
||||
| --- | --- |
|
||||
|
||||
### --icon
|
||||
|
||||
Edit the template icon path
|
||||
Edit the template icon path.
|
||||
<br/>
|
||||
| | |
|
||||
| --- | --- |
|
||||
|
||||
### --max-ttl
|
||||
|
||||
Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.
|
||||
<br/>
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Default | <code>0s</code> |
|
||||
|
||||
### --name
|
||||
|
||||
Edit the template name
|
||||
Edit the template name.
|
||||
<br/>
|
||||
| | |
|
||||
| --- | --- |
|
||||
|
|
|
@ -70,6 +70,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
|
|||
"group_acl": ActionTrack,
|
||||
"user_acl": ActionTrack,
|
||||
"allow_user_cancel_workspace_jobs": ActionTrack,
|
||||
"max_ttl": ActionTrack,
|
||||
},
|
||||
&database.TemplateVersion{}: {
|
||||
"id": ActionTrack,
|
||||
|
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/enterprise/coderd/license"
|
||||
"github.com/coder/coder/enterprise/derpmesh"
|
||||
|
@ -252,6 +253,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
|||
codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1,
|
||||
codersdk.FeatureTemplateRBAC: api.RBAC,
|
||||
codersdk.FeatureExternalProvisionerDaemons: true,
|
||||
codersdk.FeatureAdvancedTemplateScheduling: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -310,6 +312,17 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
if changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); changed {
|
||||
if enabled {
|
||||
store := &enterpriseTemplateScheduleStore{}
|
||||
ptr := schedule.TemplateScheduleStore(store)
|
||||
api.AGPL.TemplateScheduleStore.Store(&ptr)
|
||||
} else {
|
||||
store := schedule.NewAGPLTemplateScheduleStore()
|
||||
api.AGPL.TemplateScheduleStore.Store(&store)
|
||||
}
|
||||
}
|
||||
|
||||
if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed {
|
||||
coordinator := agpltailnet.NewCoordinator()
|
||||
if enabled {
|
||||
|
|
|
@ -52,6 +52,7 @@ func TestEntitlements(t *testing.T) {
|
|||
codersdk.FeatureAuditLog: 1,
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
codersdk.FeatureExternalProvisionerDaemons: 1,
|
||||
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
},
|
||||
})
|
||||
res, err := client.Entitlements(context.Background())
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/yamux"
|
||||
|
@ -26,6 +27,7 @@ import (
|
|||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/provisionerdserver"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisionerd/proto"
|
||||
)
|
||||
|
@ -216,15 +218,16 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
|
|||
}
|
||||
mux := drpcmux.New()
|
||||
err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{
|
||||
AccessURL: api.AccessURL,
|
||||
ID: daemon.ID,
|
||||
Database: api.Database,
|
||||
Pubsub: api.Pubsub,
|
||||
Provisioners: daemon.Provisioners,
|
||||
Telemetry: api.Telemetry,
|
||||
Auditor: &api.AGPL.Auditor,
|
||||
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
|
||||
Tags: rawTags,
|
||||
AccessURL: api.AccessURL,
|
||||
ID: daemon.ID,
|
||||
Database: api.Database,
|
||||
Pubsub: api.Pubsub,
|
||||
Provisioners: daemon.Provisioners,
|
||||
Telemetry: api.Telemetry,
|
||||
Auditor: &api.AGPL.Auditor,
|
||||
TemplateScheduleStore: &api.AGPL.TemplateScheduleStore,
|
||||
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
|
||||
Tags: rawTags,
|
||||
})
|
||||
if err != nil {
|
||||
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("drpc register provisioner daemon: %s", err))
|
||||
|
@ -301,3 +304,55 @@ func websocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websock
|
|||
Conn: nc,
|
||||
}
|
||||
}
|
||||
|
||||
type enterpriseTemplateScheduleStore struct{}
|
||||
|
||||
var _ schedule.TemplateScheduleStore = &enterpriseTemplateScheduleStore{}
|
||||
|
||||
func (*enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
||||
tpl, err := db.GetTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
return schedule.TemplateScheduleOptions{}, err
|
||||
}
|
||||
|
||||
return schedule.TemplateScheduleOptions{
|
||||
// TODO: make configurable at template level
|
||||
UserSchedulingEnabled: true,
|
||||
DefaultTTL: time.Duration(tpl.DefaultTTL),
|
||||
MaxTTL: time.Duration(tpl.MaxTTL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*enterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) {
|
||||
template, err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
|
||||
ID: tpl.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
DefaultTTL: int64(opts.DefaultTTL),
|
||||
MaxTTL: int64(opts.MaxTTL),
|
||||
})
|
||||
if err != nil {
|
||||
return database.Template{}, xerrors.Errorf("update template schedule: %w", err)
|
||||
}
|
||||
|
||||
// Update all workspaces using the template to set the user defined schedule
|
||||
// to be within the new bounds. This essentially does the following for each
|
||||
// workspace using the template.
|
||||
// if (template.ttl != NULL) {
|
||||
// workspace.ttl = min(workspace.ttl, template.ttl)
|
||||
// }
|
||||
//
|
||||
// NOTE: this does not apply to currently running workspaces as their
|
||||
// schedule information is committed to the workspace_build during start.
|
||||
// This limitation is displayed to the user while editing the template.
|
||||
if opts.MaxTTL > 0 {
|
||||
err = db.UpdateWorkspaceTTLToBeWithinTemplateMax(ctx, database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams{
|
||||
TemplateID: template.ID,
|
||||
TemplateMaxTTL: int64(opts.MaxTTL),
|
||||
})
|
||||
if err != nil {
|
||||
return database.Template{}, xerrors.Errorf("update TTL of all workspaces on template to be within new template max TTL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return template, nil
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
@ -21,6 +22,192 @@ import (
|
|||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestTemplates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SetMaxTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
},
|
||||
})
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
require.EqualValues(t, 0, template.MaxTTLMillis)
|
||||
|
||||
// Create some workspaces to test propagation to user-defined TTLs.
|
||||
workspace1 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
ttl := (24 * time.Hour).Milliseconds()
|
||||
cwr.TTLMillis = &ttl
|
||||
})
|
||||
workspace2TTL := (1 * time.Hour).Milliseconds()
|
||||
workspace2 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.TTLMillis = &workspace2TTL
|
||||
})
|
||||
workspace3 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
// To unset TTL you have to update, as setting a nil TTL on create
|
||||
// copies the template default TTL.
|
||||
ctx, _ := testutil.Context(t)
|
||||
err := client.UpdateWorkspaceTTL(ctx, workspace3.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTLMillis: nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||
Name: template.Name,
|
||||
DisplayName: template.DisplayName,
|
||||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||||
DefaultTTLMillis: time.Hour.Milliseconds(),
|
||||
MaxTTLMillis: (2 * time.Hour).Milliseconds(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2*time.Hour, time.Duration(updated.MaxTTLMillis)*time.Millisecond)
|
||||
|
||||
template, err = client.Template(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2*time.Hour, time.Duration(template.MaxTTLMillis)*time.Millisecond)
|
||||
|
||||
// Verify that only the first workspace has been updated.
|
||||
workspace1, err = client.Workspace(ctx, workspace1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &template.MaxTTLMillis, workspace1.TTLMillis)
|
||||
|
||||
workspace2, err = client.Workspace(ctx, workspace2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &workspace2TTL, workspace2.TTLMillis)
|
||||
|
||||
workspace3, err = client.Workspace(ctx, workspace3.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, workspace3.TTLMillis)
|
||||
})
|
||||
|
||||
t.Run("CreateUpdateWorkspaceMaxTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
},
|
||||
})
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
exp := 24 * time.Hour.Milliseconds()
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
ctr.DefaultTTLMillis = &exp
|
||||
ctr.MaxTTLMillis = &exp
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// No TTL provided should use template default
|
||||
req := codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: "testing",
|
||||
}
|
||||
ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, exp, *ws.TTLMillis)
|
||||
|
||||
// Editing a workspace to have a higher TTL than the template's max
|
||||
// should error
|
||||
exp = exp + time.Minute.Milliseconds()
|
||||
err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTLMillis: &exp,
|
||||
})
|
||||
require.Error(t, err)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
require.Len(t, apiErr.Validations, 1)
|
||||
require.Equal(t, apiErr.Validations[0].Field, "ttl_ms")
|
||||
require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL")
|
||||
|
||||
// Creating workspace with TTL higher than max should error
|
||||
req.Name = "testing2"
|
||||
req.TTLMillis = &exp
|
||||
ws, err = client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req)
|
||||
require.Error(t, err)
|
||||
apiErr = nil
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
require.Len(t, apiErr.Validations, 1)
|
||||
require.Equal(t, apiErr.Validations[0].Field, "ttl_ms")
|
||||
require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL")
|
||||
})
|
||||
|
||||
t.Run("BlockDisablingAutoOffWithMaxTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||
},
|
||||
})
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
exp := 24 * time.Hour.Milliseconds()
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
ctr.MaxTTLMillis = &exp
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// No TTL provided should use template default
|
||||
req := codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: "testing",
|
||||
}
|
||||
ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, exp, *ws.TTLMillis)
|
||||
|
||||
// Editing a workspace to disable the TTL should do nothing
|
||||
err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTLMillis: nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ws, err = client.Workspace(ctx, ws.ID)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, exp, *ws.TTLMillis)
|
||||
|
||||
// Editing a workspace to have a TTL of 0 should do nothing
|
||||
zero := int64(0)
|
||||
err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTLMillis: &zero,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ws, err = client.Workspace(ctx, ws.ID)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, exp, *ws.TTLMillis)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTemplateACL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
@ -186,6 +186,7 @@ export interface CreateTemplateRequest {
|
|||
readonly template_version_id: string
|
||||
readonly parameter_values?: CreateParameterRequest[]
|
||||
readonly default_ttl_ms?: number
|
||||
readonly max_ttl_ms?: number
|
||||
readonly allow_user_cancel_workspace_jobs?: boolean
|
||||
}
|
||||
|
||||
|
@ -719,6 +720,7 @@ export interface Template {
|
|||
readonly description: string
|
||||
readonly icon: string
|
||||
readonly default_ttl_ms: number
|
||||
readonly max_ttl_ms: number
|
||||
readonly created_by_id: string
|
||||
readonly created_by_name: string
|
||||
readonly allow_user_cancel_workspace_jobs: boolean
|
||||
|
@ -878,6 +880,7 @@ export interface UpdateTemplateMeta {
|
|||
readonly description?: string
|
||||
readonly icon?: string
|
||||
readonly default_ttl_ms?: number
|
||||
readonly max_ttl_ms?: number
|
||||
readonly allow_user_cancel_workspace_jobs?: boolean
|
||||
}
|
||||
|
||||
|
@ -1044,6 +1047,7 @@ export interface WorkspaceBuild {
|
|||
readonly reason: BuildReason
|
||||
readonly resources: WorkspaceResource[]
|
||||
readonly deadline?: string
|
||||
readonly max_deadline?: string
|
||||
readonly status: WorkspaceStatus
|
||||
readonly daily_cost: number
|
||||
}
|
||||
|
@ -1154,6 +1158,7 @@ export const Experiments: Experiment[] = ["authz_querier", "template_editor"]
|
|||
|
||||
// From codersdk/deployment.go
|
||||
export type FeatureName =
|
||||
| "advanced_template_scheduling"
|
||||
| "appearance"
|
||||
| "audit_log"
|
||||
| "browser_only"
|
||||
|
@ -1164,6 +1169,7 @@ export type FeatureName =
|
|||
| "template_rbac"
|
||||
| "user_limit"
|
||||
export const FeatureNames: FeatureName[] = [
|
||||
"advanced_template_scheduling",
|
||||
"appearance",
|
||||
"audit_log",
|
||||
"browser_only",
|
||||
|
|
|
@ -39,5 +39,7 @@
|
|||
"updateCheck": {
|
||||
"message": "Coder {{version}} is now available. View the <4>release notes</4> and <7>upgrade instructions</7> for more information.",
|
||||
"error": "Coder update check failed."
|
||||
}
|
||||
},
|
||||
"licenseFieldTextHelper": "You need an enterprise license to use it.",
|
||||
"learnMore": "Learn more"
|
||||
}
|
||||
|
|
|
@ -26,11 +26,17 @@
|
|||
"displayName": "Display name",
|
||||
"description": "Description",
|
||||
"icon": "Icon",
|
||||
"autoStop": "Auto-stop default",
|
||||
"autoStop": "Default auto-stop",
|
||||
"maxTTL": "Max. Lifetime (alpha)",
|
||||
"allowUsersToCancel": "Allow users to cancel in-progress workspace jobs"
|
||||
},
|
||||
"helperText": {
|
||||
"autoStop": "Time in hours",
|
||||
"defaultTTLHelperText_zero": "Workspaces will run until stopped manually.",
|
||||
"defaultTTLHelperText_one": "Workspaces will default to stopping after {{count}} hour.",
|
||||
"defaultTTLHelperText_other": "Workspaces will default to stopping after {{count}} hours.",
|
||||
"maxTTLHelperText_zero": "Workspaces may run indefinitely.",
|
||||
"maxTTLHelperText_one": "Workspaces must stop within 1 hour of starting.",
|
||||
"maxTTLHelperText_other": "Workspaces must stop within {{count}} hours of starting.",
|
||||
"allowUsersToCancel": "If checked, users may be able to corrupt their workspace."
|
||||
},
|
||||
"upload": {
|
||||
|
@ -39,6 +45,13 @@
|
|||
},
|
||||
"tooltip": {
|
||||
"allowUsersToCancel": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases."
|
||||
},
|
||||
"error": {
|
||||
"descriptionMax": "Please enter a description that is less than or equal to 128 characters.",
|
||||
"defaultTTLMax": "Please enter a limit that is less than or equal to 168 hours (7 days).",
|
||||
"defaultTTLMin": "Default time until auto-stop must not be less than 0.",
|
||||
"maxTTLMax": "Please enter a limit that is less than or equal to 168 hours (7 days).",
|
||||
"maxTTLMin": "Maximum time until auto-stop must not be less than 0."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,15 +4,21 @@
|
|||
"displayNameLabel": "Display name",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionMaxError": "Please enter a description that is less than or equal to 128 characters.",
|
||||
"defaultTtlLabel": "Auto-stop default",
|
||||
"defaultTtlLabel": "Default auto-stop",
|
||||
"maxTtlLabel": "Max. Lifetime (alpha)",
|
||||
"iconLabel": "Icon",
|
||||
"formAriaLabel": "Template settings form",
|
||||
"selectEmoji": "Select emoji",
|
||||
"ttlMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).",
|
||||
"ttlMinError": "Default time until auto-stop must not be less than 0.",
|
||||
"ttlHelperText_zero": "Workspaces created from this template will run until stopped manually.",
|
||||
"ttlHelperText_one": "Workspaces created from this template will default to stopping after {{count}} hour.",
|
||||
"ttlHelperText_other": "Workspaces created from this template will default to stopping after {{count}} hours.",
|
||||
"defaultTTLMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).",
|
||||
"defaultTTLMinError": "Default time until auto-stop must not be less than 0.",
|
||||
"defaultTTLHelperText_zero": "Workspaces will run until stopped manually.",
|
||||
"defaultTTLHelperText_one": "Workspaces will default to stopping after {{count}} hour.",
|
||||
"defaultTTLHelperText_other": "Workspaces will default to stopping after {{count}} hours.",
|
||||
"maxTTLMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).",
|
||||
"maxTTLMinError": "Maximum time until auto-stop must not be less than 0.",
|
||||
"maxTTLHelperText_zero": "Workspaces may run indefinitely.",
|
||||
"maxTTLHelperText_one": "Workspaces must stop within 1 hour of starting.",
|
||||
"maxTTLHelperText_other": "Workspaces must stop within {{count}} hours of starting.",
|
||||
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
|
||||
"allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.",
|
||||
"allowUsersCancelHelperText": "If checked, users may be able to corrupt their workspace.",
|
||||
|
@ -26,7 +32,7 @@
|
|||
},
|
||||
"schedule": {
|
||||
"title": "Schedule",
|
||||
"description": "Define when workspaces created from this template automatically stop."
|
||||
"description": "Define when workspaces created from this template are stopped."
|
||||
},
|
||||
"operations": {
|
||||
"title": "Operations",
|
||||
|
|
|
@ -28,19 +28,70 @@ import * as Yup from "yup"
|
|||
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
|
||||
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
|
||||
import { LazyIconField } from "components/IconField/LazyIconField"
|
||||
import { VariableInput } from "./VariableInput"
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import i18next from "i18next"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { FormFooter } from "components/FormFooter/FormFooter"
|
||||
import {
|
||||
FormFields,
|
||||
FormFooter,
|
||||
FormSection,
|
||||
HorizontalForm,
|
||||
FormSection,
|
||||
FormFields,
|
||||
} from "components/HorizontalForm/HorizontalForm"
|
||||
import camelCase from "lodash/camelCase"
|
||||
import capitalize from "lodash/capitalize"
|
||||
import { VariableInput } from "./VariableInput"
|
||||
|
||||
const MAX_DESCRIPTION_CHAR_LIMIT = 128
|
||||
const MAX_TTL_DAYS = 7
|
||||
|
||||
const TTLHelperText = ({
|
||||
ttl,
|
||||
translationName,
|
||||
}: {
|
||||
ttl?: number
|
||||
translationName: string
|
||||
}) => {
|
||||
const { t } = useTranslation("createTemplatePage")
|
||||
const count = typeof ttl !== "number" ? 0 : ttl
|
||||
return (
|
||||
// no helper text if ttl is negative - error will show once field is considered touched
|
||||
<Maybe condition={count >= 0}>
|
||||
<span>{t(translationName, { count })}</span>
|
||||
</Maybe>
|
||||
)
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: nameValidator("Name"),
|
||||
display_name: templateDisplayNameValidator("Display name"),
|
||||
name: nameValidator(
|
||||
i18next.t("form.fields.name", { ns: "createTemplatePage" }),
|
||||
),
|
||||
display_name: templateDisplayNameValidator(
|
||||
i18next.t("form.fields.displayName", {
|
||||
ns: "createTemplatePage",
|
||||
}),
|
||||
),
|
||||
description: Yup.string().max(
|
||||
MAX_DESCRIPTION_CHAR_LIMIT,
|
||||
i18next.t("form.error.descriptionMax", { ns: "createTemplatePage" }),
|
||||
),
|
||||
icon: Yup.string().optional(),
|
||||
default_ttl_hours: Yup.number()
|
||||
.integer()
|
||||
.min(
|
||||
0,
|
||||
i18next.t("form.error.defaultTTLMin", { ns: "templateSettingsPage" }),
|
||||
)
|
||||
.max(
|
||||
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
||||
i18next.t("form.error.defaultTTLMax", { ns: "templateSettingsPage" }),
|
||||
),
|
||||
max_ttl_hours: Yup.number()
|
||||
.integer()
|
||||
.min(0, i18next.t("form.error.maxTTLMin", { ns: "templateSettingsPage" }))
|
||||
.max(
|
||||
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
||||
i18next.t("form.error.maxTTLMax", { ns: "templateSettingsPage" }),
|
||||
),
|
||||
})
|
||||
|
||||
const defaultInitialValues: CreateTemplateData = {
|
||||
|
@ -49,16 +100,29 @@ const defaultInitialValues: CreateTemplateData = {
|
|||
description: "",
|
||||
icon: "",
|
||||
default_ttl_hours: 24,
|
||||
// max_ttl is an enterprise-only feature, and the server ignores the value if
|
||||
// you are not licensed. We hide the form value based on entitlements.
|
||||
max_ttl_hours: 24 * 7,
|
||||
allow_user_cancel_workspace_jobs: false,
|
||||
}
|
||||
|
||||
const getInitialValues = (starterTemplate?: TemplateExample) => {
|
||||
const getInitialValues = (
|
||||
canSetMaxTTL: boolean,
|
||||
starterTemplate?: TemplateExample,
|
||||
) => {
|
||||
let initialValues = defaultInitialValues
|
||||
if (!canSetMaxTTL) {
|
||||
initialValues = {
|
||||
...initialValues,
|
||||
max_ttl_hours: 0,
|
||||
}
|
||||
}
|
||||
if (!starterTemplate) {
|
||||
return defaultInitialValues
|
||||
return initialValues
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultInitialValues,
|
||||
...initialValues,
|
||||
name: starterTemplate.id,
|
||||
display_name: starterTemplate.name,
|
||||
icon: starterTemplate.icon,
|
||||
|
@ -77,6 +141,7 @@ export interface CreateTemplateFormProps {
|
|||
error?: unknown
|
||||
jobError?: string
|
||||
logs?: ProvisionerJobLog[]
|
||||
canSetMaxTTL: boolean
|
||||
}
|
||||
|
||||
export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
||||
|
@ -90,15 +155,17 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
|||
error,
|
||||
jobError,
|
||||
logs,
|
||||
canSetMaxTTL,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const form = useFormik<CreateTemplateData>({
|
||||
initialValues: getInitialValues(starterTemplate),
|
||||
initialValues: getInitialValues(canSetMaxTTL, starterTemplate),
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
})
|
||||
const getFieldHelpers = getFormHelpers<CreateTemplateData>(form, error)
|
||||
const { t } = useTranslation("createTemplatePage")
|
||||
const { t: commonT } = useTranslation("common")
|
||||
|
||||
return (
|
||||
<HorizontalForm onSubmit={form.handleSubmit}>
|
||||
|
@ -175,16 +242,48 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
|||
description={t("form.schedule.description")}
|
||||
>
|
||||
<FormFields>
|
||||
<TextField
|
||||
{...getFieldHelpers("default_ttl_hours")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label={t("form.fields.autoStop")}
|
||||
variant="outlined"
|
||||
type="number"
|
||||
helperText={t("form.helperText.autoStop")}
|
||||
/>
|
||||
<Stack direction="row" className={styles.ttlFields}>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"default_ttl_hours",
|
||||
<TTLHelperText
|
||||
translationName="form.helperText.defaultTTLHelperText"
|
||||
ttl={form.values.default_ttl_hours}
|
||||
/>,
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label={t("form.fields.autoStop")}
|
||||
variant="outlined"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"max_ttl_hours",
|
||||
canSetMaxTTL ? (
|
||||
<TTLHelperText
|
||||
translationName="form.helperText.maxTTLHelperText"
|
||||
ttl={form.values.max_ttl_hours}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{commonT("licenseFieldTextHelper")}{" "}
|
||||
<Link href="https://coder.com/docs/v2/latest/enterprise">
|
||||
{commonT("learnMore")}
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
),
|
||||
)}
|
||||
disabled={isSubmitting || !canSetMaxTTL}
|
||||
fullWidth
|
||||
label={t("form.fields.maxTTL")}
|
||||
variant="outlined"
|
||||
type="number"
|
||||
/>
|
||||
</Stack>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
|
@ -318,6 +417,10 @@ const fillNameAndDisplayWithFilename = async (
|
|||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
ttlFields: {
|
||||
width: "100%",
|
||||
},
|
||||
|
||||
optionText: {
|
||||
fontSize: theme.spacing(2),
|
||||
color: theme.palette.text.primary,
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useMachine } from "@xstate/react"
|
|||
import { isApiValidationError } from "api/errors"
|
||||
import { AlertBanner } from "components/AlertBanner/AlertBanner"
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
|
||||
import { Loader } from "components/Loader/Loader"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
|
@ -40,6 +41,9 @@ const CreateTemplatePage: FC = () => {
|
|||
variables,
|
||||
} = state.context
|
||||
const shouldDisplayForm = !state.hasTag("loading")
|
||||
const { entitlements } = useDashboard()
|
||||
const canSetMaxTTL =
|
||||
entitlements.features["advanced_template_scheduling"].enabled
|
||||
|
||||
const onCancel = () => {
|
||||
navigate(-1)
|
||||
|
@ -63,6 +67,7 @@ const CreateTemplatePage: FC = () => {
|
|||
|
||||
{shouldDisplayForm && (
|
||||
<CreateTemplateForm
|
||||
canSetMaxTTL={canSetMaxTTL}
|
||||
error={error}
|
||||
starterTemplate={starterTemplate}
|
||||
isSubmitting={state.hasTag("submitting")}
|
||||
|
|
|
@ -23,14 +23,21 @@ import { Stack } from "components/Stack/Stack"
|
|||
import Checkbox from "@material-ui/core/Checkbox"
|
||||
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import Link from "@material-ui/core/Link"
|
||||
|
||||
const TTLHelperText = ({ ttl }: { ttl?: number }) => {
|
||||
const TTLHelperText = ({
|
||||
ttl,
|
||||
translationName,
|
||||
}: {
|
||||
ttl?: number
|
||||
translationName: string
|
||||
}) => {
|
||||
const { t } = useTranslation("templateSettingsPage")
|
||||
const count = typeof ttl !== "number" ? 0 : ttl
|
||||
return (
|
||||
// no helper text if ttl is negative - error will show once field is considered touched
|
||||
<Maybe condition={count >= 0}>
|
||||
<span>{t("ttlHelperText", { count })}</span>
|
||||
<span>{t(translationName, { count })}</span>
|
||||
</Maybe>
|
||||
)
|
||||
}
|
||||
|
@ -53,10 +60,17 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
|
|||
),
|
||||
default_ttl_ms: Yup.number()
|
||||
.integer()
|
||||
.min(0, i18next.t("ttlMinError", { ns: "templateSettingsPage" }))
|
||||
.min(0, i18next.t("defaultTTLMinError", { ns: "templateSettingsPage" }))
|
||||
.max(
|
||||
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
||||
i18next.t("ttlMaxError", { ns: "templateSettingsPage" }),
|
||||
i18next.t("defaultTTLMaxError", { ns: "templateSettingsPage" }),
|
||||
),
|
||||
max_ttl_ms: Yup.number()
|
||||
.integer()
|
||||
.min(0, i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }))
|
||||
.max(
|
||||
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
||||
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }),
|
||||
),
|
||||
allow_user_cancel_workspace_jobs: Yup.boolean(),
|
||||
})
|
||||
|
@ -67,6 +81,7 @@ export interface TemplateSettingsForm {
|
|||
onCancel: () => void
|
||||
isSubmitting: boolean
|
||||
error?: unknown
|
||||
canSetMaxTTL: boolean
|
||||
// Helpful to show field errors on Storybook
|
||||
initialTouched?: FormikTouched<UpdateTemplateMeta>
|
||||
}
|
||||
|
@ -76,9 +91,11 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
onSubmit,
|
||||
onCancel,
|
||||
error,
|
||||
canSetMaxTTL,
|
||||
isSubmitting,
|
||||
initialTouched,
|
||||
}) => {
|
||||
const { t: commonT } = useTranslation("common")
|
||||
const validationSchema = getValidationSchema()
|
||||
const form: FormikContextType<UpdateTemplateMeta> =
|
||||
useFormik<UpdateTemplateMeta>({
|
||||
|
@ -88,6 +105,9 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
description: template.description,
|
||||
// on display, convert from ms => hours
|
||||
default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION,
|
||||
// the API ignores this value, but to avoid tripping up validation set
|
||||
// it to zero if the user can't set the field.
|
||||
max_ttl_ms: canSetMaxTTL ? template.max_ttl_ms / MS_HOUR_CONVERSION : 0,
|
||||
icon: template.icon,
|
||||
allow_user_cancel_workspace_jobs:
|
||||
template.allow_user_cancel_workspace_jobs,
|
||||
|
@ -100,6 +120,9 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
default_ttl_ms: formData.default_ttl_ms
|
||||
? formData.default_ttl_ms * MS_HOUR_CONVERSION
|
||||
: undefined,
|
||||
max_ttl_ms: formData.max_ttl_ms
|
||||
? formData.max_ttl_ms * MS_HOUR_CONVERSION
|
||||
: undefined,
|
||||
})
|
||||
},
|
||||
initialTouched,
|
||||
|
@ -169,18 +192,49 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
title={t("schedule.title")}
|
||||
description={t("schedule.description")}
|
||||
>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"default_ttl_ms",
|
||||
<TTLHelperText ttl={form.values.default_ttl_ms} />,
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
label={t("defaultTtlLabel")}
|
||||
variant="outlined"
|
||||
type="number"
|
||||
/>
|
||||
<Stack direction="row" className={styles.ttlFields}>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"default_ttl_ms",
|
||||
<TTLHelperText
|
||||
translationName="defaultTTLHelperText"
|
||||
ttl={form.values.default_ttl_ms}
|
||||
/>,
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
label={t("defaultTtlLabel")}
|
||||
variant="outlined"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"max_ttl_ms",
|
||||
canSetMaxTTL ? (
|
||||
<TTLHelperText
|
||||
translationName="maxTTLHelperText"
|
||||
ttl={form.values.max_ttl_ms}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{commonT("licenseFieldTextHelper")}{" "}
|
||||
<Link href="https://coder.com/docs/v2/latest/enterprise">
|
||||
{commonT("learnMore")}
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
),
|
||||
)}
|
||||
disabled={isSubmitting || !canSetMaxTTL}
|
||||
fullWidth
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
label={t("maxTtlLabel")}
|
||||
variant="outlined"
|
||||
type="number"
|
||||
/>
|
||||
</Stack>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
|
@ -236,4 +290,8 @@ const useStyles = makeStyles((theme) => ({
|
|||
fontSize: theme.spacing(1.5),
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
|
||||
ttlFields: {
|
||||
width: "100%",
|
||||
},
|
||||
}))
|
||||
|
|
|
@ -16,7 +16,9 @@ const validFormValues = {
|
|||
display_name: "A display name",
|
||||
description: "A description",
|
||||
icon: "vscode.png",
|
||||
// these are the form values which are actually hours
|
||||
default_ttl_ms: 1,
|
||||
max_ttl_ms: 2,
|
||||
allow_user_cancel_workspace_jobs: false,
|
||||
}
|
||||
|
||||
|
@ -36,6 +38,7 @@ const fillAndSubmitForm = async ({
|
|||
display_name,
|
||||
description,
|
||||
default_ttl_ms,
|
||||
max_ttl_ms,
|
||||
icon,
|
||||
allow_user_cancel_workspace_jobs,
|
||||
}: Required<UpdateTemplateMeta>) => {
|
||||
|
@ -61,9 +64,17 @@ const fillAndSubmitForm = async ({
|
|||
await userEvent.type(iconField, icon)
|
||||
|
||||
const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" })
|
||||
const maxTtlField = await screen.findByLabelText(defaultTtlLabel)
|
||||
await userEvent.clear(maxTtlField)
|
||||
await userEvent.type(maxTtlField, default_ttl_ms.toString())
|
||||
const defaultTtlField = await screen.findByLabelText(defaultTtlLabel)
|
||||
await userEvent.clear(defaultTtlField)
|
||||
await userEvent.type(defaultTtlField, default_ttl_ms.toString())
|
||||
|
||||
const entitlements = await API.getEntitlements()
|
||||
if (entitlements.features["advanced_template_scheduling"].enabled) {
|
||||
const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" })
|
||||
const maxTtlField = await screen.findByLabelText(maxTtlLabel)
|
||||
await userEvent.clear(maxTtlField)
|
||||
await userEvent.type(maxTtlField, max_ttl_ms.toString())
|
||||
}
|
||||
|
||||
const allowCancelJobsField = screen.getByRole("checkbox")
|
||||
// checkbox is checked by default, so it must be clicked to get unchecked
|
||||
|
@ -110,12 +121,17 @@ describe("TemplateSettingsPage", () => {
|
|||
|
||||
await fillAndSubmitForm(validFormValues)
|
||||
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
|
||||
expect(API.updateTemplateMeta).toBeCalledWith(
|
||||
"test-template",
|
||||
expect.objectContaining({
|
||||
...validFormValues,
|
||||
default_ttl_ms: 3600000, // the default_ttl_ms to ms
|
||||
}),
|
||||
await waitFor(() =>
|
||||
expect(API.updateTemplateMeta).toBeCalledWith(
|
||||
"test-template",
|
||||
expect.objectContaining({
|
||||
...validFormValues,
|
||||
// convert from the display value (hours) to ms
|
||||
default_ttl_ms: validFormValues.default_ttl_ms * 3600000,
|
||||
// this value is undefined if not entitled
|
||||
max_ttl_ms: undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -144,7 +160,7 @@ describe("TemplateSettingsPage", () => {
|
|||
}
|
||||
const validate = () => getValidationSchema().validateSync(values)
|
||||
expect(validate).toThrowError(
|
||||
t("ttlMaxError", { ns: "templateSettingsPage" }),
|
||||
t("defaultTTLMaxError", { ns: "templateSettingsPage" }),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useMachine } from "@xstate/react"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import { useOrganizationId } from "hooks/useOrganizationId"
|
||||
import { FC } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
|
@ -27,6 +28,9 @@ export const TemplateSettingsPage: FC = () => {
|
|||
saveTemplateSettingsError,
|
||||
getTemplateError,
|
||||
} = state.context
|
||||
const { entitlements } = useDashboard()
|
||||
const canSetMaxTTL =
|
||||
entitlements.features["advanced_template_scheduling"].enabled
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -34,6 +38,7 @@ export const TemplateSettingsPage: FC = () => {
|
|||
<title>{pageTitle(t("title"))}</title>
|
||||
</Helmet>
|
||||
<TemplateSettingsPageView
|
||||
canSetMaxTTL={canSetMaxTTL}
|
||||
isSubmitting={state.hasTag("submitting")}
|
||||
template={template}
|
||||
errors={{
|
||||
|
|
|
@ -10,6 +10,12 @@ import {
|
|||
export default {
|
||||
title: "pages/TemplateSettingsPageView",
|
||||
component: TemplateSettingsPageView,
|
||||
args: {
|
||||
canSetMaxTTL: true,
|
||||
template: Mocks.MockTemplate,
|
||||
onSubmit: action("onSubmit"),
|
||||
onCancel: action("cancel"),
|
||||
},
|
||||
}
|
||||
|
||||
const Template: Story<TemplateSettingsPageViewProps> = (args) => (
|
||||
|
@ -17,10 +23,11 @@ const Template: Story<TemplateSettingsPageViewProps> = (args) => (
|
|||
)
|
||||
|
||||
export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
template: Mocks.MockTemplate,
|
||||
onSubmit: action("onSubmit"),
|
||||
onCancel: action("cancel"),
|
||||
Example.args = {}
|
||||
|
||||
export const CantSetMaxTTL = Template.bind({})
|
||||
CantSetMaxTTL.args = {
|
||||
canSetMaxTTL: false,
|
||||
}
|
||||
|
||||
export const GetTemplateError = Template.bind({})
|
||||
|
@ -32,13 +39,10 @@ GetTemplateError.args = {
|
|||
detail: "You do not have permission to access this resource.",
|
||||
}),
|
||||
},
|
||||
onSubmit: action("onSubmit"),
|
||||
onCancel: action("cancel"),
|
||||
}
|
||||
|
||||
export const SaveTemplateSettingsError = Template.bind({})
|
||||
SaveTemplateSettingsError.args = {
|
||||
template: Mocks.MockTemplate,
|
||||
errors: {
|
||||
saveTemplateSettingsError: makeMockApiError({
|
||||
message: 'Template "test" already exists.',
|
||||
|
@ -53,6 +57,4 @@ SaveTemplateSettingsError.args = {
|
|||
initialTouched: {
|
||||
allow_user_cancel_workspace_jobs: true,
|
||||
},
|
||||
onSubmit: action("onSubmit"),
|
||||
onCancel: action("cancel"),
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ export interface TemplateSettingsPageViewProps {
|
|||
saveTemplateSettingsError?: unknown
|
||||
}
|
||||
initialTouched?: ComponentProps<typeof TemplateSettingsForm>["initialTouched"]
|
||||
canSetMaxTTL: boolean
|
||||
}
|
||||
|
||||
export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
||||
|
@ -25,6 +26,7 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
|||
onCancel,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
canSetMaxTTL,
|
||||
errors = {},
|
||||
initialTouched,
|
||||
}) => {
|
||||
|
@ -43,6 +45,7 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
|||
{template && (
|
||||
<>
|
||||
<TemplateSettingsForm
|
||||
canSetMaxTTL={canSetMaxTTL}
|
||||
initialTouched={initialTouched}
|
||||
isSubmitting={isSubmitting}
|
||||
template={template}
|
||||
|
|
|
@ -287,6 +287,7 @@ export const MockTemplate: TypesGen.Template = {
|
|||
},
|
||||
description: "This is a test description.",
|
||||
default_ttl_ms: 24 * 60 * 60 * 1000,
|
||||
max_ttl_ms: 2 * 24 * 60 * 60 * 1000,
|
||||
created_by_id: "test-creator-id",
|
||||
created_by_name: "test_creator",
|
||||
icon: "/icon/code.svg",
|
||||
|
|
|
@ -41,6 +41,7 @@ export interface CreateTemplateData {
|
|||
description: string
|
||||
icon: string
|
||||
default_ttl_hours: number
|
||||
max_ttl_hours: number
|
||||
allow_user_cancel_workspace_jobs: boolean
|
||||
parameter_values_by_name?: Record<string, string>
|
||||
user_variable_values?: VariableValue[]
|
||||
|
@ -413,6 +414,7 @@ export const createTemplateMachine =
|
|||
|
||||
const {
|
||||
default_ttl_hours,
|
||||
max_ttl_hours,
|
||||
parameter_values_by_name,
|
||||
...safeTemplateData
|
||||
} = templateData
|
||||
|
@ -420,6 +422,7 @@ export const createTemplateMachine =
|
|||
return createTemplate(organizationId, {
|
||||
...safeTemplateData,
|
||||
default_ttl_ms: templateData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms
|
||||
max_ttl_ms: templateData.max_ttl_hours * 60 * 60 * 1000, // Convert hours to ms
|
||||
template_version_id: version.id,
|
||||
})
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue