Skip to content

Commit

Permalink
Continuous Deployment Hours
Browse files Browse the repository at this point in the history
Allow to configure period of time during which continuous delivery
is active.
  • Loading branch information
davidcornu authored and byroot committed Sep 30, 2024
1 parent c494be6 commit 333b1b4
Show file tree
Hide file tree
Showing 14 changed files with 456 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions app/controllers/shipit/continuous_delivery_schedules_controller.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/jobs/shipit/continuous_delivery_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
84 changes: 84 additions & 0 deletions app/models/shipit/continuous_delivery_schedule.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/models/shipit/stack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions app/views/shipit/continuous_delivery_schedules/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<%= render partial: 'shipit/stacks/header', locals: { stack: @stack } %>

<div class="wrapper continuous-delivery-schedule">
<section>
<header class="section-header">
<h2>Continuous Delivery Schedule (Stack #<%= @stack.id %>)</h2>
</header>
</section>
<div class="setting-section">
<% if @continuous_delivery_schedule.errors.any? %>
<div class="validation-errors">
<p>Validation errors prevented your schedule from being saved</p>
<ul>
<% @continuous_delivery_schedule.errors.full_messages.each do |full_message| %>
<li><%= full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= form_for(@continuous_delivery_schedule, url: stack_continuous_delivery_schedule_path, method: :patch) do |f| %>
<table class="field-wrapper">
<tbody>
<% Shipit::ContinuousDeliverySchedule::DAYS.rotate.each do |day| %>
<tr>
<td>
<%= f.check_box("#{day}_enabled") %>
</td>
<td>
<%= f.label("#{day}_enabled", day.titlecase) %>
</td>
<td>
<%= f.time_field("#{day}_start", include_seconds: false) %>
</td>
<td>&rarr;</td>
<td>
<%= f.time_field("#{day}_end", include_seconds: false) %>
</td>
<td>
<% if day == "monday" %>
<button data-action="copy-to-all" type="button">Copy to all &darr;</button>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>

<p>
&#x2139;&#xFE0F;
All times are in <%= Time.zone.name %>
(the <a href="https://guides.rubyonrails.org/configuring.html#config-time-zone">default time zone</a>).
</p>

<div class="field-wrapper">
<%= f.submit("Save", class: "btn") %>
</div>
<% end %>
</div>
</div>
1 change: 1 addition & 0 deletions app/views/shipit/stacks/_settings_form.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<div class="field-wrapper">
<%= f.check_box :continuous_deployment %>
<%= f.label :continuous_deployment, 'Enable continuous deployment' %>
(<%= link_to("Edit schedule", stack_continuous_delivery_schedule_path) %>)
</div>

<div class="field-wrapper">
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions db/migrate/20240821003007_add_continuous_delivery_schedules.rb
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions test/controllers/continuous_delivery_schedules_controller_test.rb
Original file line number Diff line number Diff line change
@@ -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
31 changes: 29 additions & 2 deletions test/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 333b1b4

Please sign in to comment.