mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
feat: implement scheduling mechanism for prebuilds (#18126)
Closes https://github.com/coder/internal/issues/312 Depends on https://github.com/coder/terraform-provider-coder/pull/408 This PR adds support for defining an **autoscaling block** for prebuilds, allowing number of desired instances to scale dynamically based on a schedule. Example usage: ``` data "coder_workspace_preset" "us-nix" { ... prebuilds = { instances = 0 # default to 0 instances scheduling = { timezone = "UTC" # a single timezone is used for simplicity # Scale to 3 instances during the work week schedule { cron = "* 8-18 * * 1-5" # from 8AM–6:59PM, Mon–Fri, UTC instances = 3 # scale to 3 instances } # Scale to 1 instance on Saturdays for urgent support queries schedule { cron = "* 8-14 * * 6" # from 8AM–2:59PM, Sat, UTC instances = 1 # scale to 1 instance } } } } ``` ### Behavior - Multiple `schedule` blocks per `prebuilds` block are supported. - If the current time matches any defined autoscaling schedule, the corresponding number of instances is used. - If no schedule matches, the **default instance count** (`prebuilds.instances`) is used as a fallback. ### Why This feature allows prebuild instance capacity to adapt to predictable usage patterns, such as: - Scaling up during business hours or high-demand periods - Reducing capacity during off-hours to save resources ### Cron specification The cron specification is interpreted as a **continuous time range.** For example, the expression: ``` * 9-18 * * 1-5 ``` is intended to represent a continuous range from **09:00 to 18:59**, Monday through Friday. However, due to minor implementation imprecision, it is currently interpreted as a range from **08:59:00 to 18:58:59**, Monday through Friday. This slight discrepancy arises because the evaluation is based on whether a specific **point in time** falls within the range, using the `github.com/coder/coder/v2/coderd/schedule/cron` library, which performs per-minute matching rather than strict range evaluation. --------- Co-authored-by: Danny Kopping <danny@coder.com>
This commit is contained in:
committed by
GitHub
parent
511fd09582
commit
0f6ca55238
@ -163,6 +163,120 @@ func Test_Weekly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWithinRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
at time.Time
|
||||
expectedWithinRange bool
|
||||
expectedError string
|
||||
}{
|
||||
// "* 9-18 * * 1-5" should be interpreted as a continuous time range from 09:00:00 to 18:59:59, Monday through Friday
|
||||
{
|
||||
name: "Right before the start of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 8:59:59 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "Start of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 9:00:00 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "9:01 AM - One minute after the start of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 9:01:00 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "2PM - The middle of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 14:00:00 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "6PM - One hour before the end of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 18:00:00 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "End of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 18:59:59 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "Right after the end of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 19:00:00 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "7:01PM - One minute after the end of the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 19:01:00 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "2AM - Significantly outside the time range",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Mon, 02 Jun 2025 02:00:00 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "Outside the day range #1",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Sat, 07 Jun 2025 14:00:00 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "Outside the day range #2",
|
||||
spec: "* 9-18 * * 1-5",
|
||||
at: mustParseTime(t, time.RFC1123, "Sun, 08 Jun 2025 14:00:00 UTC"),
|
||||
expectedWithinRange: false,
|
||||
},
|
||||
{
|
||||
name: "Check that Sunday is supported with value 0",
|
||||
spec: "* 9-18 * * 0",
|
||||
at: mustParseTime(t, time.RFC1123, "Sun, 08 Jun 2025 14:00:00 UTC"),
|
||||
expectedWithinRange: true,
|
||||
},
|
||||
{
|
||||
name: "Check that value 7 is rejected as out of range",
|
||||
spec: "* 9-18 * * 7",
|
||||
at: mustParseTime(t, time.RFC1123, "Sun, 08 Jun 2025 14:00:00 UTC"),
|
||||
expectedError: "end of range (7) above maximum (6): 7",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sched, err := cron.Weekly(testCase.spec)
|
||||
if testCase.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), testCase.expectedError)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
withinRange := sched.IsWithinRange(testCase.at)
|
||||
require.Equal(t, testCase.expectedWithinRange, withinRange)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseTime(t *testing.T, layout, value string) time.Time {
|
||||
t.Helper()
|
||||
parsedTime, err := time.Parse(layout, value)
|
||||
require.NoError(t, err)
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func mustLocation(t *testing.T, s string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation(s)
|
||||
|
Reference in New Issue
Block a user