diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 4c82d1868..4d2a12074 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -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", diff --git a/riff-raff/app/AppComponents.scala b/riff-raff/app/AppComponents.scala index dd5a9ce04..0ed2f53cf 100644 --- a/riff-raff/app/AppComponents.scala +++ b/riff-raff/app/AppComponents.scala @@ -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 @@ -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 @@ -52,6 +55,15 @@ 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) @@ -59,6 +71,7 @@ class AppComponents(context: Context) extends BuiltInComponentsFromContext(conte 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) @@ -80,6 +93,7 @@ class AppComponents(context: Context) extends BuiltInComponentsFromContext(conte continuousDeployController, hooksController, restrictionsController, + scheduleController, targetController, loginController, testingController, diff --git a/riff-raff/app/assets/javascripts/form-timezone.coffee b/riff-raff/app/assets/javascripts/form-timezone.coffee new file mode 100644 index 000000000..b8cb92cc7 --- /dev/null +++ b/riff-raff/app/assets/javascripts/form-timezone.coffee @@ -0,0 +1,5 @@ +$ -> + if $('#timezoneInput').prop('selectedIndex') == 0 + myTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + $('#timezoneInput').val(myTimeZone) + diff --git a/riff-raff/app/conf/context.scala b/riff-raff/app/conf/context.scala index b9256098d..23afa73bf 100644 --- a/riff-raff/app/conf/context.scala +++ b/riff-raff/app/conf/context.scala @@ -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 { diff --git a/riff-raff/app/controllers/Application.scala b/riff-raff/app/controllers/Application.scala index 3e8606cb2..0c6f21e8e 100644 --- a/riff-raff/app/controllers/Application.scala +++ b/riff-raff/app/controllers/Application.scala @@ -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")), diff --git a/riff-raff/app/controllers/ScheduleController.scala b/riff-raff/app/controllers/ScheduleController.scala new file mode 100644 index 000000000..5f0273388 --- /dev/null +++ b/riff-raff/app/controllers/ScheduleController.scala @@ -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) + } + +} + diff --git a/riff-raff/app/deployment/model.scala b/riff-raff/app/deployment/model.scala index da84ddd87..7e95c6e89 100644 --- a/riff-raff/app/deployment/model.scala +++ b/riff-raff/app/deployment/model.scala @@ -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 diff --git a/riff-raff/app/persistence/ScheduleRepository.scala b/riff-raff/app/persistence/ScheduleRepository.scala new file mode 100644 index 000000000..27e08a031 --- /dev/null +++ b/riff-raff/app/persistence/ScheduleRepository.scala @@ -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)) +} diff --git a/riff-raff/app/schedule/DeployJob.scala b/riff-raff/app/schedule/DeployJob.scala new file mode 100644 index 000000000..0cdbb7ca6 --- /dev/null +++ b/riff-raff/app/schedule/DeployJob.scala @@ -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) + } + } + } +} diff --git a/riff-raff/app/schedule/DeployScheduler.scala b/riff-raff/app/schedule/DeployScheduler.scala new file mode 100644 index 000000000..50c43ea7c --- /dev/null +++ b/riff-raff/app/schedule/DeployScheduler.scala @@ -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" + } + +} diff --git a/riff-raff/app/schedule/ScheduleConfig.scala b/riff-raff/app/schedule/ScheduleConfig.scala new file mode 100644 index 000000000..509f34571 --- /dev/null +++ b/riff-raff/app/schedule/ScheduleConfig.scala @@ -0,0 +1,9 @@ +package schedule + +import java.util.UUID + +import org.joda.time.DateTime + +case class ScheduleConfig(id: UUID, projectName: String, stage: String, + scheduleExpression: String, timezone: String, + enabled: Boolean, lastEdited: DateTime, user: String) \ No newline at end of file diff --git a/riff-raff/app/views/schedule/form.scala.html b/riff-raff/app/views/schedule/form.scala.html new file mode 100644 index 000000000..f4f6ec0de --- /dev/null +++ b/riff-raff/app/views/schedule/form.scala.html @@ -0,0 +1,53 @@ +@(configForm: Form[controllers.ScheduleController.ScheduleForm], prismLookup: resources.PrismLookup, timeZones: List[String])(implicit request: Security.AuthenticatedRequest[AnyContent, com.gu.googleauth.UserIdentity], messages: Messages) +@import b3.vertical.fieldConstructor +@import helper.CSRF + +@main("Schedule", request, List("form-autocomplete", "form-timezone")) { + +

Schedule Configuration

+
+ + @b3.form(action=routes.ScheduleController.save) { + @CSRF.formField + @snippets.inputHidden(configForm("id")) + + @if(configForm.hasGlobalErrors) { +
+

Error

+ +
+ } + + @b3.text(configForm("projectName"), 'id -> "projectInput", Symbol("data-url") -> "/deployment/request/autoComplete/project", '_label -> "Project Name") + @b3.select( + configForm("stage"), + options = helper.options(prismLookup.stages.toList), + '_default -> "--- Choose a stage ---", + '_label -> "Stage", + '_error -> configForm.globalError.map(_.withMessage("Please select deployment stage")) + ) + @b3.text(configForm("schedule"), '_label -> "Schedule expression") +

This should be a weird Quartz cron expression + (docs) + (online evaluator). + For example 0 30 11 ? * TUE * is 11:30 every Tuesday.

+ @b3.select( + configForm("timezone"), + options = helper.options(timeZones), + 'id -> "timezoneInput", + '_default -> "--- Choose a timezone ---", + '_label -> "Schedule timezone", + '_error -> configForm.globalError.map(_.withMessage("Please select a valid timezone for the schedule")) + ) + @b3.checkbox(configForm("enabled"), '_label -> "Schedule Enabled") + +
+ or + Cancel +
+ } +} \ No newline at end of file diff --git a/riff-raff/app/views/schedule/list.scala.html b/riff-raff/app/views/schedule/list.scala.html new file mode 100644 index 000000000..e4aa77715 --- /dev/null +++ b/riff-raff/app/views/schedule/list.scala.html @@ -0,0 +1,51 @@ +@import _root_.schedule.ScheduleConfig +@(implicit request: Security.AuthenticatedRequest[AnyContent, com.gu.googleauth.UserIdentity], configs: Seq[ScheduleConfig]) + +@import org.joda.time.format.DateTimeFormat._ +@import ci.Trigger._ +@import helper.CSRF + + @main("Schedule Configurations", request) { +

Schedule Configurations

+
+

Add new schedule

+
+ @if(configs.isEmpty) { +
No configurations.
+ } else { + + + + + + + + + + + + + + @for(config <- configs) { + + + + + + + + + + } + +
Last editedProject NameTarget StageScheduleEnabled?
@utils.DateFormats.Short.print(config.lastEdited) by @config.user@config.projectName@config.stage@config.scheduleExpression (@config.timezone)@config.enabled + Edit + + @helper.form(action=routes.ScheduleController.delete(config.id.toString), 'class -> "form-make-inline") { + @CSRF.formField + + } +
+ } +
+ } diff --git a/riff-raff/conf/routes b/riff-raff/conf/routes index 6a915e18b..073e0536f 100644 --- a/riff-raff/conf/routes +++ b/riff-raff/conf/routes @@ -75,6 +75,13 @@ POST /deployment/restrictions/save controllers.Restrict GET /deployment/restrictions/edit controllers.Restrictions.edit(id) POST /deployment/restrictions/delete controllers.Restrictions.delete(id) +# Schedules +GET /deployment/schedule controllers.ScheduleController.list +GET /deployment/schedule/new controllers.ScheduleController.form +POST /deployment/schedule/save controllers.ScheduleController.save +GET /deployment/schedule/:id/edit controllers.ScheduleController.edit(id) +POST /deployment/schedule/:id/delete controllers.ScheduleController.delete(id) + # Target GET /deployment/target/find controllers.TargetController.findMatch(region: String, stack: String, app: String) GET /deployment/target/deploy controllers.TargetController.findAppropriateDeploy(region: String, stack: String, app: String, stage: String) diff --git a/riff-raff/test/schedule/DeployJobTest.scala b/riff-raff/test/schedule/DeployJobTest.scala new file mode 100644 index 000000000..78cca8214 --- /dev/null +++ b/riff-raff/test/schedule/DeployJobTest.scala @@ -0,0 +1,44 @@ +package schedule + +import java.util.UUID + +import deployment.{DeployRecord, Error} +import magenta.{Build, Deployer, DeployParameters, RunState, Stage} +import org.joda.time.DateTime +import org.scalatest.{EitherValues, FlatSpec, Matchers} + +class DeployJobTest extends FlatSpec with Matchers with EitherValues { + val uuid = UUID.fromString("7fa2ee0a-8d90-4f7e-a38b-185f36fbc5aa") + "createDeployParameters" should "return params if valid" in { + val record = new DeployRecord( + new DateTime(), + uuid, + DeployParameters(Deployer("Bob"), Build("testProject", "1"), Stage("TEST")), + recordState = Some(RunState.Completed) + ) + DeployJob.createDeployParameters(record, true) shouldBe + Right(DeployParameters(Deployer("Scheduled Deployment"), Build("testProject", "1"), Stage("TEST"))) + } + + it should "produce an error if the last deploy didn't complete" in { + val record = new DeployRecord( + new DateTime(), + uuid, + DeployParameters(Deployer("Bob"), Build("testProject", "1"), Stage("TEST")), + recordState = Some(RunState.Failed) + ) + DeployJob.createDeployParameters(record, true) shouldBe + Left(Error("Skipping scheduled deploy as deploy record 7fa2ee0a-8d90-4f7e-a38b-185f36fbc5aa has status Failed")) + } + + it should "produce an error if scheduled deploys are disabled" in { + val record = new DeployRecord( + new DateTime(), + uuid, + DeployParameters(Deployer("Bob"), Build("testProject", "1"), Stage("TEST")), + recordState = Some(RunState.Completed) + ) + DeployJob.createDeployParameters(record, false) shouldBe + Left(Error("Scheduled deployments disabled. Would have deployed DeployParameters(Deployer(Scheduled Deployment),Build(testProject,1),Stage(TEST),RecipeName(default),List(),List(),All)")) + } +}