|
| 1 | +package lifecycle |
| 2 | + |
| 3 | +import ( |
| 4 | +"context" |
| 5 | +"encoding/json" |
| 6 | +"time" |
| 7 | + |
| 8 | +"cdr.dev/slog" |
| 9 | + |
| 10 | +"github.com/coder/coder/coderd/autostart/schedule" |
| 11 | +"github.com/coder/coder/coderd/database" |
| 12 | +"github.com/coder/coder/codersdk" |
| 13 | + |
| 14 | +"github.com/google/uuid" |
| 15 | +"github.com/moby/moby/pkg/namesgenerator" |
| 16 | +"golang.org/x/xerrors" |
| 17 | +) |
| 18 | + |
| 19 | +//var ExecutorUUID = uuid.MustParse("00000000-0000-0000-0000-000000000000") |
| 20 | + |
| 21 | +// Executor executes automated workspace lifecycle operations. |
| 22 | +typeExecutorstruct { |
| 23 | +ctx context.Context |
| 24 | +db database.Store |
| 25 | +log slog.Logger |
| 26 | +tick<-chan time.Time |
| 27 | +} |
| 28 | + |
| 29 | +funcNewExecutor(ctx context.Context,db database.Store,log slog.Logger,tick<-chan time.Time)*Executor { |
| 30 | +le:=&Executor{ |
| 31 | +ctx:ctx, |
| 32 | +db:db, |
| 33 | +tick:tick, |
| 34 | +log:log, |
| 35 | +} |
| 36 | +returnle |
| 37 | +} |
| 38 | + |
| 39 | +func (e*Executor)Run()error { |
| 40 | +for { |
| 41 | +select { |
| 42 | +caset:=<-e.tick: |
| 43 | +iferr:=e.runOnce(t);err!=nil { |
| 44 | +e.log.Error(e.ctx,"error running once",slog.Error(err)) |
| 45 | +} |
| 46 | +case<-e.ctx.Done(): |
| 47 | +returnnil |
| 48 | +default: |
| 49 | +} |
| 50 | +} |
| 51 | +} |
| 52 | + |
| 53 | +func (e*Executor)runOnce(t time.Time)error { |
| 54 | +currentTick:=t.Round(time.Minute) |
| 55 | +returne.db.InTx(func(db database.Store)error { |
| 56 | +allWorkspaces,err:=db.GetWorkspaces(e.ctx) |
| 57 | +iferr!=nil { |
| 58 | +returnxerrors.Errorf("get all workspaces: %w",err) |
| 59 | +} |
| 60 | + |
| 61 | +for_,ws:=rangeallWorkspaces { |
| 62 | +// We only care about workspaces with autostart enabled. |
| 63 | +ifws.AutostartSchedule.String=="" { |
| 64 | +continue |
| 65 | +} |
| 66 | +sched,err:=schedule.Weekly(ws.AutostartSchedule.String) |
| 67 | +iferr!=nil { |
| 68 | +e.log.Warn(e.ctx,"workspace has invalid autostart schedule", |
| 69 | +slog.F("workspace_id",ws.ID), |
| 70 | +slog.F("autostart_schedule",ws.AutostartSchedule.String), |
| 71 | +) |
| 72 | +continue |
| 73 | +} |
| 74 | + |
| 75 | +// Determine the workspace state based on its latest build. We expect it to be stopped. |
| 76 | +// TODO(cian): is this **guaranteed** to be the latest build??? |
| 77 | +latestBuild,err:=db.GetWorkspaceBuildByWorkspaceIDWithoutAfter(e.ctx,ws.ID) |
| 78 | +iferr!=nil { |
| 79 | +returnxerrors.Errorf("get latest build for workspace %q: %w",ws.ID,err) |
| 80 | +} |
| 81 | +iflatestBuild.Transition!=database.WorkspaceTransitionStop { |
| 82 | +e.log.Debug(e.ctx,"autostart: skipping workspace: wrong transition", |
| 83 | +slog.F("transition",latestBuild.Transition), |
| 84 | +slog.F("workspace_id",ws.ID), |
| 85 | +) |
| 86 | +continue |
| 87 | +} |
| 88 | + |
| 89 | +// Round time to the nearest minute, as this is the finest granularity cron supports. |
| 90 | +earliestAutostart:=sched.Next(latestBuild.CreatedAt).Round(time.Minute) |
| 91 | +ifearliestAutostart.After(currentTick) { |
| 92 | +e.log.Debug(e.ctx,"autostart: skipping workspace: too early", |
| 93 | +slog.F("workspace_id",ws.ID), |
| 94 | +slog.F("earliest_autostart",earliestAutostart), |
| 95 | +slog.F("current_tick",currentTick), |
| 96 | +) |
| 97 | +continue |
| 98 | +} |
| 99 | + |
| 100 | +e.log.Info(e.ctx,"autostart: scheduling workspace start", |
| 101 | +slog.F("workspace_id",ws.ID), |
| 102 | +) |
| 103 | + |
| 104 | +iferr:=doBuild(e.ctx,db,ws,currentTick);err!=nil { |
| 105 | +e.log.Error(e.ctx,"autostart workspace",slog.F("workspace_id",ws.ID),slog.Error(err)) |
| 106 | +} |
| 107 | +} |
| 108 | +returnnil |
| 109 | +}) |
| 110 | +} |
| 111 | + |
| 112 | +// XXX: cian: this function shouldn't really exist. Refactor. |
| 113 | +funcdoBuild(ctx context.Context,store database.Store,workspace database.Workspace,now time.Time)error { |
| 114 | +template,err:=store.GetTemplateByID(ctx,workspace.TemplateID) |
| 115 | +iferr!=nil { |
| 116 | +returnxerrors.Errorf("get template: %w",err) |
| 117 | +} |
| 118 | + |
| 119 | +priorHistory,err:=store.GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx,workspace.ID) |
| 120 | +priorJob,err:=store.GetProvisionerJobByID(ctx,priorHistory.JobID) |
| 121 | +iferr==nil&&!priorJob.CompletedAt.Valid { |
| 122 | +returnxerrors.Errorf("workspace build already active") |
| 123 | +} |
| 124 | + |
| 125 | +priorHistoryID:= uuid.NullUUID{ |
| 126 | +UUID:priorHistory.ID, |
| 127 | +Valid:true, |
| 128 | +} |
| 129 | + |
| 130 | +varnewWorkspaceBuild database.WorkspaceBuild |
| 131 | +// This must happen in a transaction to ensure history can be inserted, and |
| 132 | +// the prior history can update it's "after" column to point at the new. |
| 133 | +workspaceBuildID:=uuid.New() |
| 134 | +input,err:=json.Marshal(struct { |
| 135 | +WorkspaceBuildIDstring`json:"workspace_build_id"` |
| 136 | +}{ |
| 137 | +WorkspaceBuildID:workspaceBuildID.String(), |
| 138 | +}) |
| 139 | +iferr!=nil { |
| 140 | +returnxerrors.Errorf("marshal provision job: %w",err) |
| 141 | +} |
| 142 | +provisionerJobID:=uuid.New() |
| 143 | +newProvisionerJob,err:=store.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ |
| 144 | +ID:provisionerJobID, |
| 145 | +CreatedAt:database.Now(), |
| 146 | +UpdatedAt:database.Now(), |
| 147 | +InitiatorID:workspace.OwnerID, |
| 148 | +OrganizationID:template.OrganizationID, |
| 149 | +Provisioner:template.Provisioner, |
| 150 | +Type:database.ProvisionerJobTypeWorkspaceBuild, |
| 151 | +StorageMethod:priorJob.StorageMethod, |
| 152 | +StorageSource:priorJob.StorageSource, |
| 153 | +Input:input, |
| 154 | +}) |
| 155 | +iferr!=nil { |
| 156 | +returnxerrors.Errorf("insert provisioner job: %w",err) |
| 157 | +} |
| 158 | +newWorkspaceBuild,err=store.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ |
| 159 | +ID:workspaceBuildID, |
| 160 | +CreatedAt:database.Now(), |
| 161 | +UpdatedAt:database.Now(), |
| 162 | +WorkspaceID:workspace.ID, |
| 163 | +TemplateVersionID:priorHistory.TemplateVersionID, |
| 164 | +BeforeID:priorHistoryID, |
| 165 | +Name:namesgenerator.GetRandomName(1), |
| 166 | +ProvisionerState:priorHistory.ProvisionerState, |
| 167 | +InitiatorID:workspace.OwnerID, |
| 168 | +Transition:database.WorkspaceTransitionStart, |
| 169 | +JobID:newProvisionerJob.ID, |
| 170 | +}) |
| 171 | +iferr!=nil { |
| 172 | +returnxerrors.Errorf("insert workspace build: %w",err) |
| 173 | +} |
| 174 | + |
| 175 | +ifpriorHistoryID.Valid { |
| 176 | +// Update the prior history entries "after" column. |
| 177 | +err=store.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ |
| 178 | +ID:priorHistory.ID, |
| 179 | +ProvisionerState:priorHistory.ProvisionerState, |
| 180 | +UpdatedAt:database.Now(), |
| 181 | +AfterID: uuid.NullUUID{ |
| 182 | +UUID:newWorkspaceBuild.ID, |
| 183 | +Valid:true, |
| 184 | +}, |
| 185 | +}) |
| 186 | +iferr!=nil { |
| 187 | +returnxerrors.Errorf("update prior workspace build: %w",err) |
| 188 | +} |
| 189 | +} |
| 190 | +returnnil |
| 191 | +} |
| 192 | + |
| 193 | +funcprovisionerJobStatus(j database.ProvisionerJob,now time.Time) codersdk.ProvisionerJobStatus { |
| 194 | +switch { |
| 195 | +casej.CanceledAt.Valid: |
| 196 | +ifj.CompletedAt.Valid { |
| 197 | +returncodersdk.ProvisionerJobCanceled |
| 198 | +} |
| 199 | +returncodersdk.ProvisionerJobCanceling |
| 200 | +case!j.StartedAt.Valid: |
| 201 | +returncodersdk.ProvisionerJobPending |
| 202 | +casej.CompletedAt.Valid: |
| 203 | +ifj.Error.String=="" { |
| 204 | +returncodersdk.ProvisionerJobSucceeded |
| 205 | +} |
| 206 | +returncodersdk.ProvisionerJobFailed |
| 207 | +casenow.Sub(j.UpdatedAt)>30*time.Second: |
| 208 | +returncodersdk.ProvisionerJobFailed |
| 209 | +default: |
| 210 | +returncodersdk.ProvisionerJobRunning |
| 211 | +} |
| 212 | +} |