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:
Yevhenii Shcherbina
2025-06-19 11:08:48 -04:00
committed by GitHub
co-authored by Danny Kopping
parent 511fd09582
commit 0f6ca55238
38 changed files with 2528 additions and 871 deletions
+36
View File
@@ -907,6 +907,7 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s
}
var prebuildInstances int32
var expirationPolicy *proto.ExpirationPolicy
var scheduling *proto.Scheduling
if len(preset.Prebuilds) > 0 {
prebuildInstances = int32(math.Min(math.MaxInt32, float64(preset.Prebuilds[0].Instances)))
if len(preset.Prebuilds[0].ExpirationPolicy) > 0 {
@@ -914,6 +915,9 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s
Ttl: int32(math.Min(math.MaxInt32, float64(preset.Prebuilds[0].ExpirationPolicy[0].TTL))),
}
}
if len(preset.Prebuilds[0].Scheduling) > 0 {
scheduling = convertScheduling(preset.Prebuilds[0].Scheduling[0])
}
}
protoPreset := &proto.Preset{
Name: preset.Name,
@@ -921,6 +925,7 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s
Prebuild: &proto.Prebuild{
Instances: prebuildInstances,
ExpirationPolicy: expirationPolicy,
Scheduling: scheduling,
},
}
@@ -978,6 +983,37 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s
}, nil
}
func convertScheduling(scheduling provider.Scheduling) *proto.Scheduling {
return &proto.Scheduling{
Timezone: scheduling.Timezone,
Schedule: convertSchedules(scheduling.Schedule),
}
}
func convertSchedules(schedules []provider.Schedule) []*proto.Schedule {
protoSchedules := make([]*proto.Schedule, len(schedules))
for i, schedule := range schedules {
protoSchedules[i] = convertSchedule(schedule)
}
return protoSchedules
}
func convertSchedule(schedule provider.Schedule) *proto.Schedule {
return &proto.Schedule{
Cron: schedule.Cron,
Instances: safeInt32Conversion(schedule.Instances),
}
}
func safeInt32Conversion(n int) int32 {
if n > math.MaxInt32 {
return math.MaxInt32
}
// #nosec G115 - Safe conversion, as we have explicitly checked that the number does not exceed math.MaxInt32.
return int32(n)
}
func PtrInt32(number int) *int32 {
// #nosec G115 - Safe conversion as the number is expected to be within int32 range
n := int32(number)
+13
View File
@@ -882,6 +882,19 @@ func TestConvertResources(t *testing.T) {
ExpirationPolicy: &proto.ExpirationPolicy{
Ttl: 86400,
},
Scheduling: &proto.Scheduling{
Timezone: "America/Los_Angeles",
Schedule: []*proto.Schedule{
{
Cron: "* 8-18 * * 1-5",
Instances: 3,
},
{
Cron: "* 8-14 * * 6",
Instances: 1,
},
},
},
},
}},
},
@@ -28,6 +28,17 @@ data "coder_workspace_preset" "MyFirstProject" {
expiration_policy {
ttl = 86400
}
scheduling {
timezone = "America/Los_Angeles"
schedule {
cron = "* 8-18 * * 1-5"
instances = 3
}
schedule {
cron = "* 8-14 * * 6"
instances = 1
}
}
}
}
+50 -2
View File
@@ -173,7 +173,22 @@
"ttl": 86400
}
],
"instances": 4
"instances": 4,
"scheduling": [
{
"schedule": [
{
"cron": "* 8-18 * * 1-5",
"instances": 3
},
{
"cron": "* 8-14 * * 6",
"instances": 1
}
],
"timezone": "America/Los_Angeles"
}
]
}
]
},
@@ -183,6 +198,14 @@
{
"expiration_policy": [
{}
],
"scheduling": [
{
"schedule": [
{},
{}
]
}
]
}
]
@@ -418,7 +441,32 @@
],
"instances": {
"constant_value": 4
}
},
"scheduling": [
{
"schedule": [
{
"cron": {
"constant_value": "* 8-18 * * 1-5"
},
"instances": {
"constant_value": 3
}
},
{
"cron": {
"constant_value": "* 8-14 * * 6"
},
"instances": {
"constant_value": 1
}
}
],
"timezone": {
"constant_value": "America/Los_Angeles"
}
}
]
}
]
},
+24 -1
View File
@@ -53,7 +53,22 @@
"ttl": 86400
}
],
"instances": 4
"instances": 4,
"scheduling": [
{
"schedule": [
{
"cron": "* 8-18 * * 1-5",
"instances": 3
},
{
"cron": "* 8-14 * * 6",
"instances": 1
}
],
"timezone": "America/Los_Angeles"
}
]
}
]
},
@@ -63,6 +78,14 @@
{
"expiration_policy": [
{}
],
"scheduling": [
{
"schedule": [
{},
{}
]
}
]
}
]
+1
View File
@@ -0,0 +1 @@
1.11.4