diff --git a/app/assets/javascripts/shipit/continuous_delivery_schedule.js.coffee b/app/assets/javascripts/shipit/continuous_delivery_schedule.js.coffee new file mode 100644 index 000000000..b8750ffb0 --- /dev/null +++ b/app/assets/javascripts/shipit/continuous_delivery_schedule.js.coffee @@ -0,0 +1,15 @@ +$(document) + .on "click", ".continuous-delivery-schedule [data-action='copy-to-all']", (event) -> + form = event.target.closest("form"); + + mondayStart = form.elements.namedItem("continuous_delivery_schedule[monday_start]").value + mondayEnd = form.elements.namedItem("continuous_delivery_schedule[monday_end]").value + + Array.from(form.elements).forEach (formElement) -> + return unless formElement.type == "time" + + if formElement.name.endsWith("_start]") + formElement.value = mondayStart + + if formElement.name.endsWith("_end]") + formElement.value = mondayEnd diff --git a/app/controllers/shipit/continuous_delivery_schedules_controller.rb b/app/controllers/shipit/continuous_delivery_schedules_controller.rb new file mode 100644 index 000000000..4efa02521 --- /dev/null +++ b/app/controllers/shipit/continuous_delivery_schedules_controller.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Shipit + class ContinuousDeliverySchedulesController < ShipitController + before_action :load_stack + + def show + @continuous_delivery_schedule = @stack.continuous_delivery_schedule || @stack.build_continuous_delivery_schedule + end + + def update + @continuous_delivery_schedule = @stack.continuous_delivery_schedule || @stack.build_continuous_delivery_schedule + @continuous_delivery_schedule.assign_attributes(continuous_delivery_schedule_params) + + if @continuous_delivery_schedule.save + flash[:success] = "Successfully updated" + redirect_to(stack_continuous_delivery_schedule_path) + else + flash.now[:warning] = "Check form for errors" + render(:show, status: :unprocessable_entity) + end + end + + private + + def load_stack + @stack = Stack.from_param!(params[:id]) + end + + def continuous_delivery_schedule_params + params.require(:continuous_delivery_schedule).permit( + *Shipit::ContinuousDeliverySchedule::DAYS.flat_map do |day| + [ + "#{day}_start", + "#{day}_end", + "#{day}_enabled", + ] + end + ) + end + end +end diff --git a/app/jobs/shipit/continuous_delivery_job.rb b/app/jobs/shipit/continuous_delivery_job.rb index 46a292f01..e9f2af982 100644 --- a/app/jobs/shipit/continuous_delivery_job.rb +++ b/app/jobs/shipit/continuous_delivery_job.rb @@ -9,6 +9,12 @@ class ContinuousDeliveryJob < BackgroundJob def perform(stack) return unless stack.continuous_deployment? + # If there is a schedule defined for this stack, make sure we are within a + # deployment window before proceeding. + if stack.continuous_delivery_schedule + return unless stack.continuous_delivery_schedule.can_deploy? + end + # checks if there are any tasks running, including concurrent tasks return if stack.occupied? diff --git a/app/models/shipit/continuous_delivery_schedule.rb b/app/models/shipit/continuous_delivery_schedule.rb new file mode 100644 index 000000000..52e700ea9 --- /dev/null +++ b/app/models/shipit/continuous_delivery_schedule.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Shipit + class ContinuousDeliverySchedule < Record + belongs_to(:stack) + + DAYS = %w[sunday monday tuesday wednesday thursday friday saturday].freeze + + validates( + *DAYS.map { |day| "#{day}_enabled" }, + inclusion: [true, false], + ) + + validates( + *DAYS.product([:start, :end]).map { |parts| parts.join("_") }, + presence: true + ) + + validate(:validate_time_windows) + + DeploymentWindow = Struct.new(:starts_at, :ends_at, :enabled) do + alias_method :enabled?, :enabled + end + + def can_deploy?(now = Time.current) + # Make sure time is in the default time zone so weekdays match what is + # stored in the database. + now = now.in_time_zone(Time.zone) + + deployment_window = get_deployment_window(now.to_date) + + deployment_window.enabled? && + now >= deployment_window.starts_at && + now <= deployment_window.ends_at + end + + def get_deployment_window(date) + wday_name = DAYS.fetch(date.wday) + + enabled = read_attribute("#{wday_name}_enabled") + + starts_at, ends_at = [:start, :end].map do |bound| + raw_time = read_attribute("#{wday_name}_#{bound}") + + # `ActiveRecord::Type::Time` attributes are stored as timestamps + # normalized to 2000-01-01 so they can't be used for comparisons without + # having their dates adjusted. + # https://github.com/rails/rails/blob/ec667e5f114df58087493096253541f1034815af/activemodel/lib/active_model/type/time.rb#L23 + Time.zone.local( + date.year, + date.month, + date.day, + raw_time.hour, + raw_time.min, + ) + end + + DeploymentWindow.new( + starts_at, + # Includes the full minute in the configured range. This is required so + # that a window configured to end at 17:59 actually ends at 17:59:59 + # instead of 17:59:00. + ends_at.at_end_of_minute, + enabled, + ) + end + + private + + # Make sure every `*_end` attribute comes after its matching `*_start` + # attribute + def validate_time_windows + DAYS.each do |day| + day_start, day_end = [:start, :end].map { |bound| read_attribute("#{day}_#{bound}") } + + next unless day_start && day_end + + next if day_start <= day_end + + errors.add("#{day}_end", :must_be_after_start, start: day_start.strftime("%I:%M %p")) + end + end + end +end diff --git a/app/models/shipit/stack.rb b/app/models/shipit/stack.rb index 23767f254..636ff18dc 100644 --- a/app/models/shipit/stack.rb +++ b/app/models/shipit/stack.rb @@ -38,6 +38,7 @@ def blank? has_many :github_hooks, dependent: :destroy, class_name: 'Shipit::GithubHook::Repo' has_many :hooks, dependent: :destroy has_many :api_clients, dependent: :destroy + has_one :continuous_delivery_schedule belongs_to :lock_author, class_name: :User, optional: true belongs_to :repository validates_associated :repository diff --git a/app/views/shipit/continuous_delivery_schedules/show.html.erb b/app/views/shipit/continuous_delivery_schedules/show.html.erb new file mode 100644 index 000000000..0e81894cd --- /dev/null +++ b/app/views/shipit/continuous_delivery_schedules/show.html.erb @@ -0,0 +1,59 @@ +<%= render partial: 'shipit/stacks/header', locals: { stack: @stack } %> + +
+
+
+

Continuous Delivery Schedule (Stack #<%= @stack.id %>)

+
+
+
+ <% if @continuous_delivery_schedule.errors.any? %> +
+

Validation errors prevented your schedule from being saved

+
    + <% @continuous_delivery_schedule.errors.full_messages.each do |full_message| %> +
  • <%= full_message %>
  • + <% end %> +
+
+ <% end %> + <%= form_for(@continuous_delivery_schedule, url: stack_continuous_delivery_schedule_path, method: :patch) do |f| %> + + + <% Shipit::ContinuousDeliverySchedule::DAYS.rotate.each do |day| %> + + + + + + + + + <% end %> + +
+ <%= f.check_box("#{day}_enabled") %> + + <%= f.label("#{day}_enabled", day.titlecase) %> + + <%= f.time_field("#{day}_start", include_seconds: false) %> + + <%= f.time_field("#{day}_end", include_seconds: false) %> + + <% if day == "monday" %> + + <% end %> +
+ +

+ ℹ️ + All times are in <%= Time.zone.name %> + (the default time zone). +

+ +
+ <%= f.submit("Save", class: "btn") %> +
+ <% end %> +
+
diff --git a/app/views/shipit/stacks/_settings_form.erb b/app/views/shipit/stacks/_settings_form.erb index 7b0f6db35..c5ee0d8b9 100644 --- a/app/views/shipit/stacks/_settings_form.erb +++ b/app/views/shipit/stacks/_settings_form.erb @@ -17,6 +17,7 @@
<%= f.check_box :continuous_deployment %> <%= f.label :continuous_deployment, 'Enable continuous deployment' %> + (<%= link_to("Edit schedule", stack_continuous_delivery_schedule_path) %>)
diff --git a/config/locales/en.yml b/config/locales/en.yml index ed1cf47c7..f2c57c38f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -81,6 +81,7 @@ en: messages: subset: "is not a strict subset of %{of}" ascii: "contains non-ASCII characters" + must_be_after_start: "must be after start (%{start})" deployment_description: deploy: in_progress: "%{author} triggered the deploy of %{stack} to %{sha}" diff --git a/config/routes.rb b/config/routes.rb index 9da476b8a..7d5bce9a1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -84,6 +84,8 @@ post :refresh, controller: :stacks get :refresh, controller: :stacks # For easier design, sorry :/ post :clear_git_cache, controller: :stacks + + resource :continuous_delivery_schedule, only: %i(show update) end scope '/task/:id', controller: :tasks do diff --git a/db/migrate/20240821003007_add_continuous_delivery_schedules.rb b/db/migrate/20240821003007_add_continuous_delivery_schedules.rb new file mode 100644 index 000000000..b93a11e0d --- /dev/null +++ b/db/migrate/20240821003007_add_continuous_delivery_schedules.rb @@ -0,0 +1,13 @@ +class AddContinuousDeliverySchedules < ActiveRecord::Migration[7.1] + def change + create_table(:continuous_delivery_schedules) do |t| + t.references(:stack, null: false, index: { unique: true }) + %w[sunday monday tuesday wednesday thursday friday saturday].each do |day| + t.boolean("#{day}_enabled", null: false, default: true) + t.time("#{day}_start", null: false, default: "00:00") + t.time("#{day}_end", null: false, default: "23:59") + end + t.timestamps + end + end +end diff --git a/test/controllers/continuous_delivery_schedules_controller_test.rb b/test/controllers/continuous_delivery_schedules_controller_test.rb new file mode 100644 index 000000000..af36fe1b6 --- /dev/null +++ b/test/controllers/continuous_delivery_schedules_controller_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true +require 'test_helper' + +module Shipit + class ContinuousDeliverySchedulesControllerTest < ActionController::TestCase + setup do + @routes = Shipit::Engine.routes + @stack = shipit_stacks(:shipit) + session[:user_id] = shipit_users(:walrus).id + end + + def valid_params + Shipit::ContinuousDeliverySchedule::DAYS.each_with_object({}) do |day, hash| + hash[:"#{day}_enabled"] = "0" + hash[:"#{day}_start"] = "09:00" + hash[:"#{day}_end"] = "17:00" + end + end + + test "#show returns a 200 response" do + get(:show, params: { id: @stack.to_param }) + + assert(response.ok?) + end + + test "#update" do + patch(:update, params: { + id: @stack.to_param, + continuous_delivery_schedule: { + **valid_params, + }, + }) + + assert_redirected_to(stack_continuous_delivery_schedule_path(@stack)) + assert_equal("Successfully updated", flash[:success]) + + schedule = @stack.continuous_delivery_schedule + + Shipit::ContinuousDeliverySchedule::DAYS.each do |day| + refute(schedule.read_attribute("#{day}_enabled")) + + day_start = schedule.read_attribute("#{day}_start") + assert_equal("09:00:00 AM", day_start.strftime("%r")) + + day_end = schedule.read_attribute("#{day}_end") + assert_equal("05:00:00 PM", day_end.strftime("%r")) + end + end + + test "#update renders validation errors" do + patch(:update, params: { + id: @stack.to_param, + continuous_delivery_schedule: { + # Make Sunday end before it starts + **valid_params.merge(sunday_end: "08:00"), + }, + }) + + assert_response(:unprocessable_entity) + assert_equal("Check form for errors", flash[:warning]) + elements = assert_select(".validation-errors") + assert_includes(elements.sole.inner_text, "Sunday end must be after start (09:00 AM)") + end + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index 22bbfaffb..08b1db7b2 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,8 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_07_03_181143) do - +ActiveRecord::Schema[7.1].define(version: 2024_08_21_003007) do create_table "api_clients", force: :cascade do |t| t.text "permissions", limit: 65535 t.integer "creator_id", limit: 4 @@ -87,6 +86,34 @@ t.index ["stack_id"], name: "index_commits_on_stack_id" end + create_table "continuous_delivery_schedules", force: :cascade do |t| + t.integer "stack_id", null: false + t.boolean "sunday_enabled", default: true, null: false + t.time "sunday_start", default: "2000-01-01 00:00:00", null: false + t.time "sunday_end", default: "2000-01-01 23:59:00", null: false + t.boolean "monday_enabled", default: true, null: false + t.time "monday_start", default: "2000-01-01 00:00:00", null: false + t.time "monday_end", default: "2000-01-01 23:59:00", null: false + t.boolean "tuesday_enabled", default: true, null: false + t.time "tuesday_start", default: "2000-01-01 00:00:00", null: false + t.time "tuesday_end", default: "2000-01-01 23:59:00", null: false + t.boolean "wednesday_enabled", default: true, null: false + t.time "wednesday_start", default: "2000-01-01 00:00:00", null: false + t.time "wednesday_end", default: "2000-01-01 23:59:00", null: false + t.boolean "thursday_enabled", default: true, null: false + t.time "thursday_start", default: "2000-01-01 00:00:00", null: false + t.time "thursday_end", default: "2000-01-01 23:59:00", null: false + t.boolean "friday_enabled", default: true, null: false + t.time "friday_start", default: "2000-01-01 00:00:00", null: false + t.time "friday_end", default: "2000-01-01 23:59:00", null: false + t.boolean "saturday_enabled", default: true, null: false + t.time "saturday_start", default: "2000-01-01 00:00:00", null: false + t.time "saturday_end", default: "2000-01-01 23:59:00", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["stack_id"], name: "index_continuous_delivery_schedules_on_stack_id", unique: true + end + create_table "deliveries", force: :cascade do |t| t.integer "hook_id", limit: 4, null: false t.string "status", limit: 50, default: "pending", null: false diff --git a/test/jobs/shipit/continuous_delivery_job_test.rb b/test/jobs/shipit/continuous_delivery_job_test.rb new file mode 100644 index 000000000..07b7d5331 --- /dev/null +++ b/test/jobs/shipit/continuous_delivery_job_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +require 'test_helper' + +module Shipit + class ContinuousDeliveryJobTest < ActiveSupport::TestCase + test "calls trigger_continous_delivery" do + stack = shipit_stacks(:shipit) + stack.stubs(:continuous_deployment?).returns(true).once + stack.stubs(:occupied).returns(false).once + stack.expects(:trigger_continuous_delivery).once + + Shipit::ContinuousDeliveryJob.new.perform(stack) + end + + test "does not call trigger_continuous_delivery if outside of schedule" do + freeze_time do + monday_9am = Date.current.monday.at_beginning_of_day.advance(hours: 9) + travel_to(monday_9am) + + stack = shipit_stacks(:shipit) + stack.create_continuous_delivery_schedule!(monday_start: "09:30") + + stack.stubs(:continuous_deployment?).returns(true).once + stack.expects(:trigger_continuous_delivery).never + + Shipit::ContinuousDeliveryJob.new.perform(stack) + end + end + end +end diff --git a/test/models/shipit/continuous_delivery_schedule_test.rb b/test/models/shipit/continuous_delivery_schedule_test.rb new file mode 100644 index 000000000..2c41bcee4 --- /dev/null +++ b/test/models/shipit/continuous_delivery_schedule_test.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true +require "test_helper" + +module Shipit + class ContinuousDeliveryScheduleTest < ActiveSupport::TestCase + test "defaults to all the time" do + stack = shipit_stacks(:shipit) + schedule = stack.build_continuous_delivery_schedule + + assert(schedule.valid?) + + Shipit::ContinuousDeliverySchedule::DAYS.each_with_index do |day| + assert(schedule.read_attribute("#{day}_enabled")) + + day_start = schedule.read_attribute("#{day}_start") + assert_equal(day_start.at_beginning_of_day, day_start) + + day_end = schedule.read_attribute("#{day}_end") + assert_equal(day_end.at_end_of_day.at_beginning_of_minute, day_end) + end + end + + test "#get_deployment_window" do + schedule = Shipit::ContinuousDeliverySchedule.new( + monday_enabled: false, + monday_start: "09:15", + monday_end: "17:30", + ) + + monday = Date.current.monday + + deployment_window = schedule.get_deployment_window(monday) + + refute(deployment_window.enabled?) + + starts_at = deployment_window.starts_at + assert_equal(monday, starts_at.to_date) + assert_equal(9, starts_at.hour) + assert_equal(15, starts_at.min) + assert_equal(starts_at.at_beginning_of_minute, starts_at) + + ends_at = deployment_window.ends_at + assert_equal(monday, ends_at.to_date) + assert_equal(17, ends_at.hour) + assert_equal(30, ends_at.min) + assert_equal(ends_at.at_end_of_minute, ends_at) + end + + test "#can_deploy? is false if the day is disabled" do + schedule = Shipit::ContinuousDeliverySchedule.new( + tuesday_enabled: false, + tuesday_start: "00:00", + tuesday_end: "23:59", + ) + + tuesday = Date.current.monday.advance(days: 1).beginning_of_day + + refute(schedule.can_deploy?(tuesday)) + end + + test "#can_deploy? is true when the current time is within the window" do + schedule = Shipit::ContinuousDeliverySchedule.new( + wednesday_enabled: true, + wednesday_start: "09:15", + wednesday_end: "17:30", + ) + + wednesday = Date.current.monday.advance(days: 2).beginning_of_day + + refute(schedule.can_deploy?(wednesday)) + assert(schedule.can_deploy?(wednesday.advance(hours: 9, minutes: 15))) + assert(schedule.can_deploy?(wednesday.advance(hours: 12))) + assert(schedule.can_deploy?(wednesday.advance(hours: 17, minutes: 30).at_end_of_minute)) + refute(schedule.can_deploy?(wednesday.advance(hours: 17, minutes: 31))) + end + + test "validates that end times must come after start times" do + schedule = Shipit::ContinuousDeliverySchedule.new( + thursday_start: "15:00", + thursday_end: "14:00" + ) + + schedule.validate + assert(schedule.errors.include?(:thursday_end)) + assert_equal(["must be after start (03:00 PM)"], schedule.errors.messages_for(:thursday_end)) + end + + test "validates `*_enabled` fields" do + schedule = Shipit::ContinuousDeliverySchedule.new( + friday_enabled: nil, + ) + + schedule.validate + assert_equal(["is not included in the list"], schedule.errors.messages_for(:friday_enabled)) + end + + test "requires `_start` and `_end` fields" do + schedule = Shipit::ContinuousDeliverySchedule.new( + saturday_start: nil, + saturday_end: nil, + ) + + schedule.validate + assert_equal(["can't be blank"], schedule.errors.messages_for(:saturday_start)) + assert_equal(["can't be blank"], schedule.errors.messages_for(:saturday_end)) + end + end +end