feat: add user quiet hours schedule and restart requirement feature flag (#8115)

This commit is contained in:
Dean Sheather
2023-07-20 06:35:41 -07:00
committed by GitHub
parent 4821e2e6d8
commit dc8b73168e
67 changed files with 4340 additions and 767 deletions
-2
View File
@@ -149,8 +149,6 @@ func (r *RootCmd) create() *clibase.Cmd {
var ttlMillis *int64
if stopAfter > 0 {
ttlMillis = ptr.Ref(stopAfter.Milliseconds())
} else if template.MaxTTLMillis > 0 {
ttlMillis = &template.MaxTTLMillis
}
workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, workspaceOwner, codersdk.CreateWorkspaceRequest{
+2 -1
View File
@@ -316,7 +316,8 @@ func TestScheduleOverride(t *testing.T) {
stdoutBuf = &bytes.Buffer{}
)
require.Zero(t, template.DefaultTTLMillis)
require.Zero(t, template.MaxTTLMillis)
require.Empty(t, template.RestartRequirement.DaysOfWeek)
require.Zero(t, template.RestartRequirement.Weeks)
// Unset the workspace TTL
err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil})
+1
View File
@@ -496,6 +496,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
FilesRateLimit: filesRateLimit,
HTTPClient: httpClient,
TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{},
UserQuietHoursScheduleStore: &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{},
SSHConfig: codersdk.SSHConfigResponse{
HostnamePrefix: cfg.SSHConfig.DeploymentName.String(),
SSHConfigOptions: configSSHOptions,
+55 -7
View File
@@ -3,6 +3,7 @@ package cli
import (
"fmt"
"net/http"
"strings"
"time"
"golang.org/x/xerrors"
@@ -20,6 +21,8 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
icon string
defaultTTL time.Duration
maxTTL time.Duration
restartRequirementDaysOfWeek []string
restartRequirementWeeks int64
failureTTL time.Duration
inactivityTTL time.Duration
allowUserCancelWorkspaceJobs bool
@@ -48,7 +51,15 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
}
}
if maxTTL != 0 || !allowUserAutostart || !allowUserAutostop || failureTTL != 0 || inactivityTTL != 0 {
unsetRestartRequirementDaysOfWeek := len(restartRequirementDaysOfWeek) == 1 && restartRequirementDaysOfWeek[0] == "none"
requiresEntitlement := (len(restartRequirementDaysOfWeek) > 0 && !unsetRestartRequirementDaysOfWeek) ||
restartRequirementWeeks > 0 ||
!allowUserAutostart ||
!allowUserAutostop ||
maxTTL != 0 ||
failureTTL != 0 ||
inactivityTTL != 0
if requiresEntitlement {
entitlements, err := client.Entitlements(inv.Context())
var sdkErr *codersdk.Error
if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
@@ -71,14 +82,27 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
return xerrors.Errorf("get workspace template: %w", err)
}
// Copy the default value if the list is empty, or if the user
// specified the "none" value clear the list.
if len(restartRequirementDaysOfWeek) == 0 {
restartRequirementDaysOfWeek = template.RestartRequirement.DaysOfWeek
}
if unsetRestartRequirementDaysOfWeek {
restartRequirementDaysOfWeek = []string{}
}
// NOTE: coderd will ignore empty fields.
req := codersdk.UpdateTemplateMeta{
Name: name,
DisplayName: displayName,
Description: description,
Icon: icon,
DefaultTTLMillis: defaultTTL.Milliseconds(),
MaxTTLMillis: maxTTL.Milliseconds(),
Name: name,
DisplayName: displayName,
Description: description,
Icon: icon,
DefaultTTLMillis: defaultTTL.Milliseconds(),
MaxTTLMillis: maxTTL.Milliseconds(),
RestartRequirement: &codersdk.TemplateRestartRequirement{
DaysOfWeek: restartRequirementDaysOfWeek,
Weeks: restartRequirementWeeks,
},
FailureTTLMillis: failureTTL.Milliseconds(),
InactivityTTLMillis: inactivityTTL.Milliseconds(),
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
@@ -126,6 +150,30 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
Description: "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.",
Value: clibase.DurationOf(&maxTTL),
},
{
Flag: "restart-requirement-weekdays",
Description: "Edit the template restart requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the restart requirement for the template), pass 'none'.",
// TODO(@dean): unhide when we delete max_ttl
Hidden: true,
Value: clibase.Validate(clibase.StringArrayOf(&restartRequirementDaysOfWeek), func(value *clibase.StringArray) error {
v := value.GetSlice()
if len(v) == 1 && v[0] == "none" {
return nil
}
_, err := codersdk.WeekdaysToBitmap(v)
if err != nil {
return xerrors.Errorf("invalid restart requirement days of week %q: %w", strings.Join(v, ","), err)
}
return nil
}),
},
{
Flag: "restart-requirement-weeks",
Description: "Edit the template restart requirement weeks - workspaces created from this template must be restarted on an n-weekly basis.",
// TODO(@dean): unhide when we delete max_ttl
Hidden: true,
Value: clibase.Int64Of(&restartRequirementWeeks),
},
{
Flag: "failure-ttl",
Description: "Specify a failure TTL for workspaces created from this template. This licensed feature's default is 0h (off).",
+313 -8
View File
@@ -242,6 +242,292 @@ func TestTemplateEdit(t *testing.T) {
assert.Equal(t, "", updated.Icon)
assert.Equal(t, "", updated.DisplayName)
})
t.Run("RestartRequirement", 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.RestartRequirement = nil
})
cases := []struct {
name string
flags []string
ok bool
}{
{
name: "Weekdays",
flags: []string{
"--restart-requirement-weekdays", "monday",
},
},
{
name: "WeekdaysNoneAllowed",
flags: []string{
"--restart-requirement-weekdays", "none",
},
ok: true,
},
{
name: "Weeks",
flags: []string{
"--restart-requirement-weeks", "1",
},
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
cmdArgs := []string{
"templates",
"edit",
template.Name,
}
cmdArgs = append(cmdArgs, c.flags...)
inv, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitLong)
err := inv.WithContext(ctx).Run()
if c.ok {
require.NoError(t, err)
} else {
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.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek)
assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks)
})
}
})
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.RestartRequirement = 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,
}
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.
rp := httputil.NewSingleHostReverseProxy(client.URL)
rp.Transport = &http.Transport{
DisableKeepAlives: true,
}
rp.ServeHTTP(w, r)
}))
t.Cleanup(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())
cases := []struct {
name string
flags []string
ok bool
}{
{
name: "Weekdays",
flags: []string{
"--restart-requirement-weekdays", "monday",
},
},
{
name: "WeekdaysNoneAllowed",
flags: []string{
"--restart-requirement-weekdays", "none",
},
ok: true,
},
{
name: "Weeks",
flags: []string{
"--restart-requirement-weeks", "1",
},
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
cmdArgs := []string{
"templates",
"edit",
template.Name,
}
cmdArgs = append(cmdArgs, c.flags...)
inv, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, proxyClient, root)
ctx := testutil.Context(t, testutil.WaitLong)
err = inv.WithContext(ctx).Run()
if c.ok {
require.NoError(t, err)
} else {
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.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek)
assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks)
})
}
})
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.RestartRequirement = 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,
}
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, req.RestartRequirement.DaysOfWeek, []string{"monday", "tuesday"})
assert.EqualValues(t, req.RestartRequirement.Weeks, 3)
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.
rp := httputil.NewSingleHostReverseProxy(client.URL)
rp.Transport = &http.Transport{
DisableKeepAlives: true,
}
rp.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,
"--restart-requirement-weekdays", "monday,tuesday",
"--restart-requirement-weeks", "3",
}
inv, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, proxyClient, root)
ctx := testutil.Context(t, testutil.WaitLong)
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled))
// Assert that the template metadata did not change. We verify the
// correct request gets sent to the server already.
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.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek)
assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks)
})
})
// TODO(@dean): remove this test when we remove max_ttl
t.Run("MaxTTL", func(t *testing.T) {
t.Parallel()
t.Run("BlockedAGPL", func(t *testing.T) {
@@ -317,7 +603,11 @@ func TestTemplateEdit(t *testing.T) {
}
// Otherwise, proxy the request to the real API server.
httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r)
rp := httputil.NewSingleHostReverseProxy(client.URL)
rp.Transport = &http.Transport{
DisableKeepAlives: true,
}
rp.ServeHTTP(w, r)
}))
defer proxy.Close()
@@ -404,7 +694,11 @@ func TestTemplateEdit(t *testing.T) {
}
// Otherwise, proxy the request to the real API server.
httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r)
rp := httputil.NewSingleHostReverseProxy(client.URL)
rp.Transport = &http.Transport{
DisableKeepAlives: true,
}
rp.ServeHTTP(w, r)
}))
defer proxy.Close()
@@ -452,7 +746,7 @@ func TestTemplateEdit(t *testing.T) {
_ = 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
ctr.RestartRequirement = nil
ctr.FailureTTLMillis = nil
ctr.InactivityTTLMillis = nil
})
@@ -495,7 +789,8 @@ func TestTemplateEdit(t *testing.T) {
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)
assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek)
assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks)
assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart)
assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop)
assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis)
@@ -535,7 +830,11 @@ func TestTemplateEdit(t *testing.T) {
}
// Otherwise, proxy the request to the real API server.
httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r)
rp := httputil.NewSingleHostReverseProxy(client.URL)
rp.Transport = &http.Transport{
DisableKeepAlives: true,
}
rp.ServeHTTP(w, r)
}))
defer proxy.Close()
@@ -583,7 +882,8 @@ func TestTemplateEdit(t *testing.T) {
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)
assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek)
assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks)
assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart)
assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop)
assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis)
@@ -639,7 +939,11 @@ func TestTemplateEdit(t *testing.T) {
}
// Otherwise, proxy the request to the real API server.
httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r)
rp := httputil.NewSingleHostReverseProxy(client.URL)
rp.Transport = &http.Transport{
DisableKeepAlives: true,
}
rp.ServeHTTP(w, r)
}))
defer proxy.Close()
@@ -675,7 +979,8 @@ func TestTemplateEdit(t *testing.T) {
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)
assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek)
assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks)
assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart)
assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop)
assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis)
+14
View File
@@ -377,6 +377,20 @@ telemetrywhen required by your organization's security policy.
anonymized application tracing to help improve our product. Disabling
telemetry also disables this option.
User Quiet Hours Schedule Options
Allow users to set quiet hours schedules each day for workspaces to avoid
workspaces stopping during the day due to template max TTL.
--default-quiet-hours-schedule string, $CODER_QUIET_HOURS_DEFAULT_SCHEDULE
The default daily cron schedule applied to users that haven't set a
custom quiet hours schedule themselves. The quiet hours schedule
determines when workspaces will be force stopped due to the template's
max TTL, and will round the max TTL up to be within the user's quiet
hours window (or default). The format is the same as the standard cron
format, but the day-of-month, month and day-of-week must be *. Only
one hour and minute can be specified (ranges or comma separated values
are not supported).
⚠️ Dangerous Options
--dangerous-allow-path-app-sharing bool, $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING
Allow workspace apps that are not served from subdomains to be shared.
+12
View File
@@ -369,3 +369,15 @@ supportLinks: []
# "tunnel.example.com".
# (default: <unset>, type: string)
wgtunnelHost: ""
# Allow users to set quiet hours schedules each day for workspaces to avoid
# workspaces stopping during the day due to template max TTL.
userQuietHoursSchedule:
# The default daily cron schedule applied to users that haven't set a custom quiet
# hours schedule themselves. The quiet hours schedule determines when workspaces
# will be force stopped due to the template's max TTL, and will round the max TTL
# up to be within the user's quiet hours window (or default). The format is the
# same as the standard cron format, but the day-of-month, month and day-of-week
# must be *. Only one hour and minute can be specified (ranges or comma separated
# values are not supported).
# (default: <unset>, type: string)
defaultQuietHoursSchedule: ""
+27 -4
View File
@@ -12,6 +12,7 @@ import (
"github.com/coder/coder/agent"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbtestutil"
"github.com/coder/coder/coderd/schedule"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/codersdk/agentsdk"
@@ -25,14 +26,20 @@ func TestWorkspaceActivityBump(t *testing.T) {
ctx := context.Background()
setupActivityTest := func(t *testing.T, maxDeadline ...time.Duration) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) {
// deadline allows you to forcibly set a max_deadline on the build. This
// doesn't use template restart requirements and instead edits the
// max_deadline on the build directly in the database.
setupActivityTest := func(t *testing.T, deadline ...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]
if len(deadline) > 0 {
maxTTL = deadline[0]
}
db, pubsub := dbtestutil.NewDB(t)
client = coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: pubsub,
IncludeProvisionerDaemon: true,
// Agent stats trigger the activity bump, so we want to report
// very frequently in tests.
@@ -42,7 +49,8 @@ func TestWorkspaceActivityBump(t *testing.T) {
return schedule.TemplateScheduleOptions{
UserAutostopEnabled: true,
DefaultTTL: ttl,
MaxTTL: maxTTL,
// We set max_deadline manually below.
RestartRequirement: schedule.TemplateRestartRequirement{},
}, nil
},
},
@@ -79,6 +87,21 @@ func TestWorkspaceActivityBump(t *testing.T) {
})
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// Update the max deadline.
if maxTTL != 0 {
dbBuild, err := db.GetWorkspaceBuildByID(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
_, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
ID: workspace.LatestBuild.ID,
UpdatedAt: database.Now(),
ProvisionerState: dbBuild.ProvisionerState,
Deadline: dbBuild.Deadline,
MaxDeadline: database.Now().Add(maxTTL),
})
require.NoError(t, err)
}
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(agentToken)
agentCloser := agent.New(agent.Options{
+181 -4
View File
@@ -3829,6 +3829,92 @@ const docTemplate = `{
}
}
},
"/users/{user}/quiet-hours": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get user quiet hours schedule",
"operationId": "get-user-quiet-hours-schedule",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "User ID",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse"
}
}
}
}
},
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Update user quiet hours schedule",
"operationId": "update-user-quiet-hours-schedule",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "User ID",
"name": "user",
"in": "path",
"required": true
},
{
"description": "Update schedule request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateUserQuietHoursScheduleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse"
}
}
}
}
}
},
"/users/{user}/roles": {
"get": {
"security": [
@@ -7002,13 +7088,21 @@ const docTemplate = `{
"type": "integer"
},
"max_ttl_ms": {
"description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.",
"description": "TODO(@dean): remove max_ttl once restart_requirement is matured",
"type": "integer"
},
"name": {
"description": "Name is the name of the template.",
"type": "string"
},
"restart_requirement": {
"description": "RestartRequirement allows optionally specifying the restart requirement\nfor workspaces created from this template. This is an enterprise feature.",
"allOf": [
{
"$ref": "#/definitions/codersdk.TemplateRestartRequirement"
}
]
},
"template_version_id": {
"description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.",
"type": "string",
@@ -7611,6 +7705,9 @@ const docTemplate = `{
"update_check": {
"type": "boolean"
},
"user_quiet_hours_schedule": {
"$ref": "#/definitions/codersdk.UserQuietHoursScheduleConfig"
},
"verbose": {
"type": "boolean"
},
@@ -7678,7 +7775,8 @@ const docTemplate = `{
"tailnet_ha_coordinator",
"convert-to-oidc",
"single_tailnet",
"workspace_build_logs_ui"
"workspace_build_logs_ui",
"template_restart_requirement"
],
"x-enum-varnames": [
"ExperimentMoons",
@@ -7686,7 +7784,8 @@ const docTemplate = `{
"ExperimentTailnetHACoordinator",
"ExperimentConvertToOIDC",
"ExperimentSingleTailnet",
"ExperimentWorkspaceBuildLogsUI"
"ExperimentWorkspaceBuildLogsUI",
"ExperimentTemplateRestartRequirement"
]
},
"codersdk.Feature": {
@@ -8996,7 +9095,7 @@ const docTemplate = `{
"type": "integer"
},
"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.",
"description": "TODO(@dean): remove max_ttl once restart_requirement is matured",
"type": "integer"
},
"name": {
@@ -9012,6 +9111,14 @@ const docTemplate = `{
"terraform"
]
},
"restart_requirement": {
"description": "RestartRequirement is an enterprise feature. Its value is only used if\nyour license is entitled to use the advanced template scheduling feature.",
"allOf": [
{
"$ref": "#/definitions/codersdk.TemplateRestartRequirement"
}
]
},
"updated_at": {
"type": "string",
"format": "date-time"
@@ -9054,6 +9161,31 @@ const docTemplate = `{
}
}
},
"codersdk.TemplateRestartRequirement": {
"type": "object",
"properties": {
"days_of_week": {
"description": "DaysOfWeek is a list of days of the week on which restarts are required.\nRestarts happen within the user's quiet hours (in their configured\ntimezone). If no days are specified, restarts are not required. Weekdays\ncannot be specified twice.\n\nRestarts will only happen on weekdays in this list on weeks which line up\nwith Weeks.",
"type": "array",
"items": {
"type": "string",
"enum": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
]
}
},
"weeks": {
"description": "Weeks is the number of weeks between required restarts. Weeks are synced\nacross all workspaces (and Coder deployments) using modulo math on a\nhardcoded epoch week of January 2nd, 2023 (the first Monday of 2023).\nValues of 0 or 1 indicate weekly restarts. Values of 2 indicate\nfortnightly restarts, etc.",
"type": "integer"
}
}
},
"codersdk.TemplateRole": {
"type": "string",
"enum": [
@@ -9467,6 +9599,18 @@ const docTemplate = `{
}
}
},
"codersdk.UpdateUserQuietHoursScheduleRequest": {
"type": "object",
"required": [
"schedule"
],
"properties": {
"schedule": {
"description": "Schedule is a cron expression that defines when the user's quiet hours\nwindow is. Schedule must not be empty. For new users, the schedule is set\nto 2am in their browser or computer's timezone. The schedule denotes the\nbeginning of a 4 hour window where the workspace is allowed to\nautomatically stop or restart due to maintenance or template max TTL.\n\nThe schedule must be daily with a single time, and should have a timezone\nspecified via a CRON_TZ prefix (otherwise UTC will be used).\n\nIf the schedule is empty, the user will be updated to use the default\nschedule.",
"type": "string"
}
}
},
"codersdk.UpdateWorkspaceAutostartRequest": {
"type": "object",
"properties": {
@@ -9574,6 +9718,39 @@ const docTemplate = `{
}
}
},
"codersdk.UserQuietHoursScheduleConfig": {
"type": "object",
"properties": {
"default_schedule": {
"type": "string"
}
}
},
"codersdk.UserQuietHoursScheduleResponse": {
"type": "object",
"properties": {
"next": {
"description": "Next is the next time that the quiet hours window will start.",
"type": "string",
"format": "date-time"
},
"raw_schedule": {
"type": "string"
},
"time": {
"description": "Time is the time of day that the quiet hours window starts in the given\nTimezone each day.",
"type": "string"
},
"timezone": {
"description": "raw format from the cron expression, UTC if unspecified",
"type": "string"
},
"user_set": {
"description": "UserSet is true if the user has set their own quiet hours schedule. If\nfalse, the user is using the default schedule.",
"type": "boolean"
}
}
},
"codersdk.UserStatus": {
"type": "string",
"enum": [
+169 -4
View File
@@ -3373,6 +3373,82 @@
}
}
},
"/users/{user}/quiet-hours": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get user quiet hours schedule",
"operationId": "get-user-quiet-hours-schedule",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "User ID",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse"
}
}
}
}
},
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Update user quiet hours schedule",
"operationId": "update-user-quiet-hours-schedule",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "User ID",
"name": "user",
"in": "path",
"required": true
},
{
"description": "Update schedule request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateUserQuietHoursScheduleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse"
}
}
}
}
}
},
"/users/{user}/roles": {
"get": {
"security": [
@@ -6239,13 +6315,21 @@
"type": "integer"
},
"max_ttl_ms": {
"description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.",
"description": "TODO(@dean): remove max_ttl once restart_requirement is matured",
"type": "integer"
},
"name": {
"description": "Name is the name of the template.",
"type": "string"
},
"restart_requirement": {
"description": "RestartRequirement allows optionally specifying the restart requirement\nfor workspaces created from this template. This is an enterprise feature.",
"allOf": [
{
"$ref": "#/definitions/codersdk.TemplateRestartRequirement"
}
]
},
"template_version_id": {
"description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.",
"type": "string",
@@ -6810,6 +6894,9 @@
"update_check": {
"type": "boolean"
},
"user_quiet_hours_schedule": {
"$ref": "#/definitions/codersdk.UserQuietHoursScheduleConfig"
},
"verbose": {
"type": "boolean"
},
@@ -6873,7 +6960,8 @@
"tailnet_ha_coordinator",
"convert-to-oidc",
"single_tailnet",
"workspace_build_logs_ui"
"workspace_build_logs_ui",
"template_restart_requirement"
],
"x-enum-varnames": [
"ExperimentMoons",
@@ -6881,7 +6969,8 @@
"ExperimentTailnetHACoordinator",
"ExperimentConvertToOIDC",
"ExperimentSingleTailnet",
"ExperimentWorkspaceBuildLogsUI"
"ExperimentWorkspaceBuildLogsUI",
"ExperimentTemplateRestartRequirement"
]
},
"codersdk.Feature": {
@@ -8123,7 +8212,7 @@
"type": "integer"
},
"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.",
"description": "TODO(@dean): remove max_ttl once restart_requirement is matured",
"type": "integer"
},
"name": {
@@ -8137,6 +8226,14 @@
"type": "string",
"enum": ["terraform"]
},
"restart_requirement": {
"description": "RestartRequirement is an enterprise feature. Its value is only used if\nyour license is entitled to use the advanced template scheduling feature.",
"allOf": [
{
"$ref": "#/definitions/codersdk.TemplateRestartRequirement"
}
]
},
"updated_at": {
"type": "string",
"format": "date-time"
@@ -8179,6 +8276,31 @@
}
}
},
"codersdk.TemplateRestartRequirement": {
"type": "object",
"properties": {
"days_of_week": {
"description": "DaysOfWeek is a list of days of the week on which restarts are required.\nRestarts happen within the user's quiet hours (in their configured\ntimezone). If no days are specified, restarts are not required. Weekdays\ncannot be specified twice.\n\nRestarts will only happen on weekdays in this list on weeks which line up\nwith Weeks.",
"type": "array",
"items": {
"type": "string",
"enum": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
]
}
},
"weeks": {
"description": "Weeks is the number of weeks between required restarts. Weeks are synced\nacross all workspaces (and Coder deployments) using modulo math on a\nhardcoded epoch week of January 2nd, 2023 (the first Monday of 2023).\nValues of 0 or 1 indicate weekly restarts. Values of 2 indicate\nfortnightly restarts, etc.",
"type": "integer"
}
}
},
"codersdk.TemplateRole": {
"type": "string",
"enum": ["admin", "use", ""],
@@ -8553,6 +8675,16 @@
}
}
},
"codersdk.UpdateUserQuietHoursScheduleRequest": {
"type": "object",
"required": ["schedule"],
"properties": {
"schedule": {
"description": "Schedule is a cron expression that defines when the user's quiet hours\nwindow is. Schedule must not be empty. For new users, the schedule is set\nto 2am in their browser or computer's timezone. The schedule denotes the\nbeginning of a 4 hour window where the workspace is allowed to\nautomatically stop or restart due to maintenance or template max TTL.\n\nThe schedule must be daily with a single time, and should have a timezone\nspecified via a CRON_TZ prefix (otherwise UTC will be used).\n\nIf the schedule is empty, the user will be updated to use the default\nschedule.",
"type": "string"
}
}
},
"codersdk.UpdateWorkspaceAutostartRequest": {
"type": "object",
"properties": {
@@ -8652,6 +8784,39 @@
}
}
},
"codersdk.UserQuietHoursScheduleConfig": {
"type": "object",
"properties": {
"default_schedule": {
"type": "string"
}
}
},
"codersdk.UserQuietHoursScheduleResponse": {
"type": "object",
"properties": {
"next": {
"description": "Next is the next time that the quiet hours window will start.",
"type": "string",
"format": "date-time"
},
"raw_schedule": {
"type": "string"
},
"time": {
"description": "Time is the time of day that the quiet hours window starts in the given\nTimezone each day.",
"type": "string"
},
"timezone": {
"description": "raw format from the cron expression, UTC if unspecified",
"type": "string"
},
"user_set": {
"description": "UserSet is true if the user has set their own quiet hours schedule. If\nfalse, the user is using the default schedule.",
"type": "boolean"
}
}
},
"codersdk.UserStatus": {
"type": "string",
"enum": ["active", "suspended"],
+1 -1
View File
@@ -142,7 +142,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
log.Warn(e.ctx, "get latest workspace build", slog.Error(err))
return nil
}
templateSchedule, err := (*(e.templateScheduleStore.Load())).GetTemplateScheduleOptions(e.ctx, tx, ws.TemplateID)
templateSchedule, err := (*(e.templateScheduleStore.Load())).Get(e.ctx, tx, ws.TemplateID)
if err != nil {
log.Warn(e.ctx, "get template schedule options", slog.Error(err))
return nil
+1 -1
View File
@@ -623,7 +623,7 @@ func TestExecutorAutostartTemplateDisabled(t *testing.T) {
UserAutostartEnabled: false,
UserAutostopEnabled: true,
DefaultTTL: 0,
MaxTTL: 0,
RestartRequirement: schedule.TemplateRestartRequirement{},
}, nil
},
},
+41 -28
View File
@@ -118,13 +118,14 @@ 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
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
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 *atomic.Pointer[schedule.TemplateScheduleStore]
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
// AppSecurityKey is the crypto key used to sign and encrypt tokens related to
// workspace applications. It consists of both a signing and encryption key.
AppSecurityKey workspaceapps.SecurityKey
@@ -264,6 +265,13 @@ func New(options *Options) *API {
v := schedule.NewAGPLTemplateScheduleStore()
options.TemplateScheduleStore.Store(&v)
}
if options.UserQuietHoursScheduleStore == nil {
options.UserQuietHoursScheduleStore = &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{}
}
if options.UserQuietHoursScheduleStore.Load() == nil {
v := schedule.NewAGPLUserQuietHoursScheduleStore()
options.UserQuietHoursScheduleStore.Store(&v)
}
if options.HealthcheckFunc == nil {
options.HealthcheckFunc = func(ctx context.Context, apiKey string) *healthcheck.Report {
return healthcheck.Run(ctx, &healthcheck.ReportOptions{
@@ -345,11 +353,12 @@ func New(options *Options) *API {
options.AgentInactiveDisconnectTimeout,
options.AppSecurityKey,
),
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
TemplateScheduleStore: options.TemplateScheduleStore,
Experiments: experiments,
healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{},
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
TemplateScheduleStore: options.TemplateScheduleStore,
UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore,
Experiments: experiments,
healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{},
}
if options.UpdateCheckOptions != nil {
api.updateChecker = updatecheck.New(
@@ -930,6 +939,9 @@ type API struct {
// TemplateScheduleStore is a pointer to an atomic pointer because this is
// passed to another struct, and we want them all to be the same reference.
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
// UserQuietHoursScheduleStore is a pointer to an atomic pointer for the
// same reason as TemplateScheduleStore.
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
HTTPAuth *HTTPAuthorizer
@@ -1040,22 +1052,23 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti
mux := drpcmux.New()
err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{
AccessURL: api.AccessURL,
ID: daemon.ID,
OIDCConfig: api.OIDCConfig,
Database: api.Database,
Pubsub: api.Pubsub,
Provisioners: daemon.Provisioners,
GitAuthConfigs: api.GitAuthConfigs,
Telemetry: api.Telemetry,
Tracer: tracer,
Tags: tags,
QuotaCommitter: &api.QuotaCommitter,
Auditor: &api.Auditor,
TemplateScheduleStore: api.TemplateScheduleStore,
AcquireJobDebounce: debounce,
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
DeploymentValues: api.DeploymentValues,
AccessURL: api.AccessURL,
ID: daemon.ID,
OIDCConfig: api.OIDCConfig,
Database: api.Database,
Pubsub: api.Pubsub,
Provisioners: daemon.Provisioners,
GitAuthConfigs: api.GitAuthConfigs,
Telemetry: api.Telemetry,
Tracer: tracer,
Tags: tags,
QuotaCommitter: &api.QuotaCommitter,
Auditor: &api.Auditor,
TemplateScheduleStore: api.TemplateScheduleStore,
UserQuietHoursScheduleStore: api.UserQuietHoursScheduleStore,
AcquireJobDebounce: debounce,
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
DeploymentValues: api.DeploymentValues,
})
if err != nil {
return nil, err
+7 -7
View File
@@ -2310,6 +2310,13 @@ func (q *querier) UpdateUserProfile(ctx context.Context, arg database.UpdateUser
return q.db.UpdateUserProfile(ctx, arg)
}
func (q *querier) UpdateUserQuietHoursSchedule(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) {
fetch := func(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) {
return q.db.GetUserByID(ctx, arg.ID)
}
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateUserQuietHoursSchedule)(ctx, arg)
}
// UpdateUserRoles updates the site roles of a user. The validation for this function include more than
// just a basic RBAC check.
func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRolesParams) (database.User, error) {
@@ -2509,13 +2516,6 @@ func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWor
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceTTL)(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) UpsertAppSecurityKey(ctx context.Context, data string) error {
// No authz checks as this is done during startup
return q.db.UpsertAppSecurityKey(ctx, data)
+21 -20
View File
@@ -4296,6 +4296,8 @@ func (q *FakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database
tpl.UpdatedAt = database.Now()
tpl.DefaultTTL = arg.DefaultTTL
tpl.MaxTTL = arg.MaxTTL
tpl.RestartRequirementDaysOfWeek = arg.RestartRequirementDaysOfWeek
tpl.RestartRequirementWeeks = arg.RestartRequirementWeeks
tpl.FailureTTL = arg.FailureTTL
tpl.InactivityTTL = arg.InactivityTTL
tpl.LockedTTL = arg.LockedTTL
@@ -4524,6 +4526,25 @@ func (q *FakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUs
return database.User{}, sql.ErrNoRows
}
func (q *FakeQuerier) UpdateUserQuietHoursSchedule(_ context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) {
if err := validateDatabaseType(arg); err != nil {
return database.User{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for index, user := range q.users {
if user.ID != arg.ID {
continue
}
user.QuietHoursSchedule = arg.QuietHoursSchedule
q.users[index] = user
return user, nil
}
return database.User{}, sql.ErrNoRows
}
func (q *FakeQuerier) UpdateUserRoles(_ context.Context, arg database.UpdateUserRolesParams) (database.User, error) {
if err := validateDatabaseType(arg); err != nil {
return database.User{}, err
@@ -4911,26 +4932,6 @@ func (q *FakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW
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) UpsertAppSecurityKey(_ context.Context, data string) error {
q.mutex.Lock()
defer q.mutex.Unlock()
+7 -7
View File
@@ -1411,6 +1411,13 @@ func (m metricsStore) UpdateUserProfile(ctx context.Context, arg database.Update
return user, err
}
func (m metricsStore) UpdateUserQuietHoursSchedule(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserQuietHoursSchedule(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserQuietHoursSchedule").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRolesParams) (database.User, error) {
start := time.Now()
user, err := m.s.UpdateUserRoles(ctx, arg)
@@ -1537,13 +1544,6 @@ func (m metricsStore) UpdateWorkspaceTTL(ctx context.Context, arg database.Updat
return r0
}
func (m metricsStore) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspaceTTLToBeWithinTemplateMax(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateWorkspaceTTLToBeWithinTemplateMax").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) UpsertAppSecurityKey(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertAppSecurityKey(ctx, value)
+15 -14
View File
@@ -2975,6 +2975,21 @@ func (mr *MockStoreMockRecorder) UpdateUserProfile(arg0, arg1 interface{}) *gomo
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserProfile", reflect.TypeOf((*MockStore)(nil).UpdateUserProfile), arg0, arg1)
}
// UpdateUserQuietHoursSchedule mocks base method.
func (m *MockStore) UpdateUserQuietHoursSchedule(arg0 context.Context, arg1 database.UpdateUserQuietHoursScheduleParams) (database.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserQuietHoursSchedule", arg0, arg1)
ret0, _ := ret[0].(database.User)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserQuietHoursSchedule indicates an expected call of UpdateUserQuietHoursSchedule.
func (mr *MockStoreMockRecorder) UpdateUserQuietHoursSchedule(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserQuietHoursSchedule", reflect.TypeOf((*MockStore)(nil).UpdateUserQuietHoursSchedule), arg0, arg1)
}
// UpdateUserRoles mocks base method.
func (m *MockStore) UpdateUserRoles(arg0 context.Context, arg1 database.UpdateUserRolesParams) (database.User, error) {
m.ctrl.T.Helper()
@@ -3233,20 +3248,6 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceTTL(arg0, arg1 interface{}) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTL), arg0, arg1)
}
// UpdateWorkspaceTTLToBeWithinTemplateMax mocks base method.
func (m *MockStore) UpdateWorkspaceTTLToBeWithinTemplateMax(arg0 context.Context, arg1 database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWorkspaceTTLToBeWithinTemplateMax", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateWorkspaceTTLToBeWithinTemplateMax indicates an expected call of UpdateWorkspaceTTLToBeWithinTemplateMax.
func (mr *MockStoreMockRecorder) UpdateWorkspaceTTLToBeWithinTemplateMax(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTLToBeWithinTemplateMax", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTLToBeWithinTemplateMax), arg0, arg1)
}
// UpsertAppSecurityKey mocks base method.
func (m *MockStore) UpsertAppSecurityKey(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
+13 -2
View File
@@ -566,7 +566,9 @@ CREATE TABLE templates (
allow_user_autostop boolean DEFAULT true NOT NULL,
failure_ttl bigint DEFAULT 0 NOT NULL,
inactivity_ttl bigint DEFAULT 0 NOT NULL,
locked_ttl bigint DEFAULT 0 NOT NULL
locked_ttl bigint DEFAULT 0 NOT NULL,
restart_requirement_days_of_week smallint DEFAULT 0 NOT NULL,
restart_requirement_weeks bigint DEFAULT 0 NOT NULL
);
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.';
@@ -579,6 +581,10 @@ COMMENT ON COLUMN templates.allow_user_autostart IS 'Allow users to specify an a
COMMENT ON COLUMN templates.allow_user_autostop IS 'Allow users to specify custom autostop values for workspaces (enterprise).';
COMMENT ON COLUMN templates.restart_requirement_days_of_week IS 'A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused.';
COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.';
CREATE TABLE users (
id uuid NOT NULL,
email text NOT NULL,
@@ -591,9 +597,12 @@ CREATE TABLE users (
login_type login_type DEFAULT 'password'::login_type NOT NULL,
avatar_url text,
deleted boolean DEFAULT false NOT NULL,
last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL
last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL,
quiet_hours_schedule text DEFAULT ''::text NOT NULL
);
COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.';
CREATE VIEW visible_users AS
SELECT users.id,
users.username,
@@ -625,6 +634,8 @@ CREATE VIEW template_with_users AS
templates.failure_ttl,
templates.inactivity_ttl,
templates.locked_ttl,
templates.restart_requirement_days_of_week,
templates.restart_requirement_weeks,
COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url,
COALESCE(visible_users.username, ''::text) AS created_by_username
FROM (public.templates
@@ -0,0 +1,29 @@
BEGIN;
-- Delete the new version of the template_with_users view to remove the column
-- dependency.
DROP VIEW template_with_users;
ALTER TABLE templates
DROP COLUMN restart_requirement_days_of_week,
DROP COLUMN restart_requirement_weeks;
ALTER TABLE users DROP COLUMN quiet_hours_schedule;
-- Restore the old version of the template_with_users view.
CREATE VIEW
template_with_users
AS
SELECT
templates.*,
coalesce(visible_users.avatar_url, '') AS created_by_avatar_url,
coalesce(visible_users.username, '') AS created_by_username
FROM
templates
LEFT JOIN
visible_users
ON
templates.created_by = visible_users.id;
COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.';
COMMIT;
@@ -0,0 +1,35 @@
BEGIN;
ALTER TABLE templates
-- The max_ttl column will be dropped eventually when the new "restart
-- requirement" feature flag is fully rolled out.
-- DROP COLUMN max_ttl,
ADD COLUMN restart_requirement_days_of_week smallint NOT NULL DEFAULT 0,
ADD COLUMN restart_requirement_weeks bigint NOT NULL DEFAULT 0;
COMMENT ON COLUMN templates.restart_requirement_days_of_week IS 'A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused.';
COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.';
ALTER TABLE users
ADD COLUMN quiet_hours_schedule text NOT NULL DEFAULT '';
COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.';
-- Update the template_with_users view by recreating it.
DROP VIEW template_with_users;
CREATE VIEW
template_with_users
AS
SELECT
templates.*,
coalesce(visible_users.avatar_url, '') AS created_by_avatar_url,
coalesce(visible_users.username, '') AS created_by_username
FROM
templates
LEFT JOIN
visible_users
ON
templates.created_by = visible_users.id;
COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.';
COMMIT;
+3
View File
@@ -83,6 +83,8 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate
&i.FailureTTL,
&i.InactivityTTL,
&i.LockedTTL,
&i.RestartRequirementDaysOfWeek,
&i.RestartRequirementWeeks,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
); err != nil {
@@ -304,6 +306,7 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
&i.Count,
); err != nil {
return nil, err
+8
View File
@@ -1591,6 +1591,8 @@ type Template struct {
FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"`
InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"`
LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"`
RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"`
RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"`
CreatedByAvatarURL string `db:"created_by_avatar_url" json:"created_by_avatar_url"`
CreatedByUsername string `db:"created_by_username" json:"created_by_username"`
}
@@ -1623,6 +1625,10 @@ type TemplateTable struct {
FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"`
InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"`
LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"`
// A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused.
RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"`
// The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.
RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"`
}
type TemplateVersion struct {
@@ -1708,6 +1714,8 @@ type User struct {
AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"`
Deleted bool `db:"deleted" json:"deleted"`
LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"`
// Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user's quiet hours. If empty, the default quiet hours on the instance is used instead.
QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"`
}
type UserLink struct {
+1 -1
View File
@@ -19,7 +19,7 @@ func TestViewSubsetTemplate(t *testing.T) {
joinedFields := allFields(joined)
if !assert.Subset(t, fieldNames(joinedFields), fieldNames(tableFields), "table is not subset") {
t.Log("Some fields were added to the Template Table without updating the 'template_with_users' view.")
t.Log("See migration 000138_join_users_up.sql to create the view.")
t.Log("See migration 000138_join_users.up.sql to create the view.")
}
}
+1 -1
View File
@@ -240,6 +240,7 @@ type sqlcQuerier interface {
UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error)
UpdateUserLoginType(ctx context.Context, arg UpdateUserLoginTypeParams) (User, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error)
UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error)
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error)
@@ -259,7 +260,6 @@ type sqlcQuerier interface {
UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error)
UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error
UpsertAppSecurityKey(ctx context.Context, value string) error
// The default proxy is implied and not actually stored in the database.
// So we need to store it's configuration here for display purposes.
+102 -65
View File
@@ -1066,7 +1066,7 @@ func (q *sqlQuerier) DeleteGroupMembersByOrgAndUser(ctx context.Context, arg Del
const getGroupMembers = `-- name: GetGroupMembers :many
SELECT
users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at
users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at, users.quiet_hours_schedule
FROM
users
JOIN
@@ -1103,6 +1103,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
); err != nil {
return nil, err
}
@@ -3637,7 +3638,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, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, created_by_avatar_url, created_by_username
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, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username
FROM
template_with_users
WHERE
@@ -3672,6 +3673,8 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat
&i.FailureTTL,
&i.InactivityTTL,
&i.LockedTTL,
&i.RestartRequirementDaysOfWeek,
&i.RestartRequirementWeeks,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
)
@@ -3680,7 +3683,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat
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, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, created_by_avatar_url, created_by_username
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, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username
FROM
template_with_users AS templates
WHERE
@@ -3723,6 +3726,8 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G
&i.FailureTTL,
&i.InactivityTTL,
&i.LockedTTL,
&i.RestartRequirementDaysOfWeek,
&i.RestartRequirementWeeks,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
)
@@ -3730,7 +3735,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G
}
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, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, created_by_avatar_url, created_by_username FROM template_with_users AS 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, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates
ORDER BY (name, id) ASC
`
@@ -3766,6 +3771,8 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) {
&i.FailureTTL,
&i.InactivityTTL,
&i.LockedTTL,
&i.RestartRequirementDaysOfWeek,
&i.RestartRequirementWeeks,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
); err != nil {
@@ -3784,7 +3791,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, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, created_by_avatar_url, created_by_username
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, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username
FROM
template_with_users AS templates
WHERE
@@ -3857,6 +3864,8 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate
&i.FailureTTL,
&i.InactivityTTL,
&i.LockedTTL,
&i.RestartRequirementDaysOfWeek,
&i.RestartRequirementWeeks,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
); err != nil {
@@ -4041,23 +4050,27 @@ SET
allow_user_autostop = $4,
default_ttl = $5,
max_ttl = $6,
failure_ttl = $7,
inactivity_ttl = $8,
locked_ttl = $9
restart_requirement_days_of_week = $7,
restart_requirement_weeks = $8,
failure_ttl = $9,
inactivity_ttl = $10,
locked_ttl = $11
WHERE
id = $1
`
type UpdateTemplateScheduleByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"`
AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"`
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
MaxTTL int64 `db:"max_ttl" json:"max_ttl"`
FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"`
InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"`
LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"`
ID uuid.UUID `db:"id" json:"id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"`
AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"`
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
MaxTTL int64 `db:"max_ttl" json:"max_ttl"`
RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"`
RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"`
FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"`
InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"`
LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"`
}
func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error {
@@ -4068,6 +4081,8 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT
arg.AllowUserAutostop,
arg.DefaultTTL,
arg.MaxTTL,
arg.RestartRequirementDaysOfWeek,
arg.RestartRequirementWeeks,
arg.FailureTTL,
arg.InactivityTTL,
arg.LockedTTL,
@@ -5012,7 +5027,7 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.
const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule
FROM
users
WHERE
@@ -5043,13 +5058,14 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule
FROM
users
WHERE
@@ -5074,6 +5090,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
)
return i, err
}
@@ -5096,7 +5113,7 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) {
const getUsers = `-- name: GetUsers :many
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, COUNT(*) OVER() AS count
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, COUNT(*) OVER() AS count
FROM
users
WHERE
@@ -5180,19 +5197,20 @@ type GetUsersParams struct {
}
type GetUsersRow struct {
ID uuid.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
HashedPassword []byte `db:"hashed_password" json:"hashed_password"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Status UserStatus `db:"status" json:"status"`
RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"`
LoginType LoginType `db:"login_type" json:"login_type"`
AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"`
Deleted bool `db:"deleted" json:"deleted"`
LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"`
Count int64 `db:"count" json:"count"`
ID uuid.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
HashedPassword []byte `db:"hashed_password" json:"hashed_password"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Status UserStatus `db:"status" json:"status"`
RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"`
LoginType LoginType `db:"login_type" json:"login_type"`
AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"`
Deleted bool `db:"deleted" json:"deleted"`
LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"`
QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"`
Count int64 `db:"count" json:"count"`
}
// This will never return deleted users.
@@ -5227,6 +5245,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
&i.Count,
); err != nil {
return nil, err
@@ -5243,7 +5262,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse
}
const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at FROM users WHERE id = ANY($1 :: uuid [ ])
SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule FROM users WHERE id = ANY($1 :: uuid [ ])
`
// This shouldn't check for deleted, because it's frequently used
@@ -5271,6 +5290,7 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
); err != nil {
return nil, err
}
@@ -5298,7 +5318,7 @@ INSERT INTO
login_type
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule
`
type InsertUserParams struct {
@@ -5337,6 +5357,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
)
return i, err
}
@@ -5386,7 +5407,7 @@ SET
last_seen_at = $2,
updated_at = $3
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule
`
type UpdateUserLastSeenAtParams struct {
@@ -5411,6 +5432,7 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
)
return i, err
}
@@ -5428,7 +5450,7 @@ SET
'':: bytea
END
WHERE
id = $2 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
id = $2 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule
`
type UpdateUserLoginTypeParams struct {
@@ -5452,6 +5474,7 @@ func (q *sqlQuerier) UpdateUserLoginType(ctx context.Context, arg UpdateUserLogi
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
)
return i, err
}
@@ -5465,7 +5488,7 @@ SET
avatar_url = $4,
updated_at = $5
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule
`
type UpdateUserProfileParams struct {
@@ -5498,6 +5521,43 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
)
return i, err
}
const updateUserQuietHoursSchedule = `-- name: UpdateUserQuietHoursSchedule :one
UPDATE
users
SET
quiet_hours_schedule = $2
WHERE
id = $1
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule
`
type UpdateUserQuietHoursScheduleParams struct {
ID uuid.UUID `db:"id" json:"id"`
QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"`
}
func (q *sqlQuerier) UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) {
row := q.db.QueryRowContext(ctx, updateUserQuietHoursSchedule, arg.ID, arg.QuietHoursSchedule)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
&i.RBACRoles,
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
)
return i, err
}
@@ -5510,7 +5570,7 @@ SET
rbac_roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[]))
WHERE
id = $2
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule
`
type UpdateUserRolesParams struct {
@@ -5534,6 +5594,7 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
)
return i, err
}
@@ -5545,7 +5606,7 @@ SET
status = $2,
updated_at = $3
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule
`
type UpdateUserStatusParams struct {
@@ -5570,6 +5631,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
)
return i, err
}
@@ -8844,28 +8906,3 @@ 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
}
+5 -3
View File
@@ -118,9 +118,11 @@ SET
allow_user_autostop = $4,
default_ttl = $5,
max_ttl = $6,
failure_ttl = $7,
inactivity_ttl = $8,
locked_ttl = $9
restart_requirement_days_of_week = $7,
restart_requirement_weeks = $8,
failure_ttl = $9,
inactivity_ttl = $10,
locked_ttl = $11
WHERE
id = $1
;
+9
View File
@@ -241,3 +241,12 @@ FROM
users
WHERE
id = @user_id;
-- name: UpdateUserQuietHoursSchedule :one
UPDATE
users
SET
quiet_hours_schedule = $2
WHERE
id = $1
RETURNING *;
-14
View File
@@ -347,20 +347,6 @@ SET
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;
-- name: GetDeploymentWorkspaceStats :one
WITH workspaces_with_jobs AS (
SELECT
+40 -50
View File
@@ -26,7 +26,6 @@ import (
protobuf "google.golang.org/protobuf/proto"
"cdr.dev/slog"
"github.com/coder/coder/coderd/apikey"
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
@@ -50,23 +49,35 @@ var (
)
type Server struct {
AccessURL *url.URL
ID uuid.UUID
Logger slog.Logger
Provisioners []database.ProvisionerType
GitAuthConfigs []*gitauth.Config
Tags json.RawMessage
Database database.Store
Pubsub pubsub.Pubsub
Telemetry telemetry.Reporter
Tracer trace.Tracer
QuotaCommitter *atomic.Pointer[proto.QuotaCommitter]
Auditor *atomic.Pointer[audit.Auditor]
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
DeploymentValues *codersdk.DeploymentValues
AccessURL *url.URL
ID uuid.UUID
Logger slog.Logger
Provisioners []database.ProvisionerType
GitAuthConfigs []*gitauth.Config
Tags json.RawMessage
Database database.Store
Pubsub pubsub.Pubsub
Telemetry telemetry.Reporter
Tracer trace.Tracer
QuotaCommitter *atomic.Pointer[proto.QuotaCommitter]
Auditor *atomic.Pointer[audit.Auditor]
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
DeploymentValues *codersdk.DeploymentValues
AcquireJobDebounce time.Duration
OIDCConfig httpmw.OAuth2Config
TimeNowFn func() time.Time
}
// timeNow should be used when trying to get the current time for math
// calculations regarding workspace start and stop time.
func (server *Server) timeNow() time.Time {
if server.TimeNowFn != nil {
return database.Time(server.TimeNowFn())
}
return database.Now()
}
// AcquireJob queries the database to lock a job.
@@ -101,7 +112,7 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac
// The provisioner daemon assumes no jobs are available if
// an empty struct is returned.
lastAcquireMutex.Lock()
lastAcquire = time.Now()
lastAcquire = database.Now()
lastAcquireMutex.Unlock()
return &proto.AcquiredJob{}, nil
}
@@ -894,15 +905,9 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete
var getWorkspaceError error
err = server.Database.InTx(func(db database.Store) error {
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
)
// It's important we use server.timeNow() here because we want to be
// able to customize the current time from within tests.
now := server.timeNow()
workspace, getWorkspaceError = db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
if getWorkspaceError != nil {
@@ -913,31 +918,16 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete
)
return getWorkspaceError
}
if workspace.Ttl.Valid {
deadline = now.Add(time.Duration(workspace.Ttl.Int64))
}
templateSchedule, err := (*server.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, db, workspace.TemplateID)
autoStop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{
Database: db,
TemplateScheduleStore: *server.TemplateScheduleStore.Load(),
UserQuietHoursScheduleStore: *server.UserQuietHoursScheduleStore.Load(),
Now: now,
Workspace: workspace,
})
if err != nil {
return xerrors.Errorf("get template schedule options: %w", err)
}
if !templateSchedule.UserAutostopEnabled {
// The user is not permitted to set their own TTL, so use the
// template default.
deadline = time.Time{}
if templateSchedule.DefaultTTL > 0 {
deadline = now.Add(templateSchedule.DefaultTTL)
}
}
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
}
return xerrors.Errorf("calculate auto stop: %w", err)
}
err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{
@@ -953,8 +943,8 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete
}
_, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
ID: workspaceBuild.ID,
Deadline: deadline,
MaxDeadline: maxDeadline,
Deadline: autoStop.Deadline,
MaxDeadline: autoStop.MaxDeadline,
ProvisionerState: jobType.WorkspaceBuild.State,
UpdatedAt: now,
})
@@ -11,6 +11,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace"
"golang.org/x/oauth2"
@@ -47,6 +48,13 @@ func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore]
return ptr
}
func testUserQuietHoursScheduleStore() *atomic.Pointer[schedule.UserQuietHoursScheduleStore] {
ptr := &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{}
store := schedule.NewAGPLUserQuietHoursScheduleStore()
ptr.Store(&store)
return ptr
}
func TestAcquireJob(t *testing.T) {
t.Parallel()
t.Run("Debounce", func(t *testing.T) {
@@ -54,18 +62,19 @@ func TestAcquireJob(t *testing.T) {
db := dbfake.New()
ps := pubsub.NewInMemory()
srv := &provisionerdserver.Server{
ID: uuid.New(),
Logger: slogtest.Make(t, nil),
AccessURL: &url.URL{},
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
Database: db,
Pubsub: ps,
Telemetry: telemetry.NewNoop(),
AcquireJobDebounce: time.Hour,
Auditor: mockAuditor(),
TemplateScheduleStore: testTemplateScheduleStore(),
Tracer: trace.NewNoopTracerProvider().Tracer("noop"),
DeploymentValues: &codersdk.DeploymentValues{},
ID: uuid.New(),
Logger: slogtest.Make(t, nil),
AccessURL: &url.URL{},
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
Database: db,
Pubsub: ps,
Telemetry: telemetry.NewNoop(),
AcquireJobDebounce: time.Hour,
Auditor: mockAuditor(),
TemplateScheduleStore: testTemplateScheduleStore(),
UserQuietHoursScheduleStore: testUserQuietHoursScheduleStore(),
Tracer: trace.NewNoopTracerProvider().Tracer("noop"),
DeploymentValues: &codersdk.DeploymentValues{},
}
job, err := srv.AcquireJob(context.Background(), nil)
require.NoError(t, err)
@@ -892,7 +901,8 @@ func TestCompleteJob(t *testing.T) {
require.False(t, job.Error.Valid)
})
t.Run("WorkspaceBuild", func(t *testing.T) {
// TODO(@dean): remove this legacy test for MaxTTL
t.Run("WorkspaceBuildLegacy", func(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -1011,10 +1021,11 @@ func TestCompleteJob(t *testing.T) {
var store schedule.TemplateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
UserAutostopEnabled: c.templateAllowAutostop,
DefaultTTL: c.templateDefaultTTL,
MaxTTL: c.templateMaxTTL,
UserAutostartEnabled: false,
UserAutostopEnabled: c.templateAllowAutostop,
DefaultTTL: c.templateDefaultTTL,
MaxTTL: c.templateMaxTTL,
UseRestartRequirement: false,
}, nil
},
}
@@ -1125,6 +1136,254 @@ func TestCompleteJob(t *testing.T) {
})
}
})
t.Run("WorkspaceBuild", func(t *testing.T) {
t.Parallel()
now := time.Now()
// NOTE: if you're looking for more in-depth deadline/max_deadline
// calculation testing, see the schedule package. The provsiionerdserver
// package calls `schedule.CalculateAutostop()` to generate the deadline
// and max_deadline.
// Wednesday the 8th of February 2023 at midnight. This date was
// specifically chosen as it doesn't fall on a applicable week for both
// fortnightly and triweekly restart requirements.
wednesdayMidnightUTC := time.Date(2023, 2, 8, 0, 0, 0, 0, time.UTC)
sydneyQuietHours := "CRON_TZ=Australia/Sydney 0 0 * * *"
sydneyLoc, err := time.LoadLocation("Australia/Sydney")
require.NoError(t, err)
// 12am on Saturday the 11th of February 2023 in Sydney.
saturdayMidnightSydney := time.Date(2023, 2, 11, 0, 0, 0, 0, sydneyLoc)
t.Log("now", now)
t.Log("wednesdayMidnightUTC", wednesdayMidnightUTC)
t.Log("saturdayMidnightSydney", saturdayMidnightSydney)
cases := []struct {
name string
now time.Time
workspaceTTL time.Duration
transition database.WorkspaceTransition
// These fields are only used when testing max deadline.
userQuietHoursSchedule string
templateRestartRequirement schedule.TemplateRestartRequirement
expectedDeadline time.Time
expectedMaxDeadline time.Time
}{
{
name: "OK",
now: now,
templateRestartRequirement: schedule.TemplateRestartRequirement{},
workspaceTTL: 0,
transition: database.WorkspaceTransitionStart,
expectedDeadline: time.Time{},
expectedMaxDeadline: time.Time{},
},
{
name: "Delete",
now: now,
templateRestartRequirement: schedule.TemplateRestartRequirement{},
workspaceTTL: 0,
transition: database.WorkspaceTransitionDelete,
expectedDeadline: time.Time{},
expectedMaxDeadline: time.Time{},
},
{
name: "WorkspaceTTL",
now: now,
templateRestartRequirement: schedule.TemplateRestartRequirement{},
workspaceTTL: time.Hour,
transition: database.WorkspaceTransitionStart,
expectedDeadline: now.Add(time.Hour),
expectedMaxDeadline: time.Time{},
},
{
name: "TemplateRestartRequirement",
now: wednesdayMidnightUTC,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 0, // weekly
},
workspaceTTL: 0,
transition: database.WorkspaceTransitionStart,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
srv := setup(t, false)
// Simulate the given time starting from now.
require.False(t, c.now.IsZero())
start := time.Now()
srv.TimeNowFn = func() time.Time {
return c.now.Add(time.Since(start))
}
var templateScheduleStore schedule.TemplateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
UserAutostopEnabled: true,
DefaultTTL: 0,
UseRestartRequirement: true,
RestartRequirement: c.templateRestartRequirement,
}, nil
},
}
srv.TemplateScheduleStore.Store(&templateScheduleStore)
var userQuietHoursScheduleStore schedule.UserQuietHoursScheduleStore = schedule.MockUserQuietHoursScheduleStore{
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.UserQuietHoursScheduleOptions, error) {
if c.userQuietHoursSchedule == "" {
return schedule.UserQuietHoursScheduleOptions{
Schedule: nil,
}, nil
}
sched, err := schedule.Daily(c.userQuietHoursSchedule)
if !assert.NoError(t, err) {
return schedule.UserQuietHoursScheduleOptions{}, err
}
return schedule.UserQuietHoursScheduleOptions{
Schedule: sched,
UserSet: false,
}, nil
},
}
srv.UserQuietHoursScheduleStore.Store(&userQuietHoursScheduleStore)
user := dbgen.User(t, srv.Database, database.User{
QuietHoursSchedule: c.userQuietHoursSchedule,
})
template := dbgen.Template(t, srv.Database, database.Template{
Name: "template",
Provisioner: database.ProvisionerTypeEcho,
})
err := srv.Database.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: template.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: false,
AllowUserAutostop: true,
DefaultTTL: 0,
RestartRequirementDaysOfWeek: int16(c.templateRestartRequirement.DaysOfWeek),
RestartRequirementWeeks: c.templateRestartRequirement.Weeks,
})
require.NoError(t, err)
template, err = srv.Database.GetTemplateByID(ctx, template.ID)
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 := dbgen.Workspace(t, srv.Database, database.Workspace{
TemplateID: template.ID,
Ttl: workspaceTTL,
OwnerID: user.ID,
})
version := dbgen.TemplateVersion(t, srv.Database, database.TemplateVersion{
TemplateID: uuid.NullUUID{
UUID: template.ID,
Valid: true,
},
JobID: uuid.New(),
})
build := dbgen.WorkspaceBuild(t, srv.Database, database.WorkspaceBuild{
WorkspaceID: workspace.ID,
TemplateVersionID: version.ID,
Transition: c.transition,
Reason: database.BuildReasonInitiator,
})
job := dbgen.ProvisionerJob(t, srv.Database, database.ProvisionerJob{
FileID: file.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{
WorkspaceBuildID: build.ID,
})),
})
_, 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(provisionersdk.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 the max deadline is set, the deadline should also be set.
// Default to the max deadline if the deadline is not set.
if c.expectedDeadline.IsZero() {
c.expectedDeadline = c.expectedMaxDeadline
}
if c.expectedDeadline.IsZero() {
require.True(t, workspaceBuild.Deadline.IsZero())
} else {
require.WithinDuration(t, c.expectedDeadline, workspaceBuild.Deadline, 15*time.Second, "deadline does not match expected")
}
if c.expectedMaxDeadline.IsZero() {
require.True(t, workspaceBuild.MaxDeadline.IsZero())
} else {
require.WithinDuration(t, c.expectedMaxDeadline, 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) {
t.Parallel()
srv := setup(t, false)
@@ -1260,18 +1519,23 @@ func setup(t *testing.T, ignoreLogErrors bool) *provisionerdserver.Server {
ps := pubsub.NewInMemory()
return &provisionerdserver.Server{
ID: uuid.New(),
Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}),
OIDCConfig: &oauth2.Config{},
AccessURL: &url.URL{},
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
Database: db,
Pubsub: ps,
Telemetry: telemetry.NewNoop(),
Auditor: mockAuditor(),
TemplateScheduleStore: testTemplateScheduleStore(),
Tracer: trace.NewNoopTracerProvider().Tracer("noop"),
DeploymentValues: &codersdk.DeploymentValues{},
ID: uuid.New(),
Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}),
OIDCConfig: &oauth2.Config{},
AccessURL: &url.URL{},
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
Database: db,
Pubsub: ps,
Telemetry: telemetry.NewNoop(),
Auditor: mockAuditor(),
TemplateScheduleStore: testTemplateScheduleStore(),
UserQuietHoursScheduleStore: testUserQuietHoursScheduleStore(),
Tracer: trace.NewNoopTracerProvider().Tracer("noop"),
DeploymentValues: &codersdk.DeploymentValues{},
// Negative values cause the debounce to never kick in. Tests that want
// to test debounce can override this value.
AcquireJobDebounce: -time.Minute,
}
}
+332
View File
@@ -0,0 +1,332 @@
package schedule
import (
"context"
"time"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
)
const (
// restartRequirementLeeway is the duration of time before a restart
// requirement where we skip the requirement and fall back to the next
// scheduled restart. This avoids workspaces being restarted too soon.
//
// E.g. If the workspace is started within an hour of the quiet hours, we
// will skip the restart requirement and use the next scheduled restart
// requirement.
restartRequirementLeeway = 1 * time.Hour
// restartRequirementBuffer is the duration of time we subtract from the
// time when calculating the next scheduled restart time. This avoids issues
// where autostart happens on the hour and the scheduled quiet hours are
// also on the hour.
//
// E.g. If the workspace is started at 12am (perhaps due to scheduled
// autostart) and the quiet hours is also 12am, the workspace will skip
// the day it's supposed to stop and use the next day instead. This is
// because getting the next cron schedule time will never include the
// time fed to the calculation (i.e. it's not inclusive). This happens
// because we always check for the next cron time by rounding down to
// midnight.
//
// This resolves that problem by subtracting 15 minutes from midnight
// when we check the next cron time.
restartRequirementBuffer = -15 * time.Minute
)
type CalculateAutostopParams struct {
Database database.Store
TemplateScheduleStore TemplateScheduleStore
UserQuietHoursScheduleStore UserQuietHoursScheduleStore
Now time.Time
Workspace database.Workspace
}
type AutostopTime struct {
// 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
}
// CalculateAutostop calculates the deadline and max_deadline for a workspace
// build.
//
// Deadline is the time when the workspace will be stopped, as long as it
// doesn't see any new activity (such as SSH, app requests, etc.). When activity
// is detected the deadline is bumped by the workspace's TTL (this only happens
// when activity is detected and more than 20% of the TTL has passed to save
// database queries).
//
// MaxDeadline is the maximum value for deadline. The deadline cannot be bumped
// past this value, so it denotes the absolute deadline that the workspace build
// must be stopped by. MaxDeadline is calculated using the template's "restart
// requirement" settings and the user's "quiet hours" settings to pick a time
// outside of working hours.
//
// Deadline is a cost saving measure, while max deadline is a
// compliance/updating measure.
func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (AutostopTime, error) {
var (
db = params.Database
workspace = params.Workspace
now = params.Now
autostop AutostopTime
)
if workspace.Ttl.Valid {
// When the workspace is made it copies the template's TTL, and the user
// can unset it to disable it (unless the template has
// UserAutoStopEnabled set to false, see below).
autostop.Deadline = now.Add(time.Duration(workspace.Ttl.Int64))
}
templateSchedule, err := params.TemplateScheduleStore.Get(ctx, db, workspace.TemplateID)
if err != nil {
return autostop, xerrors.Errorf("get template schedule options: %w", err)
}
if !templateSchedule.UserAutostopEnabled {
// The user is not permitted to set their own TTL, so use the template
// default.
autostop.Deadline = time.Time{}
if templateSchedule.DefaultTTL > 0 {
autostop.Deadline = now.Add(templateSchedule.DefaultTTL)
}
}
// Use the old algorithm for calculating max_deadline if the instance isn't
// configured or entitled to use the new feature flag yet.
// TODO(@dean): remove this once the feature flag is enabled for all
if !templateSchedule.UseRestartRequirement && templateSchedule.MaxTTL > 0 {
autostop.MaxDeadline = now.Add(templateSchedule.MaxTTL)
}
// TODO(@dean): remove extra conditional
if templateSchedule.UseRestartRequirement && templateSchedule.RestartRequirement.DaysOfWeek != 0 {
// The template has a restart requirement, so determine the max deadline
// of this workspace build.
// First, get the user's quiet hours schedule (this will return the
// default if the user has not set their own schedule).
userQuietHoursSchedule, err := params.UserQuietHoursScheduleStore.Get(ctx, db, workspace.OwnerID)
if err != nil {
return autostop, xerrors.Errorf("get user quiet hours schedule options: %w", err)
}
// If the schedule is nil, that means the deployment isn't entitled to
// use quiet hours or the default schedule has not been set. In this
// case, do not set a max deadline on the workspace.
if userQuietHoursSchedule.Schedule != nil {
loc := userQuietHoursSchedule.Schedule.Location()
now := now.In(loc)
// Add the leeway here so we avoid checking today's quiet hours if
// the workspace was started <1h before midnight.
startOfStopDay := truncateMidnight(now.Add(restartRequirementLeeway))
// If the template schedule wants to only restart on n-th weeks then
// change the startOfDay to be the Monday of the next applicable
// week.
if templateSchedule.RestartRequirement.Weeks > 1 {
startOfStopDay, err = GetNextApplicableMondayOfNWeeks(startOfStopDay, templateSchedule.RestartRequirement.Weeks)
if err != nil {
return autostop, xerrors.Errorf("determine start of stop week: %w", err)
}
}
// Determine if we should skip the first day because the schedule is
// too near or has already passed.
//
// Allow an hour of leeway (i.e. any workspaces started within an
// hour of the scheduled stop time will always bounce to the next
// stop window).
checkSchedule := userQuietHoursSchedule.Schedule.Next(startOfStopDay.Add(restartRequirementBuffer))
if checkSchedule.Before(now.Add(restartRequirementLeeway)) {
// Set the first stop day we try to tomorrow because today's
// schedule is too close to now or has already passed.
startOfStopDay = nextDayMidnight(startOfStopDay)
}
// Iterate from 0 to 7, check if the current startOfDay is in the
// restart requirement. If it isn't then add a day and try again.
requirementDays := templateSchedule.RestartRequirement.DaysMap()
for i := 0; i < len(DaysOfWeek)+1; i++ {
if i == len(DaysOfWeek) {
// We've wrapped, so somehow we couldn't find a day in the
// restart requirement in the next week.
//
// This shouldn't be able to happen, as we've already
// checked that there is a day in the restart requirement
// above with the
// `if templateSchedule.RestartRequirement.DaysOfWeek != 0`
// check.
//
// The eighth bit shouldn't be set, as we validate the
// bitmap in the enterprise TemplateScheduleStore.
return autostop, xerrors.New("could not find suitable day for template restart requirement in the next 7 days")
}
if requirementDays[startOfStopDay.Weekday()] {
break
}
startOfStopDay = nextDayMidnight(startOfStopDay)
}
// If the startOfDay is within an hour of now, then we add an hour.
checkTime := startOfStopDay
if checkTime.Before(now.Add(time.Hour)) {
checkTime = now.Add(time.Hour)
} else {
// If it's not within an hour of now, subtract 15 minutes to
// give a little leeway. This prevents skipped stop events
// because autostart perfectly lines up with autostop.
checkTime = checkTime.Add(restartRequirementBuffer)
}
// Get the next occurrence of the restart schedule.
autostop.MaxDeadline = userQuietHoursSchedule.Schedule.Next(checkTime)
if autostop.MaxDeadline.IsZero() {
return autostop, xerrors.New("could not find next occurrence of template restart requirement in user quiet hours schedule")
}
}
}
// 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.
if !autostop.MaxDeadline.IsZero() && (autostop.Deadline.IsZero() || autostop.MaxDeadline.Before(autostop.Deadline)) {
autostop.Deadline = autostop.MaxDeadline
}
if (!autostop.Deadline.IsZero() && autostop.Deadline.Before(now)) || (!autostop.MaxDeadline.IsZero() && autostop.MaxDeadline.Before(now)) {
// Something went wrong with the deadline calculation, so we should
// bail.
return autostop, xerrors.Errorf("deadline calculation error, computed deadline or max deadline is in the past for workspace build: deadline=%q maxDeadline=%q now=%q", autostop.Deadline, autostop.MaxDeadline, now)
}
return autostop, nil
}
// truncateMidnight truncates a time to midnight in the time object's timezone.
// t.Truncate(24 * time.Hour) truncates based on the internal time and doesn't
// factor daylight savings properly.
//
// See: https://github.com/golang/go/issues/10894
func truncateMidnight(t time.Time) time.Time {
yy, mm, dd := t.Date()
return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location())
}
// nextDayMidnight returns the next midnight in the time object's timezone.
func nextDayMidnight(t time.Time) time.Time {
yy, mm, dd := t.Date()
// time.Date will correctly normalize the date if it's past the end of the
// month. E.g. October 32nd will be November 1st.
dd++
return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location())
}
// WeeksSinceEpoch gets the weeks since the epoch for a given time. This is a
// 0-indexed number of weeks since the epoch (Monday).
//
// The timezone embedded in the time object is used to determine the epoch.
func WeeksSinceEpoch(now time.Time) (int64, error) {
epoch := TemplateRestartRequirementEpoch(now.Location())
if now.Before(epoch) {
return 0, xerrors.New("coder server system clock is incorrect, cannot calculate template restart requirement")
}
// This calculation needs to be done using YearDay, as dividing by the
// amount of hours is impacted by daylight savings. Even though daylight
// savings is usually only an hour difference, this calculation is used to
// get the current week number and could result in an entire week getting
// skipped if the calculation is off by an hour.
//
// Old naive algorithm: weeksSinceEpoch := int64(since.Hours() / (24 * 7))
// Get days since epoch. Start with a negative number of days, as we want to
// subtract the YearDay() of the epoch itself.
days := -epoch.YearDay()
for i := epoch.Year(); i < now.Year(); i++ {
startOfNextYear := time.Date(i+1, 1, 1, 0, 0, 0, 0, now.Location())
if startOfNextYear.Year() != i+1 {
return 0, xerrors.New("overflow calculating weeks since epoch")
}
endOfThisYear := startOfNextYear.AddDate(0, 0, -1)
if endOfThisYear.Year() != i {
return 0, xerrors.New("overflow calculating weeks since epoch")
}
days += endOfThisYear.YearDay()
}
// Add this year's days.
days += now.YearDay()
// Ensure that the number of days is positive.
if days < 0 {
return 0, xerrors.New("overflow calculating weeks since epoch")
}
// Divide by 7 to get the number of weeks.
weeksSinceEpoch := int64(days / 7)
return weeksSinceEpoch, nil
}
// GetMondayOfWeek gets the Monday (0:00) of the n-th week since epoch.
func GetMondayOfWeek(loc *time.Location, n int64) (time.Time, error) {
if n < 0 {
return time.Time{}, xerrors.New("weeks since epoch must be positive")
}
epoch := TemplateRestartRequirementEpoch(loc)
monday := epoch.AddDate(0, 0, int(n*7))
y, m, d := monday.Date()
monday = time.Date(y, m, d, 0, 0, 0, 0, loc)
if monday.Weekday() != time.Monday {
// This condition should never be hit, but we have a check for it just
// in case.
return time.Time{}, xerrors.Errorf("calculated incorrect Monday for week %v since epoch (actual weekday %q)", n, monday.Weekday())
}
return monday, nil
}
// GetNextApplicableMondayOfNWeeks gets the next Monday (0:00) of the next week
// divisible by n since epoch. If the next applicable week is invalid for any
// reason, the week after will be used instead (up to 2 attempts).
//
// If the current week is divisible by n, then the provided time is returned as
// is.
//
// The timezone embedded in the time object is used to determine the epoch.
func GetNextApplicableMondayOfNWeeks(now time.Time, n int64) (time.Time, error) {
// Get the current week number.
weeksSinceEpoch, err := WeeksSinceEpoch(now)
if err != nil {
return time.Time{}, xerrors.Errorf("get current week number: %w", err)
}
// Get the next week divisible by n.
remainder := weeksSinceEpoch % n
week := weeksSinceEpoch + (n - remainder)
if remainder == 0 {
return now, nil
}
// Loop until we find a week that doesn't fail. This should never loop, but
// we account for failures just in case.
var lastErr error
for i := int64(0); i < 3; i++ {
monday, err := GetMondayOfWeek(now.Location(), week+i)
if err != nil {
lastErr = err
continue
}
return monday, nil
}
return time.Time{}, xerrors.Errorf("get next applicable Monday of %v weeks: %w", n, lastErr)
}
+594
View File
@@ -0,0 +1,594 @@
package schedule_test
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbgen"
"github.com/coder/coder/coderd/database/dbtestutil"
"github.com/coder/coder/coderd/schedule"
"github.com/coder/coder/testutil"
)
func TestCalculateAutoStop(t *testing.T) {
t.Parallel()
now := time.Now()
// Wednesday the 8th of February 2023 at midnight. This date was
// specifically chosen as it doesn't fall on a applicable week for both
// fortnightly and triweekly restart requirements.
wednesdayMidnightUTC := time.Date(2023, 2, 8, 0, 0, 0, 0, time.UTC)
sydneyQuietHours := "CRON_TZ=Australia/Sydney 0 0 * * *"
sydneyLoc, err := time.LoadLocation("Australia/Sydney")
require.NoError(t, err)
// 10pm on Friday the 10th of February 2023 in Sydney.
fridayEveningSydney := time.Date(2023, 2, 10, 22, 0, 0, 0, sydneyLoc)
// 12am on Saturday the 11th of February2023 in Sydney.
saturdayMidnightSydney := time.Date(2023, 2, 11, 0, 0, 0, 0, sydneyLoc)
t.Log("now", now)
t.Log("wednesdayMidnightUTC", wednesdayMidnightUTC)
t.Log("fridayEveningSydney", fridayEveningSydney)
t.Log("saturdayMidnightSydney", saturdayMidnightSydney)
dstIn := time.Date(2023, 10, 1, 2, 0, 0, 0, sydneyLoc) // 1 hour backward
dstInQuietHours := "CRON_TZ=Australia/Sydney 30 2 * * *" // never
// The expected behavior is that we will pick the next time that falls on
// quiet hours after the DST transition. In this case, it will be the same
// time the next day.
dstInQuietHoursExpectedTime := time.Date(2023, 10, 2, 2, 30, 0, 0, sydneyLoc)
beforeDstIn := time.Date(2023, 10, 1, 0, 0, 0, 0, sydneyLoc)
saturdayMidnightAfterDstIn := time.Date(2023, 10, 7, 0, 0, 0, 0, sydneyLoc)
// Wednesday after DST starts.
duringDst := time.Date(2023, 10, 4, 0, 0, 0, 0, sydneyLoc)
saturdayMidnightAfterDuringDst := saturdayMidnightAfterDstIn
dstOut := time.Date(2024, 4, 7, 3, 0, 0, 0, sydneyLoc) // 1 hour forward
dstOutQuietHours := "CRON_TZ=Australia/Sydney 30 3 * * *" // twice
dstOutQuietHoursExpectedTime := time.Date(2024, 4, 7, 3, 30, 0, 0, sydneyLoc) // in reality, this is the first occurrence
beforeDstOut := time.Date(2024, 4, 7, 0, 0, 0, 0, sydneyLoc)
saturdayMidnightAfterDstOut := time.Date(2024, 4, 13, 0, 0, 0, 0, sydneyLoc)
t.Log("dstIn", dstIn)
t.Log("beforeDstIn", beforeDstIn)
t.Log("saturdayMidnightAfterDstIn", saturdayMidnightAfterDstIn)
t.Log("dstOut", dstOut)
t.Log("beforeDstOut", beforeDstOut)
t.Log("saturdayMidnightAfterDstOut", saturdayMidnightAfterDstOut)
cases := []struct {
name string
now time.Time
templateAllowAutostop bool
templateDefaultTTL time.Duration
// TODO(@dean): remove max_ttl tests
useMaxTTL bool
templateMaxTTL time.Duration
templateRestartRequirement schedule.TemplateRestartRequirement
userQuietHoursSchedule string
// workspaceTTL is usually copied from the template's TTL when the
// workspace is made, so it takes precedence unless
// templateAllowAutostop is false.
workspaceTTL time.Duration
// expectedDeadline is copied from expectedMaxDeadline if unset.
expectedDeadline time.Time
expectedMaxDeadline time.Time
errContains string
}{
{
name: "OK",
now: now,
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateRestartRequirement: schedule.TemplateRestartRequirement{},
workspaceTTL: 0,
expectedDeadline: time.Time{},
expectedMaxDeadline: time.Time{},
},
{
name: "Delete",
now: now,
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateRestartRequirement: schedule.TemplateRestartRequirement{},
workspaceTTL: 0,
expectedDeadline: time.Time{},
expectedMaxDeadline: time.Time{},
},
{
name: "WorkspaceTTL",
now: now,
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateRestartRequirement: schedule.TemplateRestartRequirement{},
workspaceTTL: time.Hour,
expectedDeadline: now.Add(time.Hour),
expectedMaxDeadline: time.Time{},
},
{
name: "TemplateDefaultTTLIgnored",
now: now,
templateAllowAutostop: true,
templateDefaultTTL: time.Hour,
templateRestartRequirement: schedule.TemplateRestartRequirement{},
workspaceTTL: 0,
expectedDeadline: time.Time{},
expectedMaxDeadline: time.Time{},
},
{
name: "WorkspaceTTLOverridesTemplateDefaultTTL",
now: now,
templateAllowAutostop: true,
templateDefaultTTL: 2 * time.Hour,
templateRestartRequirement: schedule.TemplateRestartRequirement{},
workspaceTTL: time.Hour,
expectedDeadline: now.Add(time.Hour),
expectedMaxDeadline: time.Time{},
},
{
name: "TemplateBlockWorkspaceTTL",
now: now,
templateAllowAutostop: false,
templateDefaultTTL: 3 * time.Hour,
templateRestartRequirement: schedule.TemplateRestartRequirement{},
workspaceTTL: 4 * time.Hour,
expectedDeadline: now.Add(3 * time.Hour),
expectedMaxDeadline: time.Time{},
},
{
name: "TemplateRestartRequirement",
now: wednesdayMidnightUTC,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 0, // weekly
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
},
{
name: "TemplateRestartRequirement1HourSkip",
now: saturdayMidnightSydney.Add(-59 * time.Minute),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 1, // 1 also means weekly
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightSydney.Add(7 * 24 * time.Hour).In(time.UTC),
},
{
// The next restart requirement should be skipped if the
// workspace is started within 1 hour of it.
name: "TemplateRestartRequirementDaily",
now: fridayEveningSydney,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b01111111, // daily
Weeks: 0, // all weeks
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
},
{
name: "TemplateRestartRequirementFortnightly/Skip",
now: wednesdayMidnightUTC,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 2, // every 2 weeks
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
},
{
name: "TemplateRestartRequirementFortnightly/NoSkip",
now: wednesdayMidnightUTC.AddDate(0, 0, 7),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 2, // every 2 weeks
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
},
{
name: "TemplateRestartRequirementTriweekly/Skip",
now: wednesdayMidnightUTC,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 3, // every 3 weeks
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
// The next triweekly restart requirement happens next week
// according to the epoch.
expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
},
{
name: "TemplateRestartRequirementTriweekly/NoSkip",
now: wednesdayMidnightUTC.AddDate(0, 0, 7),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 3, // every 3 weeks
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
},
{
name: "TemplateRestartRequirementOverridesWorkspaceTTL",
// now doesn't have to be UTC, but it helps us ensure that
// timezones are compared correctly in this test.
now: fridayEveningSydney.In(time.UTC),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 0, // weekly
},
workspaceTTL: 3 * time.Hour,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
},
{
name: "TemplateRestartRequirementOverridesTemplateDefaultTTL",
now: fridayEveningSydney.In(time.UTC),
templateAllowAutostop: true,
templateDefaultTTL: 3 * time.Hour,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 0, // weekly
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
},
{
name: "TimeBeforeEpoch",
// The epoch is 2023-01-02 in each timezone. We set the time to
// 1 second before 11pm the previous day, as this is the latest time
// we allow due to our 1h leeway logic.
now: time.Date(2023, 1, 1, 22, 59, 59, 0, sydneyLoc),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 2, // every fortnight
},
workspaceTTL: 0,
errContains: "coder server system clock is incorrect",
},
{
name: "DaylightSavings/OK",
now: duringDst,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 1, // weekly
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightAfterDuringDst,
},
{
name: "DaylightSavings/SwitchMidWeek/In",
now: beforeDstIn,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 1, // weekly
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightAfterDstIn,
},
{
name: "DaylightSavings/SwitchMidWeek/Out",
now: beforeDstOut,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 1, // weekly
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightAfterDstOut,
},
{
name: "DaylightSavings/QuietHoursFallsOnDstSwitch/In",
now: beforeDstIn.Add(-24 * time.Hour),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: dstInQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b01000000, // Sunday
Weeks: 1, // weekly
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: dstInQuietHoursExpectedTime,
},
{
name: "DaylightSavings/QuietHoursFallsOnDstSwitch/Out",
now: beforeDstOut.Add(-24 * time.Hour),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: dstOutQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b01000000, // Sunday
Weeks: 1, // weekly
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: dstOutQuietHoursExpectedTime,
},
// TODO(@dean): remove max_ttl tests
{
name: "RestartRequirementIgnoresMaxTTL",
now: fridayEveningSydney.In(time.UTC),
templateAllowAutostop: false,
templateDefaultTTL: 0,
useMaxTTL: false,
templateMaxTTL: time.Hour, // should be ignored
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 0, // weekly
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
},
{
name: "MaxTTLIgnoresRestartRequirement",
now: fridayEveningSydney.In(time.UTC),
templateAllowAutostop: false,
templateDefaultTTL: 0,
useMaxTTL: true,
templateMaxTTL: time.Hour, // should NOT be ignored
userQuietHoursSchedule: sydneyQuietHours,
templateRestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: 0b00100000, // Saturday
Weeks: 0, // weekly
},
workspaceTTL: 0,
// expectedDeadline is copied from expectedMaxDeadline.
expectedMaxDeadline: fridayEveningSydney.Add(time.Hour).In(time.UTC),
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
templateScheduleStore := schedule.MockTemplateScheduleStore{
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
UserAutostopEnabled: c.templateAllowAutostop,
DefaultTTL: c.templateDefaultTTL,
MaxTTL: c.templateMaxTTL,
UseRestartRequirement: !c.useMaxTTL,
RestartRequirement: c.templateRestartRequirement,
}, nil
},
}
userQuietHoursScheduleStore := schedule.MockUserQuietHoursScheduleStore{
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.UserQuietHoursScheduleOptions, error) {
if c.userQuietHoursSchedule == "" {
return schedule.UserQuietHoursScheduleOptions{
Schedule: nil,
}, nil
}
sched, err := schedule.Daily(c.userQuietHoursSchedule)
if !assert.NoError(t, err) {
return schedule.UserQuietHoursScheduleOptions{}, err
}
return schedule.UserQuietHoursScheduleOptions{
Schedule: sched,
UserSet: false,
}, nil
},
}
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{
QuietHoursSchedule: c.userQuietHoursSchedule,
})
template := dbgen.Template(t, db, database.Template{
Name: "template",
Provisioner: database.ProvisionerTypeEcho,
OrganizationID: org.ID,
CreatedBy: user.ID,
})
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: template.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: c.templateAllowAutostop,
RestartRequirementDaysOfWeek: int16(c.templateRestartRequirement.DaysOfWeek),
RestartRequirementWeeks: c.templateRestartRequirement.Weeks,
})
require.NoError(t, err)
template, err = db.GetTemplateByID(ctx, template.ID)
require.NoError(t, err)
workspaceTTL := sql.NullInt64{}
if c.workspaceTTL != 0 {
workspaceTTL = sql.NullInt64{
Int64: int64(c.workspaceTTL),
Valid: true,
}
}
workspace := dbgen.Workspace(t, db, database.Workspace{
TemplateID: template.ID,
OrganizationID: org.ID,
OwnerID: user.ID,
Ttl: workspaceTTL,
})
autostop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{
Database: db,
TemplateScheduleStore: templateScheduleStore,
UserQuietHoursScheduleStore: userQuietHoursScheduleStore,
Now: c.now,
Workspace: workspace,
})
if c.errContains != "" {
require.Error(t, err)
require.ErrorContains(t, err, c.errContains)
return
}
require.NoError(t, err)
// If the max deadline is set, the deadline should also be set.
// Default to the max deadline if the deadline is not set.
if c.expectedDeadline.IsZero() {
c.expectedDeadline = c.expectedMaxDeadline
}
if c.expectedDeadline.IsZero() {
require.True(t, autostop.Deadline.IsZero())
} else {
require.WithinDuration(t, c.expectedDeadline, autostop.Deadline, 15*time.Second, "deadline does not match expected")
}
if c.expectedMaxDeadline.IsZero() {
require.True(t, autostop.MaxDeadline.IsZero())
} else {
require.WithinDuration(t, c.expectedMaxDeadline, autostop.MaxDeadline, 15*time.Second, "max deadline does not match expected")
require.GreaterOrEqual(t, autostop.MaxDeadline.Unix(), autostop.Deadline.Unix(), "max deadline is smaller than deadline")
}
})
}
}
func TestFindWeek(t *testing.T) {
t.Parallel()
timezones := []string{
"UTC",
"America/Los_Angeles",
"America/New_York",
"Europe/Dublin",
"Europe/London",
"Europe/Paris",
"Asia/Kolkata", // India (UTC+5:30)
"Asia/Tokyo",
"Australia/Sydney",
"Australia/Brisbane",
}
for _, tz := range timezones {
tz := tz
t.Run("Loc/"+tz, func(t *testing.T) {
t.Parallel()
loc, err := time.LoadLocation(tz)
require.NoError(t, err)
now := time.Now().In(loc)
currentWeek, err := schedule.WeeksSinceEpoch(now)
require.NoError(t, err)
diffMonday := now.Weekday() - time.Monday
if now.Weekday() == time.Sunday {
// Sunday is 0, but Monday is the first day of the week in the
// code.
diffMonday = 6
}
currentWeekMondayExpected := now.AddDate(0, 0, -int(diffMonday))
require.Equal(t, time.Monday, currentWeekMondayExpected.Weekday())
y, m, d := currentWeekMondayExpected.Date()
// Change to midnight.
currentWeekMondayExpected = time.Date(y, m, d, 0, 0, 0, 0, loc)
currentWeekMonday, err := schedule.GetMondayOfWeek(now.Location(), currentWeek)
require.NoError(t, err)
require.Equal(t, time.Monday, currentWeekMonday.Weekday())
require.Equal(t, currentWeekMondayExpected, currentWeekMonday)
t.Log("now", now)
t.Log("currentWeek", currentWeek)
t.Log("currentMonday", currentWeekMonday)
// Loop through every single Monday and Sunday for the next 100
// years and make sure the week calculations are correct.
for i := int64(1); i < 52*100; i++ {
msg := fmt.Sprintf("week %d", i)
monday := currentWeekMonday.AddDate(0, 0, int(i*7))
y, m, d := monday.Date()
monday = time.Date(y, m, d, 0, 0, 0, 0, loc)
require.Equal(t, monday.Weekday(), time.Monday, msg)
t.Log(msg, "monday", monday)
week, err := schedule.WeeksSinceEpoch(monday)
require.NoError(t, err, msg)
require.Equal(t, currentWeek+i, week, msg)
gotMonday, err := schedule.GetMondayOfWeek(monday.Location(), week)
require.NoError(t, err, msg)
require.Equal(t, monday, gotMonday, msg)
// Check that we get the same week number for late Sunday.
sunday := time.Date(y, m, d+6, 23, 59, 59, 0, loc)
require.Equal(t, sunday.Weekday(), time.Sunday, msg)
t.Log(msg, "sunday", sunday)
week, err = schedule.WeeksSinceEpoch(sunday)
require.NoError(t, err, msg)
require.Equal(t, currentWeek+i, week, msg)
}
})
}
}
+47 -1
View File
@@ -42,6 +42,36 @@ func Weekly(raw string) (*Schedule, error) {
return nil, xerrors.Errorf("validate weekly schedule: %w", err)
}
return parse(raw)
}
// Daily parses a Schedule from spec scoped to a recurring daily event.
// Spec consists of the following space-delimited fields, in the following order:
// - timezone e.g. CRON_TZ=US/Central (optional)
// - minutes of hour e.g. 30 (required)
// - hour of day e.g. 9 (required)
// - day of month (must be *)
// - month (must be *)
// - day of week (must be *)
//
// Example Usage:
//
// local_sched, _ := schedule.Weekly("59 23 * * *")
// fmt.Println(sched.Next(time.Now().Format(time.RFC3339)))
// // Output: 2022-04-04T23:59:00Z
//
// us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 * * *")
// fmt.Println(sched.Next(time.Now()).Format(time.RFC3339))
// // Output: 2022-04-04T14:30:00Z
func Daily(raw string) (*Schedule, error) {
if err := validateDailySpec(raw); err != nil {
return nil, xerrors.Errorf("validate daily schedule: %w", err)
}
return parse(raw)
}
func parse(raw string) (*Schedule, error) {
// If schedule does not specify a timezone, default to UTC. Otherwise,
// the library will default to time.Local which we want to avoid.
if !strings.HasPrefix(raw, "CRON_TZ=") {
@@ -187,7 +217,23 @@ func validateWeeklySpec(spec string) error {
parts = parts[1:]
}
if parts[2] != "*" || parts[3] != "*" {
return xerrors.Errorf("expected month and dom to be *")
return xerrors.Errorf("expected day-of-month and month to be *")
}
return nil
}
// validateDailySpec ensures that the day-of-month, month and day-of-week
// options of spec are all set to *
func validateDailySpec(spec string) error {
parts := strings.Fields(spec)
if len(parts) < 5 {
return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix")
}
if len(parts) == 6 {
parts = parts[1:]
}
if parts[2] != "*" || parts[3] != "*" || parts[4] != "*" {
return xerrors.Errorf("expected day-of-month, month and day-of-week to be *")
}
return nil
}
+2 -2
View File
@@ -129,14 +129,14 @@ func Test_Weekly(t *testing.T) {
spec: "30 9 1 1 1-5",
at: time.Time{},
expectedNext: time.Time{},
expectedError: "validate weekly schedule: expected month and dom to be *",
expectedError: "validate weekly schedule: expected day-of-month and month to be *",
},
{
name: "valid schedule with 5 fields and timezone but month and dom not set to *",
spec: "CRON_TZ=Europe/Dublin 30 9 1 1 1-5",
at: time.Time{},
expectedNext: time.Time{},
expectedError: "validate weekly schedule: expected month and dom to be *",
expectedError: "validate weekly schedule: expected day-of-month and month to be *",
},
}
+27 -4
View File
@@ -15,18 +15,41 @@ type MockTemplateScheduleStore struct {
var _ TemplateScheduleStore = MockTemplateScheduleStore{}
func (m MockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
func (m MockTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
if m.GetFn != nil {
return m.GetFn(ctx, db, templateID)
}
return NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID)
return NewAGPLTemplateScheduleStore().Get(ctx, db, templateID)
}
func (m MockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error) {
func (m MockTemplateScheduleStore) Set(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error) {
if m.SetFn != nil {
return m.SetFn(ctx, db, template, options)
}
return NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options)
return NewAGPLTemplateScheduleStore().Set(ctx, db, template, options)
}
type MockUserQuietHoursScheduleStore struct {
GetFn func(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error)
SetFn func(ctx context.Context, db database.Store, userID uuid.UUID, schedule string) (UserQuietHoursScheduleOptions, error)
}
var _ UserQuietHoursScheduleStore = MockUserQuietHoursScheduleStore{}
func (m MockUserQuietHoursScheduleStore) Get(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) {
if m.GetFn != nil {
return m.GetFn(ctx, db, userID)
}
return NewAGPLUserQuietHoursScheduleStore().Get(ctx, db, userID)
}
func (m MockUserQuietHoursScheduleStore) Set(ctx context.Context, db database.Store, userID uuid.UUID, schedule string) (UserQuietHoursScheduleOptions, error) {
if m.SetFn != nil {
return m.SetFn(ctx, db, userID, schedule)
}
return NewAGPLUserQuietHoursScheduleStore().Set(ctx, db, userID, schedule)
}
+109 -24
View File
@@ -4,35 +4,113 @@ import (
"context"
"time"
"golang.org/x/xerrors"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
)
const MaxTemplateRestartRequirementWeeks = 16
func TemplateRestartRequirementEpoch(loc *time.Location) time.Time {
// The "first week" starts on January 2nd, 2023, which is the first Monday
// of 2023. All other weeks are counted using modulo arithmetic from that
// date.
return time.Date(2023, time.January, 2, 0, 0, 0, 0, loc)
}
// DaysOfWeek intentionally starts on Monday as opposed to Sunday so the weekend
// days are contiguous in the bitmap. This matters greatly when doing restarts
// every second week or more to avoid workspaces restarting "at the start" of
// the week rather than "at the end" of the week.
var DaysOfWeek = []time.Weekday{
time.Monday,
time.Tuesday,
time.Wednesday,
time.Thursday,
time.Friday,
time.Saturday,
time.Sunday,
}
type TemplateRestartRequirement struct {
// DaysOfWeek is a bitmap of which days of the week the workspace must be
// restarted. If fully zero, the workspace is not required to be restarted
// ever.
//
// First bit is Monday, ..., seventh bit is Sunday, eighth bit is unused.
DaysOfWeek uint8
// Weeks is the amount of weeks between restarts. If 0 or 1, the workspace
// is restarted weekly in accordance with DaysOfWeek. If 2, the workspace is
// restarted every other week. And so forth.
//
// The limit for this value is 16, which is roughly 4 months.
//
// The "first week" starts on January 2nd, 2023, which is the first Monday
// of 2023. All other weeks are counted using modulo arithmetic from that
// date.
Weeks int64
}
// DaysMap returns a map of the days of the week that the workspace must be
// restarted.
func (r TemplateRestartRequirement) DaysMap() map[time.Weekday]bool {
days := make(map[time.Weekday]bool)
for i, day := range DaysOfWeek {
days[day] = r.DaysOfWeek&(1<<uint(i)) != 0
}
return days
}
// VerifyTemplateRestartRequirement returns an error if the restart requirement
// is invalid.
func VerifyTemplateRestartRequirement(days uint8, weeks int64) error {
if days&0b10000000 != 0 {
return xerrors.New("invalid restart requirement days, last bit is set")
}
if days > 0b11111111 {
return xerrors.New("invalid restart requirement days, too large")
}
if weeks < 0 {
return xerrors.New("invalid restart requirement weeks, negative")
}
if weeks > MaxTemplateRestartRequirementWeeks {
return xerrors.New("invalid restart requirement weeks, too large")
}
return nil
}
type TemplateScheduleOptions struct {
UserAutostartEnabled bool `json:"user_autostart_enabled"`
UserAutostopEnabled bool `json:"user_autostop_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.
// TODO(@dean): remove MaxTTL once restart_requirement is matured and the
// default
MaxTTL time.Duration `json:"max_ttl"`
// FailureTTL dictates the duration after which failed workspaces will be stopped automatically.
// UseRestartRequirement dictates whether the restart requirement should be
// used instead of MaxTTL. This is governed by the feature flag and
// licensing.
// TODO(@dean): remove this when we remove max_tll
UseRestartRequirement bool
// RestartRequirement dictates when the workspace must be restarted. This
// used to be handled by MaxTTL.
RestartRequirement TemplateRestartRequirement `json:"restart_requirement"`
// FailureTTL dictates the duration after which failed workspaces will be
// stopped automatically.
FailureTTL time.Duration `json:"failure_ttl"`
// InactivityTTL dictates the duration after which inactive workspaces will be locked.
// InactivityTTL dictates the duration after which inactive workspaces will
// be locked.
InactivityTTL time.Duration `json:"inactivity_ttl"`
// LockedTTL dictates the duration after which locked workspaces will be permanently deleted.
// LockedTTL dictates the duration after which locked workspaces will be
// permanently deleted.
LockedTTL time.Duration `json:"locked_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)
Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error)
Set(ctx context.Context, db database.Store, template database.Template, opts TemplateScheduleOptions) (database.Template, error)
}
type agplTemplateScheduleStore struct{}
@@ -43,7 +121,7 @@ func NewAGPLTemplateScheduleStore() TemplateScheduleStore {
return &agplTemplateScheduleStore{}
}
func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
tpl, err := db.GetTemplateByID(ctx, templateID)
if err != nil {
return TemplateScheduleOptions{}, err
@@ -55,16 +133,21 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context
UserAutostartEnabled: true,
UserAutostopEnabled: true,
DefaultTTL: time.Duration(tpl.DefaultTTL),
// Disregard the values in the database, since MaxTTL, FailureTTL, InactivityTTL, and LockedTTL are enterprise
// features.
MaxTTL: 0,
// Disregard the values in the database, since RestartRequirement,
// FailureTTL, InactivityTTL, and LockedTTL are enterprise features.
UseRestartRequirement: false,
MaxTTL: 0,
RestartRequirement: TemplateRestartRequirement{
DaysOfWeek: 0,
Weeks: 0,
},
FailureTTL: 0,
InactivityTTL: 0,
LockedTTL: 0,
}, nil
}
func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) {
func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) {
if int64(opts.DefaultTTL) == tpl.DefaultTTL {
// Avoid updating the UpdatedAt timestamp if nothing will be changed.
return tpl, nil
@@ -76,14 +159,16 @@ func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context
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).
AllowUserAutostart: tpl.AllowUserAutostart,
AllowUserAutostop: tpl.AllowUserAutostop,
MaxTTL: tpl.MaxTTL,
FailureTTL: tpl.FailureTTL,
InactivityTTL: tpl.InactivityTTL,
LockedTTL: tpl.LockedTTL,
// Don't allow changing these settings, but keep the value in the DB (to
// avoid clearing settings if the license has an issue).
MaxTTL: tpl.MaxTTL,
RestartRequirementDaysOfWeek: tpl.RestartRequirementDaysOfWeek,
RestartRequirementWeeks: tpl.RestartRequirementWeeks,
AllowUserAutostart: tpl.AllowUserAutostart,
AllowUserAutostop: tpl.AllowUserAutostop,
FailureTTL: tpl.FailureTTL,
InactivityTTL: tpl.InactivityTTL,
LockedTTL: tpl.LockedTTL,
})
if err != nil {
return xerrors.Errorf("update template schedule: %w", err)
+60
View File
@@ -0,0 +1,60 @@
package schedule
import (
"context"
"github.com/google/uuid"
"github.com/coder/coder/coderd/database"
)
type UserQuietHoursScheduleOptions struct {
// Schedule is the cron schedule to use for quiet hours windows for all
// workspaces owned by the user.
//
// This value will be set to the parsed custom schedule of the user. If the
// user doesn't have a custom schedule set, it will be set to the default
// schedule (and UserSet will be false). If quiet hours schedules are not
// entitled or disabled instance-wide, this value will be nil to denote that
// quiet hours windows should not be used.
Schedule *Schedule
UserSet bool
}
type UserQuietHoursScheduleStore interface {
// Get retrieves the quiet hours schedule for the given user. If the user
// has not set a custom schedule, the default schedule will be returned. If
// quiet hours schedules are not entitled or disabled instance-wide, this
// will return a nil schedule.
Get(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error)
// Set sets the quiet hours schedule for the given user. If the given
// schedule is an empty string, the user's custom schedule will be cleared
// and the default schedule will be used from now on. If quiet hours
// schedules are not entitled or disabled instance-wide, this will do
// nothing and return a nil schedule.
Set(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (UserQuietHoursScheduleOptions, error)
}
type agplUserQuietHoursScheduleStore struct{}
var _ UserQuietHoursScheduleStore = &agplUserQuietHoursScheduleStore{}
func NewAGPLUserQuietHoursScheduleStore() UserQuietHoursScheduleStore {
return &agplUserQuietHoursScheduleStore{}
}
func (*agplUserQuietHoursScheduleStore) Get(_ context.Context, _ database.Store, _ uuid.UUID) (UserQuietHoursScheduleOptions, error) {
// User quiet hours windows are not supported in AGPL.
return UserQuietHoursScheduleOptions{
Schedule: nil,
UserSet: false,
}, nil
}
func (*agplUserQuietHoursScheduleStore) Set(_ context.Context, _ database.Store, _ uuid.UUID, _ string) (UserQuietHoursScheduleOptions, error) {
// User quiet hours windows are not supported in AGPL.
return UserQuietHoursScheduleOptions{
Schedule: nil,
UserSet: false,
}, nil
}
+84 -16
View File
@@ -213,17 +213,21 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
}
var (
defaultTTL time.Duration
maxTTL time.Duration
failureTTL time.Duration
inactivityTTL time.Duration
lockedTTL time.Duration
defaultTTL time.Duration
// TODO(@dean): remove max_ttl once restart_requirement is ready
maxTTL time.Duration
restartRequirementDaysOfWeek []string
restartRequirementWeeks int64
failureTTL time.Duration
inactivityTTL time.Duration
lockedTTL time.Duration
)
if createTemplate.DefaultTTLMillis != nil {
defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond
}
if createTemplate.MaxTTLMillis != nil {
maxTTL = time.Duration(*createTemplate.MaxTTLMillis) * time.Millisecond
if createTemplate.RestartRequirement != nil {
restartRequirementDaysOfWeek = createTemplate.RestartRequirement.DaysOfWeek
restartRequirementWeeks = createTemplate.RestartRequirement.Weeks
}
if createTemplate.FailureTTLMillis != nil {
failureTTL = time.Duration(*createTemplate.FailureTTLMillis) * time.Millisecond
@@ -235,7 +239,10 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
lockedTTL = time.Duration(*createTemplate.LockedTTLMillis) * time.Millisecond
}
var validErrs []codersdk.ValidationError
var (
validErrs []codersdk.ValidationError
restartRequirementDaysOfWeekParsed uint8
)
if defaultTTL < 0 {
validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."})
}
@@ -245,6 +252,21 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
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(restartRequirementDaysOfWeek) > 0 {
restartRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(restartRequirementDaysOfWeek)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.days_of_week", Detail: err.Error()})
}
}
if createTemplate.MaxTTLMillis != nil {
maxTTL = time.Duration(*createTemplate.MaxTTLMillis) * time.Millisecond
}
if restartRequirementWeeks < 0 {
validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: "Must be a positive integer."})
}
if restartRequirementWeeks > schedule.MaxTemplateRestartRequirementWeeks {
validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateRestartRequirementWeeks)})
}
if failureTTL < 0 {
validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."})
}
@@ -306,14 +328,18 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
return xerrors.Errorf("get template by id: %s", err)
}
dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{
dbTemplate, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{
UserAutostartEnabled: allowUserAutostart,
UserAutostopEnabled: allowUserAutostop,
DefaultTTL: defaultTTL,
MaxTTL: maxTTL,
// Some of these values are enterprise-only, but the
// TemplateScheduleStore will handle avoiding setting them if
// unlicensed.
MaxTTL: maxTTL,
RestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: restartRequirementDaysOfWeekParsed,
Weeks: restartRequirementWeeks,
},
FailureTTL: failureTTL,
InactivityTTL: inactivityTTL,
LockedTTL: lockedTTL,
@@ -459,12 +485,24 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
defer commitAudit()
aReq.Old = template
scheduleOpts, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching template schedule options.",
Detail: err.Error(),
})
return
}
var req codersdk.UpdateTemplateMeta
if !httpapi.Read(ctx, rw, r, &req) {
return
}
var validErrs []codersdk.ValidationError
var (
validErrs []codersdk.ValidationError
restartRequirementDaysOfWeekParsed uint8
)
if req.DefaultTTLMillis < 0 {
validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."})
}
@@ -474,6 +512,24 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
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 req.RestartRequirement == nil {
req.RestartRequirement = &codersdk.TemplateRestartRequirement{
DaysOfWeek: codersdk.BitmapToWeekdays(scheduleOpts.RestartRequirement.DaysOfWeek),
Weeks: scheduleOpts.RestartRequirement.Weeks,
}
}
if len(req.RestartRequirement.DaysOfWeek) > 0 {
restartRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(req.RestartRequirement.DaysOfWeek)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.days_of_week", Detail: err.Error()})
}
}
if req.RestartRequirement.Weeks < 0 {
validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: "Must be a positive integer."})
}
if req.RestartRequirement.Weeks > schedule.MaxTemplateRestartRequirementWeeks {
validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateRestartRequirementWeeks)})
}
if req.FailureTTLMillis < 0 {
validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."})
}
@@ -496,7 +552,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
}
var updated database.Template
err := api.Database.InTx(func(tx database.Store) error {
err = api.Database.InTx(func(tx database.Store) error {
if req.Name == template.Name &&
req.Description == template.Description &&
req.DisplayName == template.DisplayName &&
@@ -506,6 +562,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs &&
req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() &&
req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() &&
restartRequirementDaysOfWeekParsed == scheduleOpts.RestartRequirement.DaysOfWeek &&
req.RestartRequirement.Weeks == scheduleOpts.RestartRequirement.Weeks &&
req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() &&
req.InactivityTTLMillis == time.Duration(template.InactivityTTL).Milliseconds() &&
req.LockedTTLMillis == time.Duration(template.LockedTTL).Milliseconds() {
@@ -545,12 +603,14 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
if defaultTTL != time.Duration(template.DefaultTTL) ||
maxTTL != time.Duration(template.MaxTTL) ||
restartRequirementDaysOfWeekParsed != scheduleOpts.RestartRequirement.DaysOfWeek ||
req.RestartRequirement.Weeks != scheduleOpts.RestartRequirement.Weeks ||
failureTTL != time.Duration(template.FailureTTL) ||
inactivityTTL != time.Duration(template.InactivityTTL) ||
lockedTTL != time.Duration(template.LockedTTL) ||
req.AllowUserAutostart != template.AllowUserAutostart ||
req.AllowUserAutostop != template.AllowUserAutostop {
updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, schedule.TemplateScheduleOptions{
updated, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, updated, schedule.TemplateScheduleOptions{
// Some of these values are enterprise-only, but the
// TemplateScheduleStore will handle avoiding setting them if
// unlicensed.
@@ -558,9 +618,13 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
UserAutostopEnabled: req.AllowUserAutostop,
DefaultTTL: defaultTTL,
MaxTTL: maxTTL,
FailureTTL: failureTTL,
InactivityTTL: inactivityTTL,
LockedTTL: lockedTTL,
RestartRequirement: schedule.TemplateRestartRequirement{
DaysOfWeek: restartRequirementDaysOfWeekParsed,
Weeks: req.RestartRequirement.Weeks,
},
FailureTTL: failureTTL,
InactivityTTL: inactivityTTL,
LockedTTL: lockedTTL,
})
if err != nil {
return xerrors.Errorf("set template schedule options: %w", err)
@@ -694,5 +758,9 @@ func (api *API) convertTemplate(
FailureTTLMillis: time.Duration(template.FailureTTL).Milliseconds(),
InactivityTTLMillis: time.Duration(template.InactivityTTL).Milliseconds(),
LockedTTLMillis: time.Duration(template.LockedTTL).Milliseconds(),
RestartRequirement: codersdk.TemplateRestartRequirement{
DaysOfWeek: codersdk.BitmapToWeekdays(uint8(template.RestartRequirementDaysOfWeek)),
Weeks: template.RestartRequirementWeeks,
},
}
}
+337 -120
View File
@@ -168,95 +168,6 @@ 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: schedule.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("AllowUserScheduling", func(t *testing.T) {
t.Parallel()
@@ -334,6 +245,143 @@ func TestPostTemplateByOrganization(t *testing.T) {
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("RestartRequirement", func(t *testing.T) {
t.Parallel()
t.Run("None", func(t *testing.T) {
t.Parallel()
var setCalled int64
client := coderdtest.New(t, &coderdtest.Options{
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
atomic.AddInt64(&setCalled, 1)
assert.Zero(t, options.RestartRequirement.DaysOfWeek)
assert.Zero(t, options.RestartRequirement.Weeks)
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: template.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: options.UserAutostartEnabled,
AllowUserAutostop: options.UserAutostopEnabled,
DefaultTTL: int64(options.DefaultTTL),
MaxTTL: int64(options.MaxTTL),
RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek),
RestartRequirementWeeks: options.RestartRequirement.Weeks,
FailureTTL: int64(options.FailureTTL),
InactivityTTL: int64(options.InactivityTTL),
LockedTTL: int64(options.LockedTTL),
})
if !assert.NoError(t, err) {
return database.Template{}, err
}
return db.GetTemplateByID(ctx, template.ID)
},
},
})
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,
RestartRequirement: nil,
})
require.NoError(t, err)
require.EqualValues(t, 1, atomic.LoadInt64(&setCalled))
require.Empty(t, got.RestartRequirement.DaysOfWeek)
require.Zero(t, got.RestartRequirement.Weeks)
})
t.Run("OK", func(t *testing.T) {
t.Parallel()
var setCalled int64
client := coderdtest.New(t, &coderdtest.Options{
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
atomic.AddInt64(&setCalled, 1)
assert.EqualValues(t, 0b00110000, options.RestartRequirement.DaysOfWeek)
assert.EqualValues(t, 2, options.RestartRequirement.Weeks)
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: template.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: options.UserAutostartEnabled,
AllowUserAutostop: options.UserAutostopEnabled,
DefaultTTL: int64(options.DefaultTTL),
MaxTTL: int64(options.MaxTTL),
RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek),
RestartRequirementWeeks: options.RestartRequirement.Weeks,
FailureTTL: int64(options.FailureTTL),
InactivityTTL: int64(options.InactivityTTL),
LockedTTL: int64(options.LockedTTL),
})
if !assert.NoError(t, err) {
return database.Template{}, err
}
return db.GetTemplateByID(ctx, template.ID)
},
},
})
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,
RestartRequirement: &codersdk.TemplateRestartRequirement{
// wrong order
DaysOfWeek: []string{"saturday", "friday"},
Weeks: 2,
},
})
require.NoError(t, err)
require.EqualValues(t, 1, atomic.LoadInt64(&setCalled))
require.Equal(t, []string{"friday", "saturday"}, got.RestartRequirement.DaysOfWeek)
require.EqualValues(t, 2, got.RestartRequirement.Weeks)
got, err = client.Template(ctx, got.ID)
require.NoError(t, err)
require.Equal(t, []string{"friday", "saturday"}, got.RestartRequirement.DaysOfWeek)
require.EqualValues(t, 2, got.RestartRequirement.Weeks)
})
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,
RestartRequirement: &codersdk.TemplateRestartRequirement{
DaysOfWeek: []string{"friday", "saturday"},
Weeks: 2,
},
})
require.NoError(t, err)
// ignored and use AGPL defaults
require.Empty(t, got.RestartRequirement.DaysOfWeek)
require.Zero(t, got.RestartRequirement.Weeks)
})
})
}
func TestTemplatesByOrganization(t *testing.T) {
@@ -539,9 +587,25 @@ func TestPatchTemplateMeta(t *testing.T) {
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
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: template.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: options.UserAutostartEnabled,
AllowUserAutostop: options.UserAutostopEnabled,
DefaultTTL: int64(options.DefaultTTL),
MaxTTL: int64(options.MaxTTL),
RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek),
RestartRequirementWeeks: options.RestartRequirement.Weeks,
FailureTTL: int64(options.FailureTTL),
InactivityTTL: int64(options.InactivityTTL),
LockedTTL: int64(options.LockedTTL),
})
if !assert.NoError(t, err) {
return database.Template{}, err
}
return db.GetTemplateByID(ctx, template.ID)
},
},
})
@@ -674,7 +738,7 @@ func TestPatchTemplateMeta(t *testing.T) {
Description: template.Description,
Icon: template.Icon,
DefaultTTLMillis: 0,
MaxTTLMillis: 0,
RestartRequirement: &template.RestartRequirement,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
FailureTTLMillis: failureTTL.Milliseconds(),
InactivityTTLMillis: inactivityTTL.Milliseconds(),
@@ -709,7 +773,7 @@ func TestPatchTemplateMeta(t *testing.T) {
Description: template.Description,
Icon: template.Icon,
DefaultTTLMillis: template.DefaultTTLMillis,
MaxTTLMillis: template.MaxTTLMillis,
RestartRequirement: &template.RestartRequirement,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
FailureTTLMillis: failureTTL.Milliseconds(),
InactivityTTLMillis: inactivityTTL.Milliseconds(),
@@ -743,7 +807,6 @@ func TestPatchTemplateMeta(t *testing.T) {
assert.Equal(t, allowAutostop.Load(), options.UserAutostopEnabled)
template.DefaultTTL = int64(options.DefaultTTL)
template.MaxTTL = int64(options.MaxTTL)
template.AllowUserAutostart = options.UserAutostartEnabled
template.AllowUserAutostop = options.UserAutostopEnabled
return template, nil
@@ -769,7 +832,7 @@ func TestPatchTemplateMeta(t *testing.T) {
Description: template.Description,
Icon: template.Icon,
DefaultTTLMillis: template.DefaultTTLMillis,
MaxTTLMillis: template.MaxTTLMillis,
RestartRequirement: &template.RestartRequirement,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
AllowUserAutostart: allowAutostart.Load(),
AllowUserAutostop: allowAutostop.Load(),
@@ -801,7 +864,7 @@ func TestPatchTemplateMeta(t *testing.T) {
Icon: template.Icon,
// Increase the default TTL to avoid error "not modified".
DefaultTTLMillis: template.DefaultTTLMillis + 1,
MaxTTLMillis: template.MaxTTLMillis,
RestartRequirement: &template.RestartRequirement,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
AllowUserAutostart: false,
AllowUserAutostop: false,
@@ -832,6 +895,7 @@ func TestPatchTemplateMeta(t *testing.T) {
Description: template.Description,
Icon: template.Icon,
DefaultTTLMillis: template.DefaultTTLMillis,
RestartRequirement: nil,
AllowUserAutostart: template.AllowUserAutostart,
AllowUserAutostop: template.AllowUserAutostop,
}
@@ -900,34 +964,187 @@ func TestPatchTemplateMeta(t *testing.T) {
assert.Equal(t, updated.Icon, "")
})
t.Run("MaxTTLEnterpriseOnly", func(t *testing.T) {
t.Run("RestartRequirement", 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(),
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
var setCalled int64
client := coderdtest.New(t, &coderdtest.Options{
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
if atomic.AddInt64(&setCalled, 1) == 2 {
assert.EqualValues(t, 0b0110000, options.RestartRequirement.DaysOfWeek)
assert.EqualValues(t, 2, options.RestartRequirement.Weeks)
}
updated, err := client.UpdateTemplateMeta(ctx, template.ID, req)
require.NoError(t, err)
require.EqualValues(t, 0, updated.MaxTTLMillis)
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: template.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: options.UserAutostartEnabled,
AllowUserAutostop: options.UserAutostopEnabled,
DefaultTTL: int64(options.DefaultTTL),
MaxTTL: int64(options.MaxTTL),
RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek),
RestartRequirementWeeks: options.RestartRequirement.Weeks,
FailureTTL: int64(options.FailureTTL),
InactivityTTL: int64(options.InactivityTTL),
LockedTTL: int64(options.LockedTTL),
})
if !assert.NoError(t, err) {
return database.Template{}, err
}
template, err = client.Template(ctx, template.ID)
require.NoError(t, err)
require.EqualValues(t, 0, template.MaxTTLMillis)
return db.GetTemplateByID(ctx, template.ID)
},
},
})
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, 1, atomic.LoadInt64(&setCalled))
require.Empty(t, template.RestartRequirement.DaysOfWeek)
require.Zero(t, template.RestartRequirement.Weeks)
req := codersdk.UpdateTemplateMeta{
Name: template.Name,
DisplayName: template.DisplayName,
Description: template.Description,
Icon: template.Icon,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
DefaultTTLMillis: time.Hour.Milliseconds(),
RestartRequirement: &codersdk.TemplateRestartRequirement{
// wrong order
DaysOfWeek: []string{"saturday", "friday"},
Weeks: 2,
},
}
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, 2, atomic.LoadInt64(&setCalled))
require.Equal(t, []string{"friday", "saturday"}, updated.RestartRequirement.DaysOfWeek)
require.EqualValues(t, 2, updated.RestartRequirement.Weeks)
template, err = client.Template(ctx, template.ID)
require.NoError(t, err)
require.Equal(t, []string{"friday", "saturday"}, template.RestartRequirement.DaysOfWeek)
require.EqualValues(t, 2, template.RestartRequirement.Weeks)
})
t.Run("Unset", func(t *testing.T) {
t.Parallel()
var setCalled int64
client := coderdtest.New(t, &coderdtest.Options{
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
if atomic.AddInt64(&setCalled, 1) == 2 {
assert.EqualValues(t, 0, options.RestartRequirement.DaysOfWeek)
assert.EqualValues(t, 0, options.RestartRequirement.Weeks)
}
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: template.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: options.UserAutostartEnabled,
AllowUserAutostop: options.UserAutostopEnabled,
DefaultTTL: int64(options.DefaultTTL),
MaxTTL: int64(options.MaxTTL),
RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek),
RestartRequirementWeeks: options.RestartRequirement.Weeks,
FailureTTL: int64(options.FailureTTL),
InactivityTTL: int64(options.InactivityTTL),
LockedTTL: int64(options.LockedTTL),
})
if !assert.NoError(t, err) {
return database.Template{}, err
}
return db.GetTemplateByID(ctx, template.ID)
},
},
})
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.RestartRequirement = &codersdk.TemplateRestartRequirement{
// wrong order
DaysOfWeek: []string{"sunday", "saturday", "friday", "thursday", "wednesday", "tuesday", "monday"},
Weeks: 2,
}
})
require.EqualValues(t, 1, atomic.LoadInt64(&setCalled))
require.Equal(t, []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}, template.RestartRequirement.DaysOfWeek)
require.EqualValues(t, 2, template.RestartRequirement.Weeks)
req := codersdk.UpdateTemplateMeta{
Name: template.Name,
DisplayName: template.DisplayName,
Description: template.Description,
Icon: template.Icon,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
DefaultTTLMillis: time.Hour.Milliseconds(),
RestartRequirement: &codersdk.TemplateRestartRequirement{
DaysOfWeek: []string{},
Weeks: 0,
},
}
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, 2, atomic.LoadInt64(&setCalled))
require.Empty(t, updated.RestartRequirement.DaysOfWeek)
require.EqualValues(t, 0, updated.RestartRequirement.Weeks)
template, err = client.Template(ctx, template.ID)
require.NoError(t, err)
require.Empty(t, template.RestartRequirement.DaysOfWeek)
require.EqualValues(t, 0, template.RestartRequirement.Weeks)
})
t.Run("EnterpriseOnly", 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.Empty(t, template.RestartRequirement.DaysOfWeek)
require.Zero(t, template.RestartRequirement.Weeks)
req := codersdk.UpdateTemplateMeta{
Name: template.Name,
DisplayName: template.DisplayName,
Description: template.Description,
Icon: template.Icon,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
DefaultTTLMillis: time.Hour.Milliseconds(),
RestartRequirement: &codersdk.TemplateRestartRequirement{
DaysOfWeek: []string{"monday"},
Weeks: 2,
},
}
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
updated, err := client.UpdateTemplateMeta(ctx, template.ID, req)
require.NoError(t, err)
require.Empty(t, updated.RestartRequirement.DaysOfWeek)
require.Zero(t, updated.RestartRequirement.Weeks)
template, err = client.Template(ctx, template.ID)
require.NoError(t, err)
require.Empty(t, template.RestartRequirement.DaysOfWeek)
require.Zero(t, template.RestartRequirement.Weeks)
})
})
}
+17 -5
View File
@@ -369,7 +369,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
return
}
templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, template.ID)
templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching template schedule.",
@@ -378,7 +378,13 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
return
}
dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL, templateSchedule.MaxTTL)
maxTTL := templateSchedule.MaxTTL
if templateSchedule.UseRestartRequirement {
// If we're using restart requirements, there isn't a max TTL.
maxTTL = 0
}
dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL, maxTTL)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid Workspace Time to Shutdown.",
@@ -634,7 +640,7 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
}
// Check if the template allows users to configure autostart.
templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, workspace.TemplateID)
templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, workspace.TemplateID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error getting template schedule options.",
@@ -701,7 +707,7 @@ 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)
templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, s, workspace.TemplateID)
if err != nil {
return xerrors.Errorf("get template schedule: %w", err)
}
@@ -709,10 +715,16 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
return codersdk.ValidationError{Field: "ttl_ms", Detail: "Custom autostop TTL is not allowed for workspaces using this template."}
}
maxTTL := templateSchedule.MaxTTL
if templateSchedule.UseRestartRequirement {
// If we're using restart requirements, there isn't a max TTL.
maxTTL = 0
}
// don't override 0 ttl with template default here because it indicates
// disabled autostop
var validityErr error
dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, templateSchedule.MaxTTL)
dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, maxTTL)
if validityErr != nil {
return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()}
}
+2 -2
View File
@@ -1741,7 +1741,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) {
UserAutostartEnabled: false,
UserAutostopEnabled: false,
DefaultTTL: 0,
MaxTTL: 0,
RestartRequirement: schedule.TemplateRestartRequirement{},
}, nil
},
SetFn: func(_ context.Context, _ database.Store, tpl database.Template, _ schedule.TemplateScheduleOptions) (database.Template, error) {
@@ -1908,7 +1908,7 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
UserAutostartEnabled: false,
UserAutostopEnabled: false,
DefaultTTL: 0,
MaxTTL: 0,
RestartRequirement: schedule.TemplateRestartRequirement{},
}, nil
},
SetFn: func(_ context.Context, _ database.Store, tpl database.Template, _ schedule.TemplateScheduleOptions) (database.Template, error) {
+37
View File
@@ -45,6 +45,7 @@ const (
FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons"
FeatureAppearance FeatureName = "appearance"
FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling"
FeatureTemplateRestartRequirement FeatureName = "template_restart_requirement"
FeatureWorkspaceProxy FeatureName = "workspace_proxy"
)
@@ -167,6 +168,7 @@ type DeploymentValues struct {
DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"`
ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"`
EnableTerraformDebugMode clibase.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"`
UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"`
Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"`
@@ -344,6 +346,13 @@ type DangerousConfig struct {
AllowAllCors clibase.Bool `json:"allow_all_cors" typescript:",notnull"`
}
type UserQuietHoursScheduleConfig struct {
DefaultSchedule clibase.String `json:"default_schedule" typescript:",notnull"`
// TODO: add WindowDuration and the ability to postpone max_deadline by this
// amount
// WindowDuration clibase.Duration `json:"window_duration" typescript:",notnull"`
}
const (
annotationEnterpriseKey = "enterprise"
annotationSecretKey = "secret"
@@ -467,6 +476,11 @@ when required by your organization's security policy.`,
Description: `Tune the behavior of the provisioner, which is responsible for creating, updating, and deleting workspace resources.`,
YAML: "provisioning",
}
deploymentGroupUserQuietHoursSchedule = clibase.Group{
Name: "User Quiet Hours Schedule",
Description: "Allow users to set quiet hours schedules each day for workspaces to avoid workspaces stopping during the day due to template max TTL.",
YAML: "userQuietHoursSchedule",
}
deploymentGroupDangerous = clibase.Group{
Name: "⚠️ Dangerous",
YAML: "dangerous",
@@ -1581,6 +1595,16 @@ Write out the current server config as YAML to stdout.`,
Group: &deploymentGroupNetworkingHTTP,
YAML: "proxyHealthInterval",
},
{
Name: "Default Quiet Hours Schedule",
Description: "The default daily cron schedule applied to users that haven't set a custom quiet hours schedule themselves. The quiet hours schedule determines when workspaces will be force stopped due to the template's max TTL, and will round the max TTL up to be within the user's quiet hours window (or default). The format is the same as the standard cron format, but the day-of-month, month and day-of-week must be *. Only one hour and minute can be specified (ranges or comma separated values are not supported).",
Flag: "default-quiet-hours-schedule",
Env: "CODER_QUIET_HOURS_DEFAULT_SCHEDULE",
Default: "",
Value: &c.UserQuietHoursSchedule.DefaultSchedule,
Group: &deploymentGroupUserQuietHoursSchedule,
YAML: "defaultQuietHoursSchedule",
},
}
return opts
}
@@ -1782,6 +1806,19 @@ const (
ExperimentSingleTailnet Experiment = "single_tailnet"
ExperimentWorkspaceBuildLogsUI Experiment = "workspace_build_logs_ui"
// ExperimentTemplateRestartRequirement allows template admins to have more
// control over when workspaces created on a template are required to
// restart, and allows users to ensure these restarts never happen during
// their business hours.
//
// Enables:
// - User quiet hours schedule settings
// - Template restart requirement settings
// - Changes the max_deadline algorithm to use restart requirement and user
// quiet hours instead of max_ttl.
ExperimentTemplateRestartRequirement Experiment = "template_restart_requirement"
// Add new experiments here!
// ExperimentExample Experiment = "example"
)
+4 -2
View File
@@ -84,9 +84,11 @@ 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.
// TODO(@dean): remove max_ttl once restart_requirement is matured
MaxTTLMillis *int64 `json:"max_ttl_ms,omitempty"`
// RestartRequirement allows optionally specifying the restart requirement
// for workspaces created from this template. This is an enterprise feature.
RestartRequirement *TemplateRestartRequirement `json:"restart_requirement,omitempty"`
// Allow users to cancel in-progress workspace jobs.
// *bool as the default value is "true".
+90 -13
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
@@ -28,11 +29,13 @@ type Template struct {
Description string `json:"description"`
Icon string `json:"icon"`
DefaultTTLMillis int64 `json:"default_ttl_ms"`
// 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"`
// TODO(@dean): remove max_ttl once restart_requirement is matured
MaxTTLMillis int64 `json:"max_ttl_ms"`
// RestartRequirement is an enterprise feature. Its value is only used if
// your license is entitled to use the advanced template scheduling feature.
RestartRequirement TemplateRestartRequirement `json:"restart_requirement"`
CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"`
CreatedByName string `json:"created_by_name"`
// AllowUserAutostart and AllowUserAutostop are enterprise-only. Their
// values are only used if your license is entitled to use the advanced
@@ -49,6 +52,78 @@ type Template struct {
LockedTTLMillis int64 `json:"locked_ttl_ms"`
}
// WeekdaysToBitmap converts a list of weekdays to a bitmap in accordance with
// the schedule package's rules. The 0th bit is Monday, ..., the 6th bit is
// Sunday. The 7th bit is unused.
func WeekdaysToBitmap(days []string) (uint8, error) {
var bitmap uint8
for _, day := range days {
switch strings.ToLower(day) {
case "monday":
bitmap |= 1 << 0
case "tuesday":
bitmap |= 1 << 1
case "wednesday":
bitmap |= 1 << 2
case "thursday":
bitmap |= 1 << 3
case "friday":
bitmap |= 1 << 4
case "saturday":
bitmap |= 1 << 5
case "sunday":
bitmap |= 1 << 6
default:
return 0, xerrors.Errorf("invalid weekday %q", day)
}
}
return bitmap, nil
}
// BitmapToWeekdays converts a bitmap to a list of weekdays in accordance with
// the schedule package's rules (see above).
func BitmapToWeekdays(bitmap uint8) []string {
var days []string
for i := 0; i < 7; i++ {
if bitmap&(1<<i) != 0 {
switch i {
case 0:
days = append(days, "monday")
case 1:
days = append(days, "tuesday")
case 2:
days = append(days, "wednesday")
case 3:
days = append(days, "thursday")
case 4:
days = append(days, "friday")
case 5:
days = append(days, "saturday")
case 6:
days = append(days, "sunday")
}
}
}
return days
}
type TemplateRestartRequirement struct {
// DaysOfWeek is a list of days of the week on which restarts are required.
// Restarts happen within the user's quiet hours (in their configured
// timezone). If no days are specified, restarts are not required. Weekdays
// cannot be specified twice.
//
// Restarts will only happen on weekdays in this list on weeks which line up
// with Weeks.
DaysOfWeek []string `json:"days_of_week" enums:"monday,tuesday,wednesday,thursday,friday,saturday,sunday"`
// Weeks is the number of weeks between required restarts. Weeks are synced
// across all workspaces (and Coder deployments) using modulo math on a
// hardcoded epoch week of January 2nd, 2023 (the first Monday of 2023).
// Values of 0 or 1 indicate weekly restarts. Values of 2 indicate
// fortnightly restarts, etc.
Weeks int64 `json:"weeks"`
}
type TransitionStats struct {
P50 *int64 `example:"123"`
P95 *int64 `example:"146"`
@@ -98,16 +173,18 @@ type UpdateTemplateMeta struct {
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
// TODO(@dean): remove max_ttl once restart_requirement is matured
MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"`
// RestartRequirement 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"`
AllowUserAutostart bool `json:"allow_user_autostart,omitempty"`
AllowUserAutostop bool `json:"allow_user_autostop,omitempty"`
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"`
FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"`
InactivityTTLMillis int64 `json:"inactivity_ttl_ms,omitempty"`
LockedTTLMillis int64 `json:"locked_ttl_ms,omitempty"`
RestartRequirement *TemplateRestartRequirement `json:"restart_requirement,omitempty"`
AllowUserAutostart bool `json:"allow_user_autostart,omitempty"`
AllowUserAutostop bool `json:"allow_user_autostop,omitempty"`
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"`
FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"`
InactivityTTLMillis int64 `json:"inactivity_ttl_ms,omitempty"`
LockedTTLMillis int64 `json:"locked_ttl_ms,omitempty"`
}
type TemplateExample struct {
+58
View File
@@ -84,6 +84,34 @@ type UpdateUserPasswordRequest struct {
Password string `json:"password" validate:"required"`
}
type UserQuietHoursScheduleResponse struct {
RawSchedule string `json:"raw_schedule"`
// UserSet is true if the user has set their own quiet hours schedule. If
// false, the user is using the default schedule.
UserSet bool `json:"user_set"`
// Time is the time of day that the quiet hours window starts in the given
// Timezone each day.
Time string `json:"time"` // HH:mm (24-hour)
Timezone string `json:"timezone"` // raw format from the cron expression, UTC if unspecified
// Next is the next time that the quiet hours window will start.
Next time.Time `json:"next" format:"date-time"`
}
type UpdateUserQuietHoursScheduleRequest struct {
// Schedule is a cron expression that defines when the user's quiet hours
// window is. Schedule must not be empty. For new users, the schedule is set
// to 2am in their browser or computer's timezone. The schedule denotes the
// beginning of a 4 hour window where the workspace is allowed to
// automatically stop or restart due to maintenance or template max TTL.
//
// The schedule must be daily with a single time, and should have a timezone
// specified via a CRON_TZ prefix (otherwise UTC will be used).
//
// If the schedule is empty, the user will be updated to use the default
// schedule.
Schedule string `json:"schedule" validate:"required"`
}
type UpdateRoles struct {
Roles []string `json:"roles" validate:""`
}
@@ -364,6 +392,36 @@ func (c *Client) User(ctx context.Context, userIdent string) (User, error) {
return user, json.NewDecoder(res.Body).Decode(&user)
}
// UserQuietHoursSchedule returns the quiet hours settings for the user. This
// endpoint only exists in enterprise editions.
func (c *Client) UserQuietHoursSchedule(ctx context.Context, userIdent string) (UserQuietHoursScheduleResponse, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/quiet-hours", userIdent), nil)
if err != nil {
return UserQuietHoursScheduleResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserQuietHoursScheduleResponse{}, ReadBodyAsError(res)
}
var resp UserQuietHoursScheduleResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateUserQuietHoursSchedule updates the quiet hours settings for the user.
// This endpoint only exists in enterprise editions.
func (c *Client) UpdateUserQuietHoursSchedule(ctx context.Context, userIdent string, req UpdateUserQuietHoursScheduleRequest) (UserQuietHoursScheduleResponse, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/quiet-hours", userIdent), req)
if err != nil {
return UserQuietHoursScheduleResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserQuietHoursScheduleResponse{}, ReadBodyAsError(res)
}
var resp UserQuietHoursScheduleResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// Users returns all users according to the request parameters. If no parameters are set,
// the default behavior is to return all users in a single page.
func (c *Client) Users(ctx context.Context, req UsersRequest) (GetUsersResponse, error) {
+13 -13
View File
@@ -9,19 +9,19 @@ We track the following resources:
<!-- Code generated by 'make docs/admin/audit-logs.md'. DO NOT EDIT -->
| <b>Resource<b> | |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</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>true</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>true</td></tr></tbody></table> |
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</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_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</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>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</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>failure_ttl</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>inactivity_ttl</td><td>true</td></tr><tr><td>locked_ttl</td><td>true</td></tr><tr><td>max_ttl</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>message</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>true</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>locked_at</td><td>true</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>max_deadline</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> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
| <b>Resource<b> | |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</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>true</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>true</td></tr></tbody></table> |
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</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_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</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>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</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>failure_ttl</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>inactivity_ttl</td><td>true</td></tr><tr><td>locked_ttl</td><td>true</td></tr><tr><td>max_ttl</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>restart_requirement_days_of_week</td><td>true</td></tr><tr><td>restart_requirement_weeks</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>message</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>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</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>locked_at</td><td>true</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>max_deadline</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> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
<!-- End generated by 'make docs/admin/audit-logs.md'. -->
+122
View File
@@ -1122,6 +1122,128 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template}/acl \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get user quiet hours schedule
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/users/{user}/quiet-hours \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /users/{user}/quiet-hours`
### Parameters
| Name | In | Type | Required | Description |
| ------ | ---- | ------------ | -------- | ----------- |
| `user` | path | string(uuid) | true | User ID |
### Example responses
> 200 Response
```json
[
{
"next": "2019-08-24T14:15:22Z",
"raw_schedule": "string",
"time": "string",
"timezone": "string",
"user_set": true
}
]
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserQuietHoursScheduleResponse](schemas.md#codersdkuserquiethoursscheduleresponse) |
<h3 id="get-user-quiet-hours-schedule-responseschema">Response Schema</h3>
Status Code **200**
| Name | Type | Required | Restrictions | Description |
| ---------------- | ----------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `[array item]` | array | false | | |
| `» next` | string(date-time) | false | | Next is the next time that the quiet hours window will start. |
| `» raw_schedule` | string | false | | |
| `» time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. |
| `» timezone` | string | false | | raw format from the cron expression, UTC if unspecified |
| `» user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Update user quiet hours schedule
### Code samples
```shell
# Example request using curl
curl -X PUT http://coder-server:8080/api/v2/users/{user}/quiet-hours \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`PUT /users/{user}/quiet-hours`
> Body parameter
```json
{
"schedule": "string"
}
```
### Parameters
| Name | In | Type | Required | Description |
| ------ | ---- | ------------------------------------------------------------------------------------------------------ | -------- | ----------------------- |
| `user` | path | string(uuid) | true | User ID |
| `body` | body | [codersdk.UpdateUserQuietHoursScheduleRequest](schemas.md#codersdkupdateuserquiethoursschedulerequest) | true | Update schedule request |
### Example responses
> 200 Response
```json
[
{
"next": "2019-08-24T14:15:22Z",
"raw_schedule": "string",
"time": "string",
"timezone": "string",
"user_set": true
}
]
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserQuietHoursScheduleResponse](schemas.md#codersdkuserquiethoursscheduleresponse) |
<h3 id="update-user-quiet-hours-schedule-responseschema">Response Schema</h3>
Status Code **200**
| Name | Type | Required | Restrictions | Description |
| ---------------- | ----------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `[array item]` | array | false | | |
| `» next` | string(date-time) | false | | Next is the next time that the quiet hours window will start. |
| `» raw_schedule` | string | false | | |
| `» time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. |
| `» timezone` | string | false | | raw format from the cron expression, UTC if unspecified |
| `» user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get workspace quota by user
### Code samples
+3
View File
@@ -370,6 +370,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"honeycomb_api_key": "string"
},
"update_check": true,
"user_quiet_hours_schedule": {
"default_schedule": "string"
},
"verbose": true,
"wgtunnel_host": "string",
"wildcard_access_url": {
+136 -48
View File
@@ -1382,28 +1382,33 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"locked_ttl_ms": 0,
"max_ttl_ms": 0,
"name": "string",
"restart_requirement": {
"days_of_week": ["monday"],
"weeks": 0
},
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allow_user_autostart` | boolean | false | | Allow user autostart allows users to set a schedule for autostarting their workspace. By default this is true. This can only be disabled when using an enterprise license. |
| `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. |
| `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. \*bool as the default value is "true". |
| `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. |
| `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. |
| `disable_everyone_group_access` | boolean | false | | Disable everyone group access allows optionally disabling the default behavior of granting the 'everyone' group access to use the template. If this is set to true, the template will not be available to all users, and must be explicitly granted to users or groups in the permissions settings of the template. |
| `display_name` | string | false | | Display name is the displayed name of the template. |
| `failure_ttl_ms` | integer | false | | Failure ttl ms allows optionally specifying the max lifetime before Coder stops all resources for failed workspaces created from this template. |
| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. |
| `inactivity_ttl_ms` | integer | false | | Inactivity ttl ms allows optionally specifying the max lifetime before Coder locks inactive workspaces created from this template. |
| `locked_ttl_ms` | integer | false | | Locked ttl ms allows optionally specifying the max lifetime before Coder permanently deletes locked workspaces created from this template. |
| `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. |
| `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. |
| Name | Type | Required | Restrictions | Description |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allow_user_autostart` | boolean | false | | Allow user autostart allows users to set a schedule for autostarting their workspace. By default this is true. This can only be disabled when using an enterprise license. |
| `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. |
| `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. \*bool as the default value is "true". |
| `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. |
| `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. |
| `disable_everyone_group_access` | boolean | false | | Disable everyone group access allows optionally disabling the default behavior of granting the 'everyone' group access to use the template. If this is set to true, the template will not be available to all users, and must be explicitly granted to users or groups in the permissions settings of the template. |
| `display_name` | string | false | | Display name is the displayed name of the template. |
| `failure_ttl_ms` | integer | false | | Failure ttl ms allows optionally specifying the max lifetime before Coder stops all resources for failed workspaces created from this template. |
| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. |
| `inactivity_ttl_ms` | integer | false | | Inactivity ttl ms allows optionally specifying the max lifetime before Coder locks inactive workspaces created from this template. |
| `locked_ttl_ms` | integer | false | | Locked ttl ms allows optionally specifying the max lifetime before Coder permanently deletes locked workspaces created from this template. |
| `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured |
| `name` | string | true | | Name is the name of the template. |
| `restart_requirement` | [codersdk.TemplateRestartRequirement](#codersdktemplaterestartrequirement) | false | | Restart requirement allows optionally specifying the restart requirement for workspaces created from this template. This is an enterprise feature. |
| `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. |
| This is required on creation to enable a user-flow of validating a template works. There is no reason the data-model cannot support empty templates, but it doesn't make sense for users. |
## codersdk.CreateTemplateVersionDryRunRequest
@@ -2076,6 +2081,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"honeycomb_api_key": "string"
},
"update_check": true,
"user_quiet_hours_schedule": {
"default_schedule": "string"
},
"verbose": true,
"wgtunnel_host": "string",
"wildcard_access_url": {
@@ -2426,6 +2434,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"honeycomb_api_key": "string"
},
"update_check": true,
"user_quiet_hours_schedule": {
"default_schedule": "string"
},
"verbose": true,
"wgtunnel_host": "string",
"wildcard_access_url": {
@@ -2497,6 +2508,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | |
| `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | |
| `update_check` | boolean | false | | |
| `user_quiet_hours_schedule` | [codersdk.UserQuietHoursScheduleConfig](#codersdkuserquiethoursscheduleconfig) | false | | |
| `verbose` | boolean | false | | |
| `wgtunnel_host` | string | false | | |
| `wildcard_access_url` | [clibase.URL](#clibaseurl) | false | | |
@@ -2566,14 +2578,15 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
#### Enumerated Values
| Value |
| ------------------------- |
| `moons` |
| `workspace_actions` |
| `tailnet_ha_coordinator` |
| `convert-to-oidc` |
| `single_tailnet` |
| `workspace_build_logs_ui` |
| Value |
| ------------------------------ |
| `moons` |
| `workspace_actions` |
| `tailnet_ha_coordinator` |
| `convert-to-oidc` |
| `single_tailnet` |
| `workspace_build_logs_ui` |
| `template_restart_requirement` |
## codersdk.Feature
@@ -4001,36 +4014,41 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"provisioner": "terraform",
"restart_requirement": {
"days_of_week": ["monday"],
"weeks": 0
},
"updated_at": "2019-08-24T14:15:22Z"
}
```
### 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_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. |
| `allow_user_autostop` | boolean | false | | |
| `allow_user_cancel_workspace_jobs` | boolean | false | | |
| `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 | | |
| `failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. |
| `icon` | string | false | | |
| `id` | string | false | | |
| `inactivity_ttl_ms` | integer | false | | |
| `locked_ttl_ms` | integer | 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 | | |
| 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_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. |
| `allow_user_autostop` | boolean | false | | |
| `allow_user_cancel_workspace_jobs` | boolean | false | | |
| `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 | | |
| `failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. |
| `icon` | string | false | | |
| `id` | string | false | | |
| `inactivity_ttl_ms` | integer | false | | |
| `locked_ttl_ms` | integer | false | | |
| `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured |
| `name` | string | false | | |
| `organization_id` | string | false | | |
| `provisioner` | string | false | | |
| `restart_requirement` | [codersdk.TemplateRestartRequirement](#codersdktemplaterestartrequirement) | false | | Restart requirement is an enterprise feature. Its value is only used if your license is entitled to use the advanced template scheduling feature. |
| `updated_at` | string | false | | |
#### Enumerated Values
@@ -4085,6 +4103,23 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `tags` | array of string | false | | |
| `url` | string | false | | |
## codersdk.TemplateRestartRequirement
```json
{
"days_of_week": ["monday"],
"weeks": 0
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------------------------------------------------------------------------- | --------------- | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `days_of_week` | array of string | false | | Days of week is a list of days of the week on which restarts are required. Restarts happen within the user's quiet hours (in their configured timezone). If no days are specified, restarts are not required. Weekdays cannot be specified twice. |
| Restarts will only happen on weekdays in this list on weeks which line up with Weeks. |
| `weeks` | integer | false | | Weeks is the number of weeks between required restarts. Weeks are synced across all workspaces (and Coder deployments) using modulo math on a hardcoded epoch week of January 2nd, 2023 (the first Monday of 2023). Values of 0 or 1 indicate weekly restarts. Values of 2 indicate fortnightly restarts, etc. |
## codersdk.TemplateRole
```json
@@ -4531,6 +4566,23 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| ---------- | ------ | -------- | ------------ | ----------- |
| `username` | string | true | | |
## codersdk.UpdateUserQuietHoursScheduleRequest
```json
{
"schedule": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ---------- | ------ | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `schedule` | string | true | | Schedule is a cron expression that defines when the user's quiet hours window is. Schedule must not be empty. For new users, the schedule is set to 2am in their browser or computer's timezone. The schedule denotes the beginning of a 4 hour window where the workspace is allowed to automatically stop or restart due to maintenance or template max TTL. |
The schedule must be daily with a single time, and should have a timezone specified via a CRON_TZ prefix (otherwise UTC will be used).
If the schedule is empty, the user will be updated to use the default schedule.|
## codersdk.UpdateWorkspaceAutostartRequest
```json
@@ -4657,6 +4709,42 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| ------------ | ---------------------------------------- | -------- | ------------ | ----------- |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
## codersdk.UserQuietHoursScheduleConfig
```json
{
"default_schedule": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------ | -------- | ------------ | ----------- |
| `default_schedule` | string | false | | |
## codersdk.UserQuietHoursScheduleResponse
```json
{
"next": "2019-08-24T14:15:22Z",
"raw_schedule": "string",
"time": "string",
"timezone": "string",
"user_set": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | ------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `next` | string | false | | Next is the next time that the quiet hours window will start. |
| `raw_schedule` | string | false | | |
| `time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. |
| `timezone` | string | false | | raw format from the cron expression, UTC if unspecified |
| `user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. |
## codersdk.UserStatus
```json
+56 -28
View File
@@ -56,6 +56,10 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"provisioner": "terraform",
"restart_requirement": {
"days_of_week": ["monday"],
"weeks": 0
},
"updated_at": "2019-08-24T14:15:22Z"
}
]
@@ -71,34 +75,38 @@ 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_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. |
| `» allow_user_autostop` | boolean | false | | |
| `» allow_user_cancel_workspace_jobs` | boolean | false | | |
| `» 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 | | |
| `» failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. |
| `» icon` | string | false | | |
| `» id` | string(uuid) | false | | |
| `» inactivity_ttl_ms` | integer | false | | |
| `» locked_ttl_ms` | integer | 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 | | |
| 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_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. |
| `» allow_user_autostop` | boolean | false | | |
| `» allow_user_cancel_workspace_jobs` | boolean | false | | |
| `» 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 | | |
| `» failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. |
| `» icon` | string | false | | |
| `» id` | string(uuid) | false | | |
| `» inactivity_ttl_ms` | integer | false | | |
| `» locked_ttl_ms` | integer | false | | |
| `» max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured |
| `» name` | string | false | | |
| `» organization_id` | string(uuid) | false | | |
| `» provisioner` | string | false | | |
| restart_requirement` | [codersdk.TemplateRestartRequirement](schemas.md#codersdktemplaterestartrequirement) | false | | Restart requirement is an enterprise feature. Its value is only used if your license is entitled to use the advanced template scheduling feature. |
| `»» days_of_week` | array | false | | »days of week is a list of days of the week on which restarts are required. Restarts happen within the user's quiet hours (in their configured timezone). If no days are specified, restarts are not required. Weekdays cannot be specified twice. |
| Restarts will only happen on weekdays in this list on weeks which line up with Weeks. |
| `»» weeks` | integer | false | | Weeks is the number of weeks between required restarts. Weeks are synced across all workspaces (and Coder deployments) using modulo math on a hardcoded epoch week of January 2nd, 2023 (the first Monday of 2023). Values of 0 or 1 indicate weekly restarts. Values of 2 indicate fortnightly restarts, etc. |
| `» updated_at` | string(date-time) | false | | |
#### Enumerated Values
@@ -139,6 +147,10 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
"locked_ttl_ms": 0,
"max_ttl_ms": 0,
"name": "string",
"restart_requirement": {
"days_of_week": ["monday"],
"weeks": 0
},
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1"
}
```
@@ -186,6 +198,10 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"provisioner": "terraform",
"restart_requirement": {
"days_of_week": ["monday"],
"weeks": 0
},
"updated_at": "2019-08-24T14:15:22Z"
}
```
@@ -314,6 +330,10 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"provisioner": "terraform",
"restart_requirement": {
"days_of_week": ["monday"],
"weeks": 0
},
"updated_at": "2019-08-24T14:15:22Z"
}
```
@@ -648,6 +668,10 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"provisioner": "terraform",
"restart_requirement": {
"days_of_week": ["monday"],
"weeks": 0
},
"updated_at": "2019-08-24T14:15:22Z"
}
```
@@ -759,6 +783,10 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"provisioner": "terraform",
"restart_requirement": {
"days_of_week": ["monday"],
"weeks": 0
},
"updated_at": "2019-08-24T14:15:22Z"
}
```
+10
View File
@@ -183,6 +183,16 @@ An HTTP URL that is accessible by other replicas to relay DERP traffic. Required
Addresses for STUN servers to establish P2P connections. Use special value 'disable' to turn off STUN.
### --default-quiet-hours-schedule
| | |
| ----------- | ------------------------------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_QUIET_HOURS_DEFAULT_SCHEDULE</code> |
| YAML | <code>userQuietHoursSchedule.defaultQuietHoursSchedule</code> |
The default daily cron schedule applied to users that haven't set a custom quiet hours schedule themselves. The quiet hours schedule determines when workspaces will be force stopped due to the template's max TTL, and will round the max TTL up to be within the user's quiet hours window (or default). The format is the same as the standard cron format, but the day-of-month, month and day-of-week must be \*. Only one hour and minute can be specified (ranges or comma separated values are not supported).
### --disable-owner-workspace-access
| | |
+16 -13
View File
@@ -70,6 +70,9 @@ var auditableResourcesTypes = map[any]map[string]Action{
"description": ActionTrack,
"icon": ActionTrack,
"default_ttl": ActionTrack,
"max_ttl": ActionTrack,
"restart_requirement_days_of_week": ActionTrack,
"restart_requirement_weeks": ActionTrack,
"created_by": ActionTrack,
"created_by_username": ActionIgnore,
"created_by_avatar_url": ActionIgnore,
@@ -78,7 +81,6 @@ var auditableResourcesTypes = map[any]map[string]Action{
"allow_user_autostart": ActionTrack,
"allow_user_autostop": ActionTrack,
"allow_user_cancel_workspace_jobs": ActionTrack,
"max_ttl": ActionTrack,
"failure_ttl": ActionTrack,
"inactivity_ttl": ActionTrack,
"locked_ttl": ActionTrack,
@@ -97,18 +99,19 @@ var auditableResourcesTypes = map[any]map[string]Action{
"git_auth_providers": ActionIgnore, // Not helpful because this can only change when new versions are added.
},
&database.User{}: {
"id": ActionTrack,
"email": ActionTrack,
"username": ActionTrack,
"hashed_password": ActionSecret, // Do not expose a users hashed password.
"created_at": ActionIgnore, // Never changes.
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
"status": ActionTrack,
"rbac_roles": ActionTrack,
"login_type": ActionTrack,
"avatar_url": ActionIgnore,
"last_seen_at": ActionIgnore,
"deleted": ActionTrack,
"id": ActionTrack,
"email": ActionTrack,
"username": ActionTrack,
"hashed_password": ActionSecret, // Do not expose a users hashed password.
"created_at": ActionIgnore, // Never changes.
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
"status": ActionTrack,
"rbac_roles": ActionTrack,
"login_type": ActionTrack,
"avatar_url": ActionIgnore,
"last_seen_at": ActionIgnore,
"deleted": ActionTrack,
"quiet_hours_schedule": ActionTrack,
},
&database.Workspace{}: {
"id": ActionTrack,
+9 -8
View File
@@ -57,14 +57,15 @@ func (r *RootCmd) server() *clibase.Cmd {
options.TrialGenerator = trialer.New(options.Database, "https://v2-licensor.coder.com/trial", coderd.Keys)
o := &coderd.Options{
AuditLogging: true,
BrowserOnly: options.DeploymentValues.BrowserOnly.Value(),
SCIMAPIKey: []byte(options.DeploymentValues.SCIMAPIKey.Value()),
RBAC: true,
DERPServerRelayAddress: options.DeploymentValues.DERP.Server.RelayURL.String(),
DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()),
Options: options,
ProxyHealthInterval: options.DeploymentValues.ProxyHealthStatusInterval.Value(),
Options: options,
AuditLogging: true,
BrowserOnly: options.DeploymentValues.BrowserOnly.Value(),
SCIMAPIKey: []byte(options.DeploymentValues.SCIMAPIKey.Value()),
RBAC: true,
DERPServerRelayAddress: options.DeploymentValues.DERP.Server.RelayURL.String(),
DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()),
ProxyHealthInterval: options.DeploymentValues.ProxyHealthStatusInterval.Value(),
DefaultQuietHoursSchedule: options.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(),
}
api, err := coderd.New(ctx, o)
+14
View File
@@ -377,6 +377,20 @@ telemetrywhen required by your organization's security policy.
anonymized application tracing to help improve our product. Disabling
telemetry also disables this option.
User Quiet Hours Schedule Options
Allow users to set quiet hours schedules each day for workspaces to avoid
workspaces stopping during the day due to template max TTL.
--default-quiet-hours-schedule string, $CODER_QUIET_HOURS_DEFAULT_SCHEDULE
The default daily cron schedule applied to users that haven't set a
custom quiet hours schedule themselves. The quiet hours schedule
determines when workspaces will be force stopped due to the template's
max TTL, and will round the max TTL up to be within the user's quiet
hours window (or default). The format is the same as the standard cron
format, but the day-of-month, month and day-of-week must be *. Only
one hour and minute can be specified (ranges or comma separated values
are not supported).
⚠️ Dangerous Options
--dangerous-allow-path-app-sharing bool, $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING
Allow workspace apps that are not served from subdomains to be shared.
+67 -6
View File
@@ -22,10 +22,11 @@ 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"
agplschedule "github.com/coder/coder/coderd/schedule"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd/license"
"github.com/coder/coder/enterprise/coderd/proxyhealth"
"github.com/coder/coder/enterprise/coderd/schedule"
"github.com/coder/coder/enterprise/derpmesh"
"github.com/coder/coder/enterprise/replicasync"
"github.com/coder/coder/enterprise/tailnet"
@@ -52,6 +53,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
if options.Options.Authorizer == nil {
options.Options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry)
}
ctx, cancelFunc := context.WithCancel(ctx)
api := &API{
ctx: ctx,
@@ -240,6 +242,16 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Put("/", api.putAppearance)
})
})
r.Route("/users/{user}/quiet-hours", func(r chi.Router) {
r.Use(
api.restartRequirementEnabledMW,
apiKeyMiddleware,
httpmw.ExtractUserParam(options.Database, false),
)
r.Get("/", api.userQuietHoursSchedule)
r.Put("/", api.putUserQuietHoursSchedule)
})
})
if len(options.SCIMAPIKey) != 0 {
@@ -334,6 +346,9 @@ type Options struct {
DERPServerRelayAddress string
DERPServerRegionID int
// Used for user quiet hours schedules.
DefaultQuietHoursSchedule string // cron schedule, if empty user quiet hours schedules are disabled
EntitlementsUpdateInterval time.Duration
ProxyHealthInterval time.Duration
Keys map[string]ed25519.PublicKey
@@ -386,6 +401,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
codersdk.FeatureTemplateRBAC: api.RBAC,
codersdk.FeatureExternalProvisionerDaemons: true,
codersdk.FeatureAdvancedTemplateScheduling: true,
// FeatureTemplateRestartRequirement depends on
// FeatureAdvancedTemplateScheduling.
codersdk.FeatureTemplateRestartRequirement: api.DefaultQuietHoursSchedule != "",
codersdk.FeatureWorkspaceProxy: true,
})
if err != nil {
@@ -405,6 +423,18 @@ func (api *API) updateEntitlements(ctx context.Context) error {
return nil
}
if entitlements.Features[codersdk.FeatureTemplateRestartRequirement].Enabled && !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled {
api.entitlements.Errors = []string{
`Your license is entitled to the feature "template restart ` +
`requirement" (and you have it enabled by setting the ` +
"default quiet hours schedule), but you are not entitled to " +
`the dependency feature "advanced template scheduling". ` +
"Please contact support for a new license.",
}
api.Logger.Error(ctx, "license is entitled to template restart requirement but not advanced template scheduling")
return nil
}
featureChanged := func(featureName codersdk.FeatureName) (initial, changed, enabled bool) {
if api.entitlements.Features == nil {
return true, false, entitlements.Features[featureName].Enabled
@@ -450,12 +480,43 @@ func (api *API) updateEntitlements(ctx context.Context) error {
if initial, changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); shouldUpdate(initial, changed, enabled) {
if enabled {
store := &EnterpriseTemplateScheduleStore{}
ptr := schedule.TemplateScheduleStore(store)
api.AGPL.TemplateScheduleStore.Store(&ptr)
templateStore := schedule.NewEnterpriseTemplateScheduleStore()
templateStoreInterface := agplschedule.TemplateScheduleStore(templateStore)
api.AGPL.TemplateScheduleStore.Store(&templateStoreInterface)
} else {
store := schedule.NewAGPLTemplateScheduleStore()
api.AGPL.TemplateScheduleStore.Store(&store)
templateStore := agplschedule.NewAGPLTemplateScheduleStore()
api.AGPL.TemplateScheduleStore.Store(&templateStore)
}
}
if initial, changed, enabled := featureChanged(codersdk.FeatureTemplateRestartRequirement); shouldUpdate(initial, changed, enabled) {
if enabled {
templateStore := *(api.AGPL.TemplateScheduleStore.Load())
enterpriseTemplateStore, ok := templateStore.(*schedule.EnterpriseTemplateScheduleStore)
if !ok {
api.Logger.Error(ctx, "unable to set up enterprise template schedule store, template restart requirements will not be applied to workspace builds")
}
enterpriseTemplateStore.UseRestartRequirement.Store(true)
quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule)
if err != nil {
api.Logger.Error(ctx, "unable to set up enterprise user quiet hours schedule store, template restart requirements will not be applied to workspace builds", slog.Error(err))
} else {
api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore)
}
} else {
if api.DefaultQuietHoursSchedule != "" {
api.Logger.Warn(ctx, "template restart requirements are not enabled (due to setting default quiet hours schedule) as your license is not entitled to this feature")
}
templateStore := *(api.AGPL.TemplateScheduleStore.Load())
enterpriseTemplateStore, ok := templateStore.(*schedule.EnterpriseTemplateScheduleStore)
if ok {
enterpriseTemplateStore.UseRestartRequirement.Store(false)
}
quietHoursStore := agplschedule.NewAGPLUserQuietHoursScheduleStore()
api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore)
}
}
@@ -45,15 +45,16 @@ func init() {
type Options struct {
*coderdtest.Options
AuditLogging bool
BrowserOnly bool
EntitlementsUpdateInterval time.Duration
SCIMAPIKey []byte
UserWorkspaceQuota int
ProxyHealthInterval time.Duration
LicenseOptions *LicenseOptions
DontAddLicense bool
DontAddFirstUser bool
AuditLogging bool
BrowserOnly bool
EntitlementsUpdateInterval time.Duration
SCIMAPIKey []byte
UserWorkspaceQuota int
ProxyHealthInterval time.Duration
LicenseOptions *LicenseOptions
NoDefaultQuietHoursSchedule bool
DontAddLicense bool
DontAddFirstUser bool
}
// New constructs a codersdk client connected to an in-memory Enterprise API instance.
@@ -75,6 +76,10 @@ func NewWithAPI(t *testing.T, options *Options) (
}
require.False(t, options.DontAddFirstUser && !options.DontAddLicense, "DontAddFirstUser requires DontAddLicense")
setHandler, cancelFunc, serverURL, oop := coderdtest.NewOptions(t, options.Options)
if !options.NoDefaultQuietHoursSchedule && oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value() == "" {
err := oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Set("0 0 * * *")
require.NoError(t, err)
}
coderAPI, err := coderd.New(context.Background(), &coderd.Options{
RBAC: true,
AuditLogging: options.AuditLogging,
@@ -86,6 +91,7 @@ func NewWithAPI(t *testing.T, options *Options) (
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
Keys: Keys,
ProxyHealthInterval: options.ProxyHealthInterval,
DefaultQuietHoursSchedule: oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(),
})
require.NoError(t, err)
setHandler(coderAPI.AGPL.RootHandler)
+15 -100
View File
@@ -10,7 +10,6 @@ import (
"net"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/hashicorp/yamux"
@@ -28,7 +27,6 @@ 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"
)
@@ -219,20 +217,21 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
}
mux := drpcmux.New()
err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{
AccessURL: api.AccessURL,
GitAuthConfigs: api.GitAuthConfigs,
OIDCConfig: api.OIDCConfig,
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,
Tracer: trace.NewNoopTracerProvider().Tracer("noop"),
DeploymentValues: api.DeploymentValues,
AccessURL: api.AccessURL,
GitAuthConfigs: api.GitAuthConfigs,
OIDCConfig: api.OIDCConfig,
ID: daemon.ID,
Database: api.Database,
Pubsub: api.Pubsub,
Provisioners: daemon.Provisioners,
Telemetry: api.Telemetry,
Auditor: &api.AGPL.Auditor,
TemplateScheduleStore: api.AGPL.TemplateScheduleStore,
UserQuietHoursScheduleStore: api.AGPL.UserQuietHoursScheduleStore,
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
Tags: rawTags,
Tracer: trace.NewNoopTracerProvider().Tracer("noop"),
DeploymentValues: api.DeploymentValues,
})
if err != nil {
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("drpc register provisioner daemon: %s", err))
@@ -309,87 +308,3 @@ 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{
UserAutostartEnabled: tpl.AllowUserAutostart,
UserAutostopEnabled: tpl.AllowUserAutostop,
DefaultTTL: time.Duration(tpl.DefaultTTL),
MaxTTL: time.Duration(tpl.MaxTTL),
FailureTTL: time.Duration(tpl.FailureTTL),
InactivityTTL: time.Duration(tpl.InactivityTTL),
LockedTTL: time.Duration(tpl.LockedTTL),
}, nil
}
func (*EnterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) {
if int64(opts.DefaultTTL) == tpl.DefaultTTL &&
int64(opts.MaxTTL) == tpl.MaxTTL &&
int64(opts.FailureTTL) == tpl.FailureTTL &&
int64(opts.InactivityTTL) == tpl.InactivityTTL &&
int64(opts.LockedTTL) == tpl.LockedTTL &&
opts.UserAutostartEnabled == tpl.AllowUserAutostart &&
opts.UserAutostopEnabled == tpl.AllowUserAutostop {
// Avoid updating the UpdatedAt timestamp if nothing will be changed.
return tpl, nil
}
var template database.Template
err := db.InTx(func(db database.Store) error {
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: tpl.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: opts.UserAutostartEnabled,
AllowUserAutostop: opts.UserAutostopEnabled,
DefaultTTL: int64(opts.DefaultTTL),
MaxTTL: int64(opts.MaxTTL),
FailureTTL: int64(opts.FailureTTL),
InactivityTTL: int64(opts.InactivityTTL),
LockedTTL: int64(opts.LockedTTL),
})
if err != nil {
return 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: tpl.ID,
TemplateMaxTTL: int64(opts.MaxTTL),
})
if err != nil {
return xerrors.Errorf("update TTL of all workspaces on template to be within new template max TTL: %w", err)
}
}
template, err = db.GetTemplateByID(ctx, tpl.ID)
if err != nil {
return xerrors.Errorf("get updated template schedule: %w", err)
}
return nil
}, nil)
if err != nil {
return database.Template{}, err
}
return template, nil
}
+120
View File
@@ -0,0 +1,120 @@
package schedule
import (
"context"
"sync/atomic"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
agpl "github.com/coder/coder/coderd/schedule"
)
// EnterpriseTemplateScheduleStore provides an agpl.TemplateScheduleStore that
// has all fields implemented for enterprise customers.
type EnterpriseTemplateScheduleStore struct {
// UseRestartRequirement decides whether the RestartRequirement field should
// be used instead of the MaxTTL field for determining the max deadline of a
// workspace build. This value is determined by a feature flag, licensing,
// and whether a default user quiet hours schedule is set.
UseRestartRequirement atomic.Bool
}
var _ agpl.TemplateScheduleStore = &EnterpriseTemplateScheduleStore{}
func NewEnterpriseTemplateScheduleStore() *EnterpriseTemplateScheduleStore {
return &EnterpriseTemplateScheduleStore{}
}
// Get implements agpl.TemplateScheduleStore.
func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (agpl.TemplateScheduleOptions, error) {
tpl, err := db.GetTemplateByID(ctx, templateID)
if err != nil {
return agpl.TemplateScheduleOptions{}, err
}
// These extra checks have to be done before the conversion because we lose
// precision and signs when converting to the agpl types from the database.
if tpl.RestartRequirementDaysOfWeek < 0 {
return agpl.TemplateScheduleOptions{}, xerrors.New("invalid restart requirement days, negative")
}
if tpl.RestartRequirementDaysOfWeek > 0b11111111 {
return agpl.TemplateScheduleOptions{}, xerrors.New("invalid restart requirement days, too large")
}
err = agpl.VerifyTemplateRestartRequirement(uint8(tpl.RestartRequirementDaysOfWeek), tpl.RestartRequirementWeeks)
if err != nil {
return agpl.TemplateScheduleOptions{}, err
}
return agpl.TemplateScheduleOptions{
UserAutostartEnabled: tpl.AllowUserAutostart,
UserAutostopEnabled: tpl.AllowUserAutostop,
DefaultTTL: time.Duration(tpl.DefaultTTL),
MaxTTL: time.Duration(tpl.MaxTTL),
UseRestartRequirement: s.UseRestartRequirement.Load(),
RestartRequirement: agpl.TemplateRestartRequirement{
DaysOfWeek: uint8(tpl.RestartRequirementDaysOfWeek),
Weeks: tpl.RestartRequirementWeeks,
},
FailureTTL: time.Duration(tpl.FailureTTL),
InactivityTTL: time.Duration(tpl.InactivityTTL),
LockedTTL: time.Duration(tpl.LockedTTL),
}, nil
}
// Set implements agpl.TemplateScheduleStore.
func (*EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts agpl.TemplateScheduleOptions) (database.Template, error) {
if int64(opts.DefaultTTL) == tpl.DefaultTTL &&
int64(opts.MaxTTL) == tpl.MaxTTL &&
int16(opts.RestartRequirement.DaysOfWeek) == tpl.RestartRequirementDaysOfWeek &&
opts.RestartRequirement.Weeks == tpl.RestartRequirementWeeks &&
int64(opts.FailureTTL) == tpl.FailureTTL &&
int64(opts.InactivityTTL) == tpl.InactivityTTL &&
int64(opts.LockedTTL) == tpl.LockedTTL &&
opts.UserAutostartEnabled == tpl.AllowUserAutostart &&
opts.UserAutostopEnabled == tpl.AllowUserAutostop {
// Avoid updating the UpdatedAt timestamp if nothing will be changed.
return tpl, nil
}
err := agpl.VerifyTemplateRestartRequirement(opts.RestartRequirement.DaysOfWeek, opts.RestartRequirement.Weeks)
if err != nil {
return database.Template{}, err
}
var template database.Template
err = db.InTx(func(db database.Store) error {
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: tpl.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: opts.UserAutostartEnabled,
AllowUserAutostop: opts.UserAutostopEnabled,
DefaultTTL: int64(opts.DefaultTTL),
MaxTTL: int64(opts.MaxTTL),
RestartRequirementDaysOfWeek: int16(opts.RestartRequirement.DaysOfWeek),
RestartRequirementWeeks: opts.RestartRequirement.Weeks,
FailureTTL: int64(opts.FailureTTL),
InactivityTTL: int64(opts.InactivityTTL),
LockedTTL: int64(opts.LockedTTL),
})
if err != nil {
return xerrors.Errorf("update template schedule: %w", err)
}
// TODO: update all workspace max_deadlines to be within new bounds
template, err = db.GetTemplateByID(ctx, tpl.ID)
if err != nil {
return xerrors.Errorf("get updated template schedule: %w", err)
}
return nil
}, nil)
if err != nil {
return database.Template{}, err
}
return template, nil
}
+98
View File
@@ -0,0 +1,98 @@
package schedule
import (
"context"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
agpl "github.com/coder/coder/coderd/schedule"
)
// enterpriseUserQuietHoursScheduleStore provides an
// agpl.UserQuietHoursScheduleStore that has all fields implemented for
// enterprise customers.
type enterpriseUserQuietHoursScheduleStore struct {
defaultSchedule string
}
var _ agpl.UserQuietHoursScheduleStore = &enterpriseUserQuietHoursScheduleStore{}
func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string) (agpl.UserQuietHoursScheduleStore, error) {
if defaultSchedule == "" {
return nil, xerrors.Errorf("default schedule must be set")
}
s := &enterpriseUserQuietHoursScheduleStore{
defaultSchedule: defaultSchedule,
}
_, err := s.parseSchedule(defaultSchedule)
if err != nil {
return nil, xerrors.Errorf("parse default schedule: %w", err)
}
return s, nil
}
func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) {
userSet := true
if strings.TrimSpace(rawSchedule) == "" {
userSet = false
rawSchedule = s.defaultSchedule
}
sched, err := agpl.Daily(rawSchedule)
if err != nil {
// This shouldn't get hit during Gets, only Sets.
return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("parse daily schedule %q: %w", rawSchedule, err)
}
if strings.HasPrefix(sched.Time(), "cron(") {
// Times starting with "cron(" mean it isn't a single time and probably
// a range or a list of times as a cron expression. We only support
// single times for user quiet hours schedules.
// This shouldn't get hit during Gets, only Sets.
return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("daily schedule %q has more than one time: %v", rawSchedule, sched.Time())
}
return agpl.UserQuietHoursScheduleOptions{
Schedule: sched,
UserSet: userSet,
}, nil
}
func (s *enterpriseUserQuietHoursScheduleStore) Get(ctx context.Context, db database.Store, userID uuid.UUID) (agpl.UserQuietHoursScheduleOptions, error) {
user, err := db.GetUserByID(ctx, userID)
if err != nil {
return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("get user by ID: %w", err)
}
return s.parseSchedule(user.QuietHoursSchedule)
}
func (s *enterpriseUserQuietHoursScheduleStore) Set(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) {
opts, err := s.parseSchedule(rawSchedule)
if err != nil {
return opts, err
}
// Use the tidy version when storing in the database.
rawSchedule = ""
if opts.UserSet {
rawSchedule = opts.Schedule.String()
}
_, err = db.UpdateUserQuietHoursSchedule(ctx, database.UpdateUserQuietHoursScheduleParams{
ID: userID,
QuietHoursSchedule: rawSchedule,
})
if err != nil {
return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("update user quiet hours schedule: %w", err)
}
// TODO(@dean): update max_deadline for all active builds for this user to clamp to
// the new schedule.
return opts, nil
}
+47 -67
View File
@@ -25,73 +25,7 @@ import (
func TestTemplates(t *testing.T) {
t.Parallel()
t.Run("SetMaxTTL", func(t *testing.T) {
t.Parallel()
client, user := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &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, testutil.WaitLong)
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)
})
// TODO(@dean): remove legacy max_ttl tests
t.Run("CreateUpdateWorkspaceMaxTTL", func(t *testing.T) {
t.Parallel()
client, user := coderdenttest.New(t, &coderdenttest.Options{
@@ -123,6 +57,7 @@ func TestTemplates(t *testing.T) {
}
ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req)
require.NoError(t, err)
require.NotNil(t, ws.TTLMillis)
require.EqualValues(t, exp, *ws.TTLMillis)
// Editing a workspace to have a higher TTL than the template's max
@@ -182,6 +117,7 @@ func TestTemplates(t *testing.T) {
}
ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req)
require.NoError(t, err)
require.NotNil(t, ws.TTLMillis)
require.EqualValues(t, exp, *ws.TTLMillis)
// Editing a workspace to disable the TTL should do nothing
@@ -204,6 +140,50 @@ func TestTemplates(t *testing.T) {
require.EqualValues(t, exp, *ws.TTLMillis)
})
t.Run("SetRestartRequirement", func(t *testing.T) {
t.Parallel()
client, user := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &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.Empty(t, 0, template.RestartRequirement.DaysOfWeek)
require.Zero(t, template.RestartRequirement.Weeks)
// ctx := testutil.Context(t, testutil.WaitLong)
ctx := context.Background()
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(),
RestartRequirement: &codersdk.TemplateRestartRequirement{
DaysOfWeek: []string{"monday", "saturday"},
Weeks: 3,
},
})
require.NoError(t, err)
require.Equal(t, []string{"monday", "saturday"}, updated.RestartRequirement.DaysOfWeek)
require.EqualValues(t, 3, updated.RestartRequirement.Weeks)
template, err = client.Template(ctx, template.ID)
require.NoError(t, err)
require.Equal(t, []string{"monday", "saturday"}, template.RestartRequirement.DaysOfWeek)
require.EqualValues(t, 3, template.RestartRequirement.Weeks)
})
t.Run("CleanupTTLs", func(t *testing.T) {
t.Parallel()
+121
View File
@@ -0,0 +1,121 @@
package coderd
import (
"net/http"
"time"
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
)
func (api *API) restartRequirementEnabledMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// The experiment must be enabled.
if !api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateRestartRequirement) {
httpapi.RouteNotFound(rw)
return
}
// Entitlement must be enabled.
api.entitlementsMu.RLock()
entitled := api.entitlements.Features[codersdk.FeatureTemplateRestartRequirement].Entitlement != codersdk.EntitlementNotEntitled
enabled := api.entitlements.Features[codersdk.FeatureTemplateRestartRequirement].Enabled
api.entitlementsMu.RUnlock()
if !entitled {
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
Message: "Template restart requirement is an Enterprise feature. Contact sales!",
})
return
}
if !enabled {
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
Message: "Template restart requirement feature is not enabled. Please specify a default user quiet hours schedule to use this feature.",
})
return
}
next.ServeHTTP(rw, r)
})
}
// @Summary Get user quiet hours schedule
// @ID get-user-quiet-hours-schedule
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param user path string true "User ID" format(uuid)
// @Success 200 {array} codersdk.UserQuietHoursScheduleResponse
// @Router /users/{user}/quiet-hours [get]
func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
)
opts, err := (*api.UserQuietHoursScheduleStore.Load()).Get(ctx, api.Database, user.ID)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
if opts.Schedule == nil {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{
RawSchedule: opts.Schedule.String(),
UserSet: opts.UserSet,
Time: opts.Schedule.Time(),
Timezone: opts.Schedule.Location().String(),
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
})
}
// @Summary Update user quiet hours schedule
// @ID update-user-quiet-hours-schedule
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Enterprise
// @Param user path string true "User ID" format(uuid)
// @Param request body codersdk.UpdateUserQuietHoursScheduleRequest true "Update schedule request"
// @Success 200 {array} codersdk.UserQuietHoursScheduleResponse
// @Router /users/{user}/quiet-hours [put]
func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
params codersdk.UpdateUserQuietHoursScheduleRequest
aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{
Audit: api.Auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
)
defer commitAudit()
aReq.Old = user
if !httpapi.Read(ctx, rw, r, &params) {
return
}
opts, err := (*api.UserQuietHoursScheduleStore.Load()).Set(ctx, api.Database, user.ID, params.Schedule)
if err != nil {
// TODO(@dean): some of these errors are related to bad syntax, so it
// would be nice to 400 instead
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{
RawSchedule: opts.Schedule.String(),
UserSet: opts.UserSet,
Time: opts.Schedule.Time(),
Timezone: opts.Schedule.Location().String(),
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
})
}
+213
View File
@@ -0,0 +1,213 @@
package coderd_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/schedule"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
"github.com/coder/coder/enterprise/coderd/license"
"github.com/coder/coder/testutil"
)
func TestUserQuietHours(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 0 * * *"
defaultScheduleParsed, err := schedule.Daily(defaultQuietHoursSchedule)
require.NoError(t, err)
nextTime := defaultScheduleParsed.Next(time.Now().In(defaultScheduleParsed.Location()))
if time.Until(nextTime) < time.Hour {
// Use a different default schedule instead, because we want to avoid
// the schedule "ticking over" during this test run.
defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 12 * * *"
defaultScheduleParsed, err = schedule.Daily(defaultQuietHoursSchedule)
require.NoError(t, err)
}
dv := coderdtest.DeploymentValues(t)
dv.UserQuietHoursSchedule.DefaultSchedule.Set(defaultQuietHoursSchedule)
dv.Experiments.Set(string(codersdk.ExperimentTemplateRestartRequirement))
client, user := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
codersdk.FeatureTemplateRestartRequirement: 1,
},
},
})
// Get quiet hours for a user that doesn't have them set.
ctx := testutil.Context(t, testutil.WaitLong)
sched1, err := client.UserQuietHoursSchedule(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, defaultScheduleParsed.String(), sched1.RawSchedule)
require.False(t, sched1.UserSet)
require.Equal(t, defaultScheduleParsed.Time(), sched1.Time)
require.Equal(t, defaultScheduleParsed.Location().String(), sched1.Timezone)
require.WithinDuration(t, defaultScheduleParsed.Next(time.Now()), sched1.Next, 15*time.Second)
// Set their quiet hours.
customQuietHoursSchedule := "CRON_TZ=Australia/Sydney 0 0 * * *"
customScheduleParsed, err := schedule.Daily(customQuietHoursSchedule)
require.NoError(t, err)
nextTime = customScheduleParsed.Next(time.Now().In(customScheduleParsed.Location()))
if time.Until(nextTime) < time.Hour {
// Use a different default schedule instead, because we want to avoid
// the schedule "ticking over" during this test run.
customQuietHoursSchedule = "CRON_TZ=Australia/Sydney 0 12 * * *"
customScheduleParsed, err = schedule.Daily(customQuietHoursSchedule)
require.NoError(t, err)
}
sched2, err := client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{
Schedule: customQuietHoursSchedule,
})
require.NoError(t, err)
require.Equal(t, customScheduleParsed.String(), sched2.RawSchedule)
require.True(t, sched2.UserSet)
require.Equal(t, customScheduleParsed.Time(), sched2.Time)
require.Equal(t, customScheduleParsed.Location().String(), sched2.Timezone)
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched2.Next, 15*time.Second)
// Get quiet hours for a user that has them set.
sched3, err := client.UserQuietHoursSchedule(ctx, user.UserID.String())
require.NoError(t, err)
require.Equal(t, customScheduleParsed.String(), sched3.RawSchedule)
require.True(t, sched3.UserSet)
require.Equal(t, customScheduleParsed.Time(), sched3.Time)
require.Equal(t, customScheduleParsed.Location().String(), sched3.Timezone)
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second)
// Try setting a garbage schedule.
_, err = client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{
Schedule: "garbage",
})
require.Error(t, err)
require.ErrorContains(t, err, "parse daily schedule")
// Try setting a non-daily schedule.
_, err = client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{
Schedule: "CRON_TZ=America/Chicago 0 0 * * 1",
})
require.Error(t, err)
require.ErrorContains(t, err, "parse daily schedule")
// Try setting a schedule with a timezone that doesn't exist.
_, err = client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{
Schedule: "CRON_TZ=Deans/House 0 0 * * *",
})
require.Error(t, err)
require.ErrorContains(t, err, "parse daily schedule")
// Try setting a schedule with more than one time.
_, err = client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{
Schedule: "CRON_TZ=America/Chicago 0 0,12 * * *",
})
require.Error(t, err)
require.ErrorContains(t, err, "more than one time")
_, err = client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{
Schedule: "CRON_TZ=America/Chicago 0-30 0 * * *",
})
require.Error(t, err)
require.ErrorContains(t, err, "more than one time")
// We don't allow unsetting the custom schedule so we don't need to worry
// about it in this test.
})
t.Run("NotEntitled", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *")
dv.Experiments.Set(string(codersdk.ExperimentTemplateRestartRequirement))
client, user := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
// Not entitled.
// codersdk.FeatureTemplateRestartRequirement: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
_, err := client.UserQuietHoursSchedule(ctx, user.UserID.String())
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
})
t.Run("NotEnabled", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.UserQuietHoursSchedule.DefaultSchedule.Set("")
dv.Experiments.Set(string(codersdk.ExperimentTemplateRestartRequirement))
client, user := coderdenttest.New(t, &coderdenttest.Options{
NoDefaultQuietHoursSchedule: true,
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
codersdk.FeatureTemplateRestartRequirement: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
_, err := client.UserQuietHoursSchedule(ctx, user.UserID.String())
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
})
t.Run("NoFeatureFlag", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *")
dv.UserQuietHoursSchedule.DefaultSchedule.Set("")
client, user := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
codersdk.FeatureTemplateRestartRequirement: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
_, err := client.UserQuietHoursSchedule(ctx, user.UserID.String())
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}
+13 -13
View File
@@ -15,12 +15,12 @@ import (
"github.com/coder/coder/coderd/autobuild"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/schedule"
agplschedule "github.com/coder/coder/coderd/schedule"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
"github.com/coder/coder/enterprise/coderd/license"
"github.com/coder/coder/enterprise/coderd/schedule"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/testutil"
)
@@ -101,7 +101,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{},
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -148,7 +148,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{},
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -194,7 +194,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{},
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -236,7 +236,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{},
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -293,7 +293,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{},
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -335,7 +335,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{},
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -377,7 +377,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{},
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -428,7 +428,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{},
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -498,7 +498,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{},
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -559,7 +559,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: tickCh,
IncludeProvisionerDaemon: true,
AutobuildStats: statsCh,
TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{},
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -575,7 +575,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
sched, err := schedule.Weekly("CRON_TZ=UTC 0 * * * *")
sched, err := agplschedule.Weekly("CRON_TZ=UTC 0 * * * *")
require.NoError(t, err)
ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
+33
View File
@@ -181,6 +181,7 @@ export interface CreateTemplateRequest {
readonly template_version_id: string
readonly default_ttl_ms?: number
readonly max_ttl_ms?: number
readonly restart_requirement?: TemplateRestartRequirement
readonly allow_user_cancel_workspace_jobs?: boolean
readonly allow_user_autostart?: boolean
readonly allow_user_autostop?: boolean
@@ -384,6 +385,7 @@ export interface DeploymentValues {
readonly disable_owner_workspace_exec?: boolean
readonly proxy_health_status_interval?: number
readonly enable_terraform_debug_mode?: boolean
readonly user_quiet_hours_schedule?: UserQuietHoursScheduleConfig
// This is likely an enum in an external package ("github.com/coder/coder/cli/clibase.YAMLConfigPath")
readonly config?: string
readonly write_config?: boolean
@@ -867,6 +869,7 @@ export interface Template {
readonly icon: string
readonly default_ttl_ms: number
readonly max_ttl_ms: number
readonly restart_requirement: TemplateRestartRequirement
readonly created_by_id: string
readonly created_by_name: string
readonly allow_user_autostart: boolean
@@ -905,6 +908,12 @@ export interface TemplateGroup extends Group {
readonly role: TemplateRole
}
// From codersdk/templates.go
export interface TemplateRestartRequirement {
readonly days_of_week: string[]
readonly weeks: number
}
// From codersdk/templates.go
export interface TemplateUser extends User {
readonly role: TemplateRole
@@ -1038,6 +1047,7 @@ export interface UpdateTemplateMeta {
readonly icon?: string
readonly default_ttl_ms?: number
readonly max_ttl_ms?: number
readonly restart_requirement?: TemplateRestartRequirement
readonly allow_user_autostart?: boolean
readonly allow_user_autostop?: boolean
readonly allow_user_cancel_workspace_jobs?: boolean
@@ -1057,6 +1067,11 @@ export interface UpdateUserProfileRequest {
readonly username: string
}
// From codersdk/users.go
export interface UpdateUserQuietHoursScheduleRequest {
readonly schedule: string
}
// From codersdk/workspaces.go
export interface UpdateWorkspaceAutostartRequest {
readonly schedule?: string
@@ -1106,6 +1121,20 @@ export interface UserLoginType {
readonly login_type: LoginType
}
// From codersdk/deployment.go
export interface UserQuietHoursScheduleConfig {
readonly default_schedule: string
}
// From codersdk/users.go
export interface UserQuietHoursScheduleResponse {
readonly raw_schedule: string
readonly user_set: boolean
readonly time: string
readonly timezone: string
readonly next: string
}
// From codersdk/users.go
export interface UserRoles {
readonly roles: string[]
@@ -1435,6 +1464,7 @@ export type Experiment =
| "moons"
| "single_tailnet"
| "tailnet_ha_coordinator"
| "template_restart_requirement"
| "workspace_actions"
| "workspace_build_logs_ui"
export const Experiments: Experiment[] = [
@@ -1442,6 +1472,7 @@ export const Experiments: Experiment[] = [
"moons",
"single_tailnet",
"tailnet_ha_coordinator",
"template_restart_requirement",
"workspace_actions",
"workspace_build_logs_ui",
]
@@ -1457,6 +1488,7 @@ export type FeatureName =
| "multiple_git_auth"
| "scim"
| "template_rbac"
| "template_restart_requirement"
| "user_limit"
| "workspace_proxy"
export const FeatureNames: FeatureName[] = [
@@ -1469,6 +1501,7 @@ export const FeatureNames: FeatureName[] = [
"multiple_git_auth",
"scim",
"template_rbac",
"template_restart_requirement",
"user_limit",
"workspace_proxy",
]
@@ -26,6 +26,10 @@ const validFormValues: FormValues = {
allow_user_cancel_workspace_jobs: false,
allow_user_autostart: false,
allow_user_autostop: false,
restart_requirement: {
days_of_week: [],
weeks: 1,
},
failure_ttl_ms: 0,
inactivity_ttl_ms: 0,
locked_ttl_ms: 0,
@@ -71,6 +71,11 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
? template.locked_ttl_ms / MS_DAY_CONVERSION
: 0,
restart_requirement: {
days_of_week: template.restart_requirement.days_of_week,
weeks: template.restart_requirement.weeks,
},
allow_user_autostart: template.allow_user_autostart,
allow_user_autostop: template.allow_user_autostop,
failure_cleanup_enabled:
+4
View File
@@ -424,6 +424,10 @@ 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,
restart_requirement: {
days_of_week: [],
weeks: 1,
},
created_by_id: "test-creator-id",
created_by_name: "test_creator",
icon: "/icon/code.svg",