11package schedule_test
22
33import (
4+ "context"
45"database/sql"
56"encoding/json"
67"fmt"
@@ -17,14 +18,18 @@ import (
1718
1819"github.com/coder/coder/v2/coderd/database"
1920"github.com/coder/coder/v2/coderd/database/dbauthz"
21+ "github.com/coder/coder/v2/coderd/database/dbfake"
2022"github.com/coder/coder/v2/coderd/database/dbgen"
2123"github.com/coder/coder/v2/coderd/database/dbtestutil"
2224"github.com/coder/coder/v2/coderd/database/dbtime"
2325"github.com/coder/coder/v2/coderd/notifications"
2426"github.com/coder/coder/v2/coderd/notifications/notificationstest"
2527agplschedule"github.com/coder/coder/v2/coderd/schedule"
28+ "github.com/coder/coder/v2/coderd/schedule/cron"
29+ "github.com/coder/coder/v2/codersdk"
2630"github.com/coder/coder/v2/cryptorand"
2731"github.com/coder/coder/v2/enterprise/coderd/schedule"
32+ "github.com/coder/coder/v2/provisionersdk/proto"
2833"github.com/coder/coder/v2/testutil"
2934"github.com/coder/quartz"
3035)
@@ -979,6 +984,252 @@ func TestTemplateTTL(t *testing.T) {
979984})
980985}
981986
987+ func TestTemplateUpdatePrebuilds (t * testing.T ) {
988+ t .Parallel ()
989+
990+ // Dormant auto-delete configured to 10 hours
991+ dormantAutoDelete := 10 * time .Hour
992+
993+ // TTL configured to 8 hours
994+ ttl := 8 * time .Hour
995+
996+ // Autostop configuration set to everyday at midnight
997+ autostopWeekdays ,err := codersdk .WeekdaysToBitmap (codersdk .AllDaysOfWeek )
998+ require .NoError (t ,err )
999+
1000+ // Autostart configuration set to everyday at midnight
1001+ autostartSchedule ,err := cron .Weekly ("CRON_TZ=UTC 0 0 * * *" )
1002+ require .NoError (t ,err )
1003+ autostartWeekdays ,err := codersdk .WeekdaysToBitmap (codersdk .AllDaysOfWeek )
1004+ require .NoError (t ,err )
1005+
1006+ cases := []struct {
1007+ name string
1008+ templateSchedule agplschedule.TemplateScheduleOptions
1009+ workspaceUpdate func (* testing.T , context.Context , database.Store , time.Time , database.ClaimPrebuiltWorkspaceRow )
1010+ assertWorkspace func (* testing.T , context.Context , database.Store , time.Time ,bool , database.Workspace )
1011+ }{
1012+ {
1013+ name :"TemplateDormantAutoDeleteUpdatePrebuildAfterClaim" ,
1014+ templateSchedule : agplschedule.TemplateScheduleOptions {
1015+ // Template level TimeTilDormantAutodelete set to 10 hours
1016+ TimeTilDormantAutoDelete :dormantAutoDelete ,
1017+ },
1018+ workspaceUpdate :func (t * testing.T ,ctx context.Context ,db database.Store ,now time.Time ,
1019+ workspace database.ClaimPrebuiltWorkspaceRow ,
1020+ ) {
1021+ // When: the workspace is marked dormant
1022+ dormantWorkspace ,err := db .UpdateWorkspaceDormantDeletingAt (ctx , database.UpdateWorkspaceDormantDeletingAtParams {
1023+ ID :workspace .ID ,
1024+ DormantAt : sql.NullTime {
1025+ Time :now ,
1026+ Valid :true ,
1027+ },
1028+ })
1029+ require .NoError (t ,err )
1030+ require .NotNil (t ,dormantWorkspace .DormantAt )
1031+ },
1032+ assertWorkspace :func (t * testing.T ,ctx context.Context ,db database.Store ,now time.Time ,
1033+ isPrebuild bool ,workspace database.Workspace ,
1034+ ) {
1035+ if isPrebuild {
1036+ // The unclaimed prebuild should have an empty DormantAt and DeletingAt
1037+ require .True (t ,workspace .DormantAt .Time .IsZero ())
1038+ require .True (t ,workspace .DeletingAt .Time .IsZero ())
1039+ }else {
1040+ // The claimed workspace should have its DormantAt and DeletingAt updated
1041+ require .False (t ,workspace .DormantAt .Time .IsZero ())
1042+ require .False (t ,workspace .DeletingAt .Time .IsZero ())
1043+ require .WithinDuration (t ,now .UTC (),workspace .DormantAt .Time .UTC (),time .Second )
1044+ require .WithinDuration (t ,now .Add (dormantAutoDelete ).UTC (),workspace .DeletingAt .Time .UTC (),time .Second )
1045+ }
1046+ },
1047+ },
1048+ {
1049+ name :"TemplateTTLUpdatePrebuildAfterClaim" ,
1050+ templateSchedule : agplschedule.TemplateScheduleOptions {
1051+ // Template level TTL can only be set if autostop is disabled for users
1052+ DefaultTTL :ttl ,
1053+ UserAutostopEnabled :false ,
1054+ },
1055+ workspaceUpdate :func (t * testing.T ,ctx context.Context ,db database.Store ,now time.Time ,
1056+ workspace database.ClaimPrebuiltWorkspaceRow ) {
1057+ },
1058+ assertWorkspace :func (t * testing.T ,ctx context.Context ,db database.Store ,now time.Time ,
1059+ isPrebuild bool ,workspace database.Workspace ,
1060+ ) {
1061+ if isPrebuild {
1062+ // The unclaimed prebuild should have an empty TTL
1063+ require .Equal (t , sql.NullInt64 {},workspace .Ttl )
1064+ }else {
1065+ // The claimed workspace should have its TTL updated
1066+ require .Equal (t , sql.NullInt64 {Int64 :int64 (ttl ),Valid :true },workspace .Ttl )
1067+ }
1068+ },
1069+ },
1070+ {
1071+ name :"TemplateAutostopUpdatePrebuildAfterClaim" ,
1072+ templateSchedule : agplschedule.TemplateScheduleOptions {
1073+ // Template level Autostop set for everyday
1074+ AutostopRequirement : agplschedule.TemplateAutostopRequirement {
1075+ DaysOfWeek :autostopWeekdays ,
1076+ Weeks :0 ,
1077+ },
1078+ },
1079+ workspaceUpdate :func (t * testing.T ,ctx context.Context ,db database.Store ,now time.Time ,
1080+ workspace database.ClaimPrebuiltWorkspaceRow ) {
1081+ },
1082+ assertWorkspace :func (t * testing.T ,ctx context.Context ,db database.Store ,now time.Time ,isPrebuild bool ,workspace database.Workspace ) {
1083+ if isPrebuild {
1084+ // The unclaimed prebuild should have an empty MaxDeadline
1085+ prebuildBuild ,err := db .GetLatestWorkspaceBuildByWorkspaceID (ctx ,workspace .ID )
1086+ require .NoError (t ,err )
1087+ require .True (t ,prebuildBuild .MaxDeadline .IsZero ())
1088+ }else {
1089+ // The claimed workspace should have its MaxDeadline updated
1090+ workspaceBuild ,err := db .GetLatestWorkspaceBuildByWorkspaceID (ctx ,workspace .ID )
1091+ require .NoError (t ,err )
1092+ require .False (t ,workspaceBuild .MaxDeadline .IsZero ())
1093+ }
1094+ },
1095+ },
1096+ {
1097+ name :"TemplateAutostartUpdatePrebuildAfterClaim" ,
1098+ templateSchedule : agplschedule.TemplateScheduleOptions {
1099+ // Template level Autostart set for everyday
1100+ UserAutostartEnabled :true ,
1101+ AutostartRequirement : agplschedule.TemplateAutostartRequirement {
1102+ DaysOfWeek :autostartWeekdays ,
1103+ },
1104+ },
1105+ workspaceUpdate :func (t * testing.T ,ctx context.Context ,db database.Store ,now time.Time ,workspace database.ClaimPrebuiltWorkspaceRow ) {
1106+ // To compute NextStartAt, the workspace must have a valid autostart schedule
1107+ err = db .UpdateWorkspaceAutostart (ctx , database.UpdateWorkspaceAutostartParams {
1108+ ID :workspace .ID ,
1109+ AutostartSchedule : sql.NullString {
1110+ String :autostartSchedule .String (),
1111+ Valid :true ,
1112+ },
1113+ })
1114+ require .NoError (t ,err )
1115+ },
1116+ assertWorkspace :func (t * testing.T ,ctx context.Context ,db database.Store ,now time.Time ,isPrebuild bool ,workspace database.Workspace ) {
1117+ if isPrebuild {
1118+ // The unclaimed prebuild should have an empty NextStartAt
1119+ require .True (t ,workspace .NextStartAt .Time .IsZero ())
1120+ }else {
1121+ // The claimed workspace should have its NextStartAt updated
1122+ require .False (t ,workspace .NextStartAt .Time .IsZero ())
1123+ }
1124+ },
1125+ },
1126+ }
1127+
1128+ for _ ,tc := range cases {
1129+ tc := tc
1130+ t .Run (tc .name ,func (t * testing.T ) {
1131+ t .Parallel ()
1132+
1133+ clock := quartz .NewMock (t )
1134+ clock .Set (dbtime .Now ())
1135+
1136+ // Setup
1137+ var (
1138+ logger = slogtest .Make (t ,& slogtest.Options {IgnoreErrors :true }).Leveled (slog .LevelDebug )
1139+ db ,_ = dbtestutil .NewDB (t ,dbtestutil .WithDumpOnFailure ())
1140+ ctx = testutil .Context (t ,testutil .WaitLong )
1141+ user = dbgen .User (t ,db , database.User {})
1142+ )
1143+
1144+ // Setup the template schedule store
1145+ notifyEnq := notifications .NewNoopEnqueuer ()
1146+ const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
1147+ userQuietHoursStore ,err := schedule .NewEnterpriseUserQuietHoursScheduleStore (userQuietHoursSchedule ,true )
1148+ require .NoError (t ,err )
1149+ userQuietHoursStorePtr := & atomic.Pointer [agplschedule.UserQuietHoursScheduleStore ]{}
1150+ userQuietHoursStorePtr .Store (& userQuietHoursStore )
1151+ templateScheduleStore := schedule .NewEnterpriseTemplateScheduleStore (userQuietHoursStorePtr ,notifyEnq ,logger ,clock )
1152+
1153+ // Given: a template and a template version with preset and a prebuilt workspace
1154+ presetID := uuid .New ()
1155+ org := dbfake .Organization (t ,db ).Do ()
1156+ tv := dbfake .TemplateVersion (t ,db ).Seed (database.TemplateVersion {
1157+ OrganizationID :org .Org .ID ,
1158+ CreatedBy :user .ID ,
1159+ }).Preset (database.TemplateVersionPreset {
1160+ ID :presetID ,
1161+ DesiredInstances : sql.NullInt32 {
1162+ Int32 :1 ,
1163+ Valid :true ,
1164+ },
1165+ }).Do ()
1166+ workspaceBuild := dbfake .WorkspaceBuild (t ,db , database.WorkspaceTable {
1167+ OwnerID :database .PrebuildsSystemUserID ,
1168+ TemplateID :tv .Template .ID ,
1169+ OrganizationID :tv .Template .OrganizationID ,
1170+ }).Seed (database.WorkspaceBuild {
1171+ TemplateVersionID :tv .TemplateVersion .ID ,
1172+ TemplateVersionPresetID : uuid.NullUUID {
1173+ UUID :presetID ,
1174+ Valid :true ,
1175+ },
1176+ }).WithAgent (func (agent []* proto.Agent ) []* proto.Agent {
1177+ return agent
1178+ }).Do ()
1179+
1180+ // Mark the prebuilt workspace's agent as ready so the prebuild can be claimed
1181+ // nolint:gocritic
1182+ agentCtx := dbauthz .AsSystemRestricted (testutil .Context (t ,testutil .WaitLong ))
1183+ agent ,err := db .GetWorkspaceAgentAndLatestBuildByAuthToken (agentCtx ,uuid .MustParse (workspaceBuild .AgentToken ))
1184+ require .NoError (t ,err )
1185+ err = db .UpdateWorkspaceAgentLifecycleStateByID (agentCtx , database.UpdateWorkspaceAgentLifecycleStateByIDParams {
1186+ ID :agent .WorkspaceAgent .ID ,
1187+ LifecycleState :database .WorkspaceAgentLifecycleStateReady ,
1188+ })
1189+ require .NoError (t ,err )
1190+
1191+ // Given: a prebuilt workspace
1192+ prebuild ,err := db .GetWorkspaceByID (ctx ,workspaceBuild .Workspace .ID )
1193+ require .NoError (t ,err )
1194+ tc .assertWorkspace (t ,ctx ,db ,clock .Now (),true ,prebuild )
1195+
1196+ // When: the template schedule is updated
1197+ _ ,err = templateScheduleStore .Set (ctx ,db ,tv .Template ,tc .templateSchedule )
1198+ require .NoError (t ,err )
1199+
1200+ // Then: lifecycle parameters must remain unset while the prebuild is unclaimed
1201+ prebuild ,err = db .GetWorkspaceByID (ctx ,workspaceBuild .Workspace .ID )
1202+ require .NoError (t ,err )
1203+ tc .assertWorkspace (t ,ctx ,db ,clock .Now (),true ,prebuild )
1204+
1205+ // Given: the prebuilt workspace is claimed by a user
1206+ claimedWorkspace := dbgen .ClaimPrebuild (
1207+ t ,db ,
1208+ clock .Now (),
1209+ user .ID ,
1210+ "claimedWorkspace-autostop" ,
1211+ presetID ,
1212+ sql.NullString {},
1213+ sql.NullTime {},
1214+ sql.NullInt64 {})
1215+ require .Equal (t ,prebuild .ID ,claimedWorkspace .ID )
1216+
1217+ // Given: the workspace level configurations are properly set in order to ensure the
1218+ // lifecycle parameters are updated
1219+ tc .workspaceUpdate (t ,ctx ,db ,clock .Now (),claimedWorkspace )
1220+
1221+ // When: the template schedule is updated
1222+ _ ,err = templateScheduleStore .Set (ctx ,db ,tv .Template ,tc .templateSchedule )
1223+ require .NoError (t ,err )
1224+
1225+ // Then: the workspace should have its lifecycle parameters updated
1226+ workspace ,err := db .GetWorkspaceByID (ctx ,claimedWorkspace .ID )
1227+ require .NoError (t ,err )
1228+ tc .assertWorkspace (t ,ctx ,db ,clock .Now (),false ,workspace )
1229+ })
1230+ }
1231+ }
1232+
9821233func must [V any ](v V ,err error )V {
9831234if err != nil {
9841235panic (err )