-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #476 from guardian/sihil/scheduled-deploys
Scheduled deploys
- Loading branch information
Showing
15 changed files
with
481 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
$ -> | ||
if $('#timezoneInput').prop('selectedIndex') == 0 | ||
myTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone | ||
$('#timezoneInput').val(myTimeZone) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
package controllers | ||
|
||
import java.text.ParseException | ||
import java.util.{TimeZone, UUID} | ||
|
||
import com.gu.googleauth.AuthAction | ||
import org.joda.time.DateTime | ||
import org.quartz.CronExpression | ||
import persistence.ScheduleRepository | ||
import play.api.data.Form | ||
import play.api.data.Forms._ | ||
import play.api.data.validation.{Constraint, Invalid, Valid} | ||
import play.api.i18n.I18nSupport | ||
import play.api.libs.ws.WSClient | ||
import play.api.mvc.{AnyContent, BaseController, ControllerComponents} | ||
import resources.PrismLookup | ||
import schedule.{DeployScheduler, ScheduleConfig} | ||
|
||
import scala.util.{Failure, Success, Try} | ||
|
||
class ScheduleController(authAction: AuthAction[AnyContent], val controllerComponents: ControllerComponents, | ||
prismLookup: PrismLookup, deployScheduler: DeployScheduler)(implicit val wsClient: WSClient) | ||
extends BaseController with Logging with I18nSupport { | ||
|
||
import ScheduleController.ScheduleForm | ||
|
||
val quartzExpressionConstraint: Constraint[String] = Constraint("quartz.expression"){ expression => | ||
Try(CronExpression.validateExpression(expression)) match { | ||
case Success(()) => Valid | ||
case Failure(pe:ParseException) => Invalid(s"Invalid Quartz expression: ${pe.getMessage}") | ||
case Failure(_) => Invalid(s"Invalid Quartz expression") | ||
} | ||
} | ||
val timezoneConstraint: Constraint[String] = Constraint("timezone"){ expression => | ||
Try(TimeZone.getTimeZone(expression)) match { | ||
case Success(_) => Valid | ||
case Failure(_) => Invalid(s"Invalid timezone") | ||
} | ||
} | ||
val timeZones: List[String] = TimeZone.getAvailableIDs.toList.sorted | ||
|
||
val scheduleForm = Form[ScheduleForm]( | ||
mapping( | ||
"id" -> uuid, | ||
"projectName" -> nonEmptyText, | ||
"stage" -> nonEmptyText, | ||
"schedule" -> nonEmptyText.verifying(quartzExpressionConstraint), | ||
"timezone" -> nonEmptyText.verifying(timezoneConstraint), | ||
"enabled" -> boolean | ||
)(ScheduleForm.apply)(ScheduleForm.unapply) | ||
) | ||
|
||
def list = authAction { implicit request => | ||
val schedules = ScheduleRepository.getScheduleList() | ||
Ok(views.html.schedule.list(request, schedules)) | ||
} | ||
|
||
def form = authAction { implicit request => | ||
Ok(views.html.schedule.form( | ||
scheduleForm.fill(ScheduleForm(UUID.randomUUID(), "", "", "", "", enabled = true)), prismLookup, timeZones | ||
)) | ||
} | ||
|
||
def save = authAction { implicit request => | ||
scheduleForm.bindFromRequest().fold( | ||
formWithErrors => Ok(views.html.schedule.form(formWithErrors, prismLookup, timeZones)), | ||
form => { | ||
val config = form.toConfig(new DateTime(), request.user.fullName) | ||
ScheduleRepository.setSchedule(config) | ||
deployScheduler.reschedule(config) | ||
Redirect(routes.ScheduleController.list()) | ||
} | ||
) | ||
} | ||
|
||
def edit(id: String) = authAction { implicit request => | ||
ScheduleRepository.getSchedule(UUID.fromString(id)) | ||
.fold(NotFound(s"Schedule with ID $id doesn't exist"))( | ||
config => Ok(views.html.schedule.form(scheduleForm.fill(ScheduleForm(config)), prismLookup, timeZones)) | ||
) | ||
} | ||
|
||
def delete(id: String) = authAction { implicit request => | ||
Form("action" -> nonEmptyText).bindFromRequest().fold( | ||
errors => {}, | ||
{ | ||
case "delete" => | ||
val uuid = UUID.fromString(id) | ||
ScheduleRepository.deleteSchedule(uuid) | ||
deployScheduler.unschedule(uuid) | ||
} | ||
) | ||
Redirect(routes.ScheduleController.list()) | ||
} | ||
|
||
} | ||
|
||
object ScheduleController { | ||
|
||
case class ScheduleForm(id: UUID, projectName: String, stage: String, schedule: String, timezone: String, enabled: Boolean) { | ||
def toConfig(lastEdited: DateTime, user: String): ScheduleConfig = | ||
ScheduleConfig(id, projectName, stage, schedule, timezone, enabled, lastEdited, user) | ||
} | ||
object ScheduleForm { | ||
def apply(config: ScheduleConfig): ScheduleForm = | ||
ScheduleForm(config.id, config.projectName, config.stage, config.scheduleExpression, config.timezone, config.enabled) | ||
} | ||
|
||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
package persistence | ||
|
||
import java.util.UUID | ||
|
||
import ci.Trigger | ||
import com.gu.scanamo.syntax._ | ||
import com.gu.scanamo.{DynamoFormat, Table} | ||
import schedule.ScheduleConfig | ||
|
||
object ScheduleRepository extends DynamoRepository { | ||
|
||
implicit val triggerModeFormat = | ||
DynamoFormat.coercedXmap[Trigger.Mode, String, NoSuchElementException](Trigger.withName)(_.toString) | ||
|
||
override val tablePrefix = "schedule-config" | ||
|
||
val table = Table[ScheduleConfig](tableName) | ||
|
||
def getScheduleList(): List[ScheduleConfig] = | ||
exec(table.scan()).flatMap(_.toOption) | ||
|
||
def getSchedule(id: UUID): Option[ScheduleConfig] = | ||
exec(table.get('id -> id)).flatMap(_.toOption) | ||
|
||
def setSchedule(schedule: ScheduleConfig): Unit = | ||
exec(table.put(schedule)) | ||
|
||
def deleteSchedule(id: UUID): Unit = | ||
exec(table.delete('id -> id)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
package schedule | ||
|
||
import conf.Configuration | ||
import controllers.Logging | ||
import deployment._ | ||
import magenta.{Deployer, DeployParameters, RunState} | ||
import org.quartz.{Job, JobDataMap, JobExecutionContext} | ||
import schedule.DeployScheduler.JobDataKeys | ||
|
||
import scala.annotation.tailrec | ||
import scala.util.Try | ||
|
||
class DeployJob extends Job with Logging { | ||
private def getAs[T](key: String)(implicit jobDataMap: JobDataMap): T = jobDataMap.get(key).asInstanceOf[T] | ||
|
||
override def execute(context: JobExecutionContext): Unit = { | ||
implicit val jobDataMap = context.getJobDetail.getJobDataMap | ||
val deployments = getAs[Deployments](JobDataKeys.Deployments) | ||
val projectName = getAs[String](JobDataKeys.ProjectName) | ||
val stage = getAs[String](JobDataKeys.Stage) | ||
|
||
val result = for { | ||
record <- DeployJob.getLastDeploy(deployments, projectName, stage) | ||
params <- DeployJob.createDeployParameters(record, Configuration.scheduledDeployment.enabled) | ||
uuid <- deployments.deploy(params, ScheduleRequestSource) | ||
} yield uuid | ||
result match { | ||
case Left(error) => log.warn(error.message) | ||
case Right(uuid) => log.info(s"Started scheduled deploy $uuid") | ||
} | ||
} | ||
} | ||
|
||
object DeployJob { | ||
def createDeployParameters(lastDeploy: Record, scheduledDeploysEnabled: Boolean): Either[Error, DeployParameters] = { | ||
lastDeploy.state match { | ||
case RunState.Completed => | ||
val params = DeployParameters( | ||
Deployer("Scheduled Deployment"), | ||
lastDeploy.parameters.build, | ||
lastDeploy.stage | ||
) | ||
if (scheduledDeploysEnabled) { | ||
Right(params) | ||
} else { | ||
Left(Error(s"Scheduled deployments disabled. Would have deployed $params")) | ||
} | ||
case otherState => | ||
Left(Error(s"Skipping scheduled deploy as deploy record ${lastDeploy.uuid} has status $otherState")) | ||
} | ||
} | ||
|
||
@tailrec | ||
private def getLastDeploy(deployments: Deployments, projectName: String, stage: String, attempts: Int = 5): Either[Error, Record] = { | ||
if (attempts == 0) { | ||
Left(Error(s"Didn't find any deploys for $projectName / $stage")) | ||
} else { | ||
val filter = DeployFilter( | ||
projectName = Some(projectName), | ||
stage = Some(stage) | ||
) | ||
val pagination = PaginationView().withPageSize(Some(1)) | ||
|
||
val result = Try(deployments.getDeploys(Some(filter), pagination).headOption).toOption.flatten | ||
result match { | ||
case Some(record) => Right(record) | ||
case None => | ||
Thread.sleep(1000) | ||
getLastDeploy(deployments, projectName, stage, attempts-1) | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
package schedule | ||
|
||
import java.util.{TimeZone, UUID} | ||
|
||
import deployment.Deployments | ||
import org.quartz.CronScheduleBuilder._ | ||
import org.quartz.JobBuilder._ | ||
import org.quartz.TriggerBuilder._ | ||
import org.quartz.impl.StdSchedulerFactory | ||
import org.quartz.{JobDataMap, JobKey, TriggerKey} | ||
import play.api.Logger | ||
import schedule.DeployScheduler.JobDataKeys | ||
|
||
class DeployScheduler(deployments: Deployments) { | ||
|
||
private val scheduler = StdSchedulerFactory.getDefaultScheduler | ||
|
||
def initialise(schedules: Iterable[ScheduleConfig]): Unit = { | ||
schedules.foreach(scheduleDeploy) | ||
} | ||
|
||
def reschedule(schedule: ScheduleConfig): Unit = { | ||
// Delete any job and trigger that we may have previously created | ||
unschedule(schedule.id) | ||
scheduleDeploy(schedule) | ||
} | ||
|
||
def unschedule(id: UUID): Unit = { | ||
scheduler.deleteJob(jobKey(id)) | ||
} | ||
|
||
private def scheduleDeploy(scheduleConfig: ScheduleConfig): Unit = { | ||
val id = scheduleConfig.id | ||
if (scheduleConfig.enabled) { | ||
val jobDetail = newJob(classOf[DeployJob]) | ||
.withIdentity(jobKey(id)) | ||
.usingJobData(buildJobDataMap(scheduleConfig)) | ||
.build() | ||
val trigger = newTrigger() | ||
.withIdentity(triggerKey(id)) | ||
.withSchedule( | ||
cronSchedule(scheduleConfig.scheduleExpression).inTimeZone(TimeZone.getTimeZone(scheduleConfig.timezone)) | ||
) | ||
.build() | ||
scheduler.scheduleJob(jobDetail, trigger) | ||
Logger.info(s"Scheduled [$id] to deploy with schedule [${scheduleConfig.scheduleExpression} in ${scheduleConfig.timezone}]") | ||
} else { | ||
Logger.info(s"NOT scheduling disabled schedule [$id] to deploy with schedule [${scheduleConfig.scheduleExpression} in ${scheduleConfig.timezone}]") | ||
} | ||
} | ||
|
||
def start(): Unit = scheduler.start() | ||
|
||
def shutdown(): Unit = scheduler.shutdown() | ||
|
||
private def jobKey(id: UUID): JobKey = new JobKey(id.toString) | ||
private def triggerKey(id: UUID): TriggerKey = new TriggerKey(id.toString) | ||
|
||
private def buildJobDataMap(scheduleConfig: ScheduleConfig): JobDataMap = { | ||
val map = new JobDataMap() | ||
map.put(JobDataKeys.Deployments, deployments) | ||
map.put(JobDataKeys.ProjectName, scheduleConfig.projectName) | ||
map.put(JobDataKeys.Stage, scheduleConfig.stage) | ||
map | ||
} | ||
|
||
} | ||
|
||
object DeployScheduler { | ||
|
||
object JobDataKeys { | ||
val Deployments = "deployments" | ||
val ProjectName = "projectName" | ||
val Stage = "stage" | ||
} | ||
|
||
} |
Oops, something went wrong.