Skip to content

Commit

Permalink
Merge pull request #476 from guardian/sihil/scheduled-deploys
Browse files Browse the repository at this point in the history
Scheduled deploys
  • Loading branch information
sihil authored Jan 19, 2018
2 parents f051d21 + 24009c1 commit 3133933
Show file tree
Hide file tree
Showing 15 changed files with 481 additions and 2 deletions.
1 change: 1 addition & 0 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ object Dependencies {
"com.adrianhurt" %% "play-bootstrap" % "1.2-P26-B3-RC2",
"com.gu" %% "scanamo" % "0.9.5",
"com.amazonaws" % "aws-java-sdk-dynamodb" % Versions.aws,
"org.quartz-scheduler" % "quartz" % "2.3.0",
"org.webjars" %% "webjars-play" % "2.6.0",
"org.webjars" % "jquery" % "3.1.1",
"org.webjars" % "jquery-ui" % "1.12.1",
Expand Down
16 changes: 15 additions & 1 deletion riff-raff/app/AppComponents.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import controllers._
import deployment.preview.PreviewCoordinator
import deployment.{DeploymentEngine, Deployments}
import magenta.deployment_type._
import persistence.ScheduleRepository
import play.api.ApplicationLoader.Context
import play.api.http.DefaultHttpErrorHandler
import play.api.i18n.I18nComponents
Expand All @@ -20,13 +21,15 @@ import utils.HstsFilter
import scala.concurrent.Future
import scala.concurrent.duration._
import router.Routes
import schedule.DeployScheduler

class AppComponents(context: Context) extends BuiltInComponentsFromContext(context)
with AhcWSComponents
with I18nComponents
with CSRFComponents
with GzipFilterComponents
with AssetsComponents {
with AssetsComponents
with Logging {

implicit val implicitMessagesApi = messagesApi
implicit val implicitWsClient = wsClient
Expand All @@ -52,13 +55,23 @@ class AppComponents(context: Context) extends BuiltInComponentsFromContext(conte
new HstsFilter()(executionContext)
) // TODO (this would require an upgrade of the management-play lib) ++ PlayRequestMetrics.asFilters

val deployScheduler = new DeployScheduler(deployments)
log.info("Starting deployment scheduler")
deployScheduler.start()
applicationLifecycle.addStopHook { () =>
log.info("Shutting down deployment scheduler")
Future.successful(deployScheduler.shutdown())
}
deployScheduler.initialise(ScheduleRepository.getScheduleList())

val applicationController = new Application(prismLookup, availableDeploymentTypes, authAction, controllerComponents, assets)(environment, wsClient, executionContext)
val deployController = new DeployController(deployments, prismLookup, availableDeploymentTypes, builds, authAction, controllerComponents)
val apiController = new Api(deployments, availableDeploymentTypes, authAction, controllerComponents)
val continuousDeployController = new ContinuousDeployController(prismLookup, authAction, controllerComponents)
val previewController = new PreviewController(previewCoordinator, authAction, controllerComponents)(wsClient, executionContext)
val hooksController = new HooksController(prismLookup, authAction, controllerComponents)
val restrictionsController = new Restrictions(authAction, controllerComponents)
val scheduleController = new ScheduleController(authAction, controllerComponents, prismLookup, deployScheduler)
val targetController = new TargetController(deployments, authAction, controllerComponents)
val loginController = new Login(deployments, controllerComponents, authAction)
val testingController = new Testing(prismLookup, authAction, controllerComponents)
Expand All @@ -80,6 +93,7 @@ class AppComponents(context: Context) extends BuiltInComponentsFromContext(conte
continuousDeployController,
hooksController,
restrictionsController,
scheduleController,
targetController,
loginController,
testingController,
Expand Down
5 changes: 5 additions & 0 deletions riff-raff/app/assets/javascripts/form-timezone.coffee
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)

3 changes: 3 additions & 0 deletions riff-raff/app/conf/context.scala
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ class Configuration(val application: String, val webappConfDirectory: String = "

object continuousDeployment {
lazy val enabled = configuration.getStringProperty("continuousDeployment.enabled", "false") == "true"
}

object scheduledDeployment {
lazy val enabled = configuration.getStringProperty("scheduledDeployment.enabled", "false") == "true"
}

object credentials {
Expand Down
3 changes: 2 additions & 1 deletion riff-raff/app/controllers/Application.scala
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ object Menu {
SingleMenuItem("Hooks", routes.HooksController.list()),
SingleMenuItem("Authorisation", routes.Login.authList(), enabled = conf.Configuration.auth.whitelist.useDatabase),
SingleMenuItem("API keys", routes.Api.listKeys()),
SingleMenuItem("Restrictions", routes.Restrictions.list())
SingleMenuItem("Restrictions", routes.Restrictions.list()),
SingleMenuItem("Schedules", routes.ScheduleController.list())
)),
DropDownMenuItem("Documentation", Seq(
SingleMenuItem("Deployment Types", routes.Application.documentation("magenta-lib/types")),
Expand Down
110 changes: 110 additions & 0 deletions riff-raff/app/controllers/ScheduleController.scala
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)
}

}

1 change: 1 addition & 0 deletions riff-raff/app/deployment/model.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ sealed trait RequestSource
case class UserRequestSource(user: UserIdentity) extends RequestSource
case object ContinuousDeploymentRequestSource extends RequestSource
case class ApiRequestSource(key: ApiKey) extends RequestSource
case object ScheduleRequestSource extends RequestSource

case class Error(message: String) extends AnyVal

Expand Down
30 changes: 30 additions & 0 deletions riff-raff/app/persistence/ScheduleRepository.scala
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))
}
73 changes: 73 additions & 0 deletions riff-raff/app/schedule/DeployJob.scala
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)
}
}
}
}
77 changes: 77 additions & 0 deletions riff-raff/app/schedule/DeployScheduler.scala
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"
}

}
Loading

0 comments on commit 3133933

Please sign in to comment.