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

This commit is contained in:
Dean Sheather
2023-07-20 23:35:41 +10: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: ""