feat: add template max_ttl (#6114)

Co-authored-by: Bruno Quaresma <bruno@coder.com>
This commit is contained in:
Dean Sheather 2023-03-08 01:14:58 +11:00 committed by GitHub
parent 248c53d68d
commit 66a6b590a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 2090 additions and 398 deletions

View File

@ -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

View File

@ -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

View File

@ -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"
)

View File

@ -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"

View File

@ -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)

View File

@ -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)

View File

@ -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)
})
})
}

View File

@ -39,6 +39,7 @@
"reason": "initiator",
"resources": [],
"deadline": "[timestamp]",
"max_deadline": null,
"status": "running",
"daily_cost": 0
},

View File

@ -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:

View File

@ -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"
)

View File

@ -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)
}

View File

@ -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)
})
}

12
coderd/apidoc/docs.go generated
View File

@ -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",

View File

@ -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": [

View File

@ -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.

View File

@ -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"

View File

@ -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

View File

@ -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: {

View File

@ -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)

View File

@ -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
}

View File

@ -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,

View File

@ -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 (

View File

@ -0,0 +1,3 @@
ALTER TABLE "workspace_builds" DROP COLUMN "max_deadline";
ALTER TABLE "templates" DROP COLUMN "max_ttl";

View File

@ -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;

View File

@ -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
}

View File

@ -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 {

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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 *;

View File

@ -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;

View File

@ -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

View File

@ -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,
})

View File

@ -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)
}

View File

@ -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 (

View File

@ -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) {

View File

@ -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,
})
}

View File

@ -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,

View File

@ -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) {

View File

@ -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),

View File

@ -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),

View File

@ -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) {

View File

@ -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.

View File

@ -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".

View File

@ -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 {

View File

@ -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"`
}

View File

@ -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'. -->

View File

@ -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": [
{

View File

@ -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": [
{

View File

@ -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",

View File

@ -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": [
{

View File

@ -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/>
| | |
| --- | --- |

View File

@ -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,

View File

@ -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 {

View File

@ -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())

View File

@ -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
}

View File

@ -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()

View File

@ -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",

View File

@ -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"
}

View File

@ -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."
}
}
}

View File

@ -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",

View File

@ -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,

View File

@ -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")}

View File

@ -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%",
},
}))

View File

@ -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" }),
)
})

View File

@ -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={{

View File

@ -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"),
}

View File

@ -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}

View File

@ -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",

View File

@ -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,
})
},