Add scheduled_trigger model. Add cron parser. Plus, specs.

This commit is contained in:
Shinya Maeda 2017-03-23 03:54:49 +09:00
parent 46e4ed6bd0
commit 5f715f1d32
9 changed files with 356 additions and 3 deletions

View file

@ -0,0 +1,23 @@
module Ci
class ScheduledTrigger < ActiveRecord::Base
extend Ci::Model
acts_as_paranoid
belongs_to :project
belongs_to :owner, class_name: "User"
def schedule_next_run!
next_time = Ci::CronParser.new(cron, cron_time_zone).next_time_from_now
update(:next_run_at => next_time) if next_time.present?
end
def valid_ref?
true #TODO:
end
def update_last_run!
update(:last_run_at => Time.now)
end
end
end

View file

@ -0,0 +1,18 @@
class ScheduledTriggerWorker
include Sidekiq::Worker
include CronjobQueue
def perform
# TODO: Update next_run_at
Ci::ScheduledTriggers.where("next_run_at < ?", Time.now).find_each do |trigger|
begin
Ci::CreateTriggerRequestService.new.execute(trigger.project, trigger, trigger.ref)
rescue => e
Rails.logger.error "#{trigger.id}: Failed to trigger job: #{e.message}"
ensure
trigger.schedule_next_run!
end
end
end
end

View file

@ -0,0 +1,45 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class CreateCiScheduledTriggers < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index" or "add_column_with_default"
# you must disable the use of transactions as these methods can not run in an
# existing transaction. When using "add_concurrent_index" make sure that this
# method is the _only_ method called in the migration, any other changes
# should go in a separate migration. This ensures that upon failure _only_ the
# index creation fails and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
# disable_ddl_transaction!
def change
create_table :ci_scheduled_triggers do |t|
t.integer "project_id"
t.datetime "deleted_at"
t.datetime "created_at"
t.datetime "updated_at"
t.integer "owner_id"
t.string "description"
t.string "cron"
t.string "cron_time_zone"
t.datetime "next_run_at"
t.datetime "last_run_at"
t.string "ref"
end
add_index :ci_scheduled_triggers, ["next_run_at"], name: "index_ci_scheduled_triggers_on_next_run_at", using: :btree
add_index :ci_scheduled_triggers, ["project_id"], name: "index_ci_scheduled_triggers_on_project_id", using: :btree
add_foreign_key :ci_scheduled_triggers, :users, column: :owner_id, on_delete: :cascade
end
end

View file

@ -61,7 +61,6 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.boolean "shared_runners_enabled", default: true, null: false
t.integer "max_artifacts_size", default: 100, null: false
t.string "runners_registration_token"
t.integer "max_pages_size", default: 100, null: false
t.boolean "require_two_factor_authentication", default: false
t.integer "two_factor_grace_period", default: 48
t.boolean "metrics_enabled", default: false
@ -111,6 +110,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.string "plantuml_url"
t.boolean "plantuml_enabled"
t.integer "terminal_max_session_time", default: 0, null: false
t.integer "max_pages_size", default: 100, null: false
t.string "default_artifacts_expire_in", default: "0", null: false
t.integer "unique_ips_limit_per_user"
t.integer "unique_ips_limit_time_window"
@ -290,6 +290,23 @@ ActiveRecord::Schema.define(version: 20170405080720) do
add_index "ci_runners", ["locked"], name: "index_ci_runners_on_locked", using: :btree
add_index "ci_runners", ["token"], name: "index_ci_runners_on_token", using: :btree
create_table "ci_scheduled_triggers", force: :cascade do |t|
t.integer "project_id"
t.datetime "deleted_at"
t.datetime "created_at"
t.datetime "updated_at"
t.integer "owner_id"
t.string "description"
t.string "cron"
t.string "cron_time_zone"
t.datetime "next_run_at"
t.datetime "last_run_at"
t.string "ref"
end
add_index "ci_scheduled_triggers", ["next_run_at"], name: "index_ci_scheduled_triggers_on_next_run_at", using: :btree
add_index "ci_scheduled_triggers", ["project_id"], name: "index_ci_scheduled_triggers_on_project_id", using: :btree
create_table "ci_trigger_requests", force: :cascade do |t|
t.integer "trigger_id", null: false
t.text "variables"
@ -689,8 +706,8 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.integer "visibility_level", default: 20, null: false
t.boolean "request_access_enabled", default: false, null: false
t.datetime "deleted_at"
t.text "description_html"
t.boolean "lfs_enabled"
t.text "description_html"
t.integer "parent_id"
end
@ -1242,8 +1259,8 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.datetime "otp_grace_period_started_at"
t.boolean "ldap_email", default: false, null: false
t.boolean "external", default: false
t.string "incoming_email_token"
t.string "organization"
t.string "incoming_email_token"
t.boolean "authorized_projects_populated"
t.boolean "ghost"
t.boolean "notified_of_own_activity"
@ -1298,6 +1315,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do
add_foreign_key "boards", "projects"
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
add_foreign_key "ci_scheduled_triggers", "users", column: "owner_id", on_delete: :cascade
add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade
add_foreign_key "issue_metrics", "issues", on_delete: :cascade
add_foreign_key "label_priorities", "labels", on_delete: :cascade

30
lib/ci/cron_parser.rb Normal file
View file

@ -0,0 +1,30 @@
require 'rufus-scheduler' # Included in sidekiq-cron
module Ci
class CronParser
def initialize(cron, cron_time_zone = 'UTC')
@cron = cron
@cron_time_zone = cron_time_zone
end
def next_time_from_now
cronLine = try_parse_cron
return nil unless cronLine.present?
cronLine.next_time
end
def valid_syntax?
try_parse_cron.present? ? true : false
end
private
def try_parse_cron
begin
Rufus::Scheduler.parse("#{@cron} #{@cron_time_zone}")
rescue
nil
end
end
end
end

View file

@ -0,0 +1,42 @@
FactoryGirl.define do
factory :ci_scheduled_trigger, class: Ci::ScheduledTrigger do
project factory: :empty_project
owner factory: :user
ref 'master'
trait :cron_nightly_build do
cron '0 1 * * *'
cron_time_zone 'Europe/Istanbul'
end
trait :cron_weekly_build do
cron '0 1 * * 5'
cron_time_zone 'Europe/Istanbul'
end
trait :cron_monthly_build do
cron '0 1 22 * *'
cron_time_zone 'Europe/Istanbul'
end
trait :cron_every_5_minutes do
cron '*/5 * * * *'
cron_time_zone 'Europe/Istanbul'
end
trait :cron_every_5_hours do
cron '* */5 * * *'
cron_time_zone 'Europe/Istanbul'
end
trait :cron_every_5_days do
cron '* * */5 * *'
cron_time_zone 'Europe/Istanbul'
end
trait :cron_every_5_months do
cron '* * * */5 *'
cron_time_zone 'Europe/Istanbul'
end
end
end

View file

@ -0,0 +1,128 @@
require 'spec_helper'
module Ci
describe CronParser, lib: true do
describe '#next_time_from_now' do
subject { described_class.new(cron, cron_time_zone).next_time_from_now }
context 'when cron and cron_time_zone are valid' do
context 'at 00:00, 00:10, 00:20, 00:30, 00:40, 00:50' do
let(:cron) { '*/10 * * * *' }
let(:cron_time_zone) { 'US/Pacific' }
it 'returns next time from now' do
time = Time.now.in_time_zone(cron_time_zone)
time = time + 10.minutes
time = time.change(sec: 0, min: time.min-time.min%10)
is_expected.to eq(time)
end
end
context 'at 10:00, 20:00' do
let(:cron) { '0 */10 * * *' }
let(:cron_time_zone) { 'US/Pacific' }
it 'returns next time from now' do
time = Time.now.in_time_zone(cron_time_zone)
time = time + 10.hours
time = time.change(sec: 0, min: 0, hour: time.hour-time.hour%10)
is_expected.to eq(time)
end
end
context 'when cron is every 10 days' do
let(:cron) { '0 0 */10 * *' }
let(:cron_time_zone) { 'US/Pacific' }
it 'returns next time from now' do
time = Time.now.in_time_zone(cron_time_zone)
time = time + 10.days
time = time.change(sec: 0, min: 0, hour: 0, day: time.day-time.day%10)
is_expected.to eq(time)
end
end
context 'when cron is every week 2:00 AM' do
let(:cron) { '0 2 * * *' }
let(:cron_time_zone) { 'US/Pacific' }
it 'returns next time from now' do
time = Time.now.in_time_zone(cron_time_zone)
is_expected.to eq(time.change(sec: 0, min: 0, hour: 2, day: time.day+1))
end
end
context 'when cron_time_zone is US/Pacific' do
let(:cron) { '0 1 * * *' }
let(:cron_time_zone) { 'US/Pacific' }
it 'returns next time from now' do
time = Time.now.in_time_zone(cron_time_zone)
is_expected.to eq(time.change(sec: 0, min: 0, hour: 1, day: time.day+1))
end
end
context 'when cron_time_zone is Europe/London' do
let(:cron) { '0 1 * * *' }
let(:cron_time_zone) { 'Europe/London' }
it 'returns next time from now' do
time = Time.now.in_time_zone(cron_time_zone)
is_expected.to eq(time.change(sec: 0, min: 0, hour: 1, day: time.day+1))
end
end
context 'when cron_time_zone is Asia/Tokyo' do
let(:cron) { '0 1 * * *' }
let(:cron_time_zone) { 'Asia/Tokyo' }
it 'returns next time from now' do
time = Time.now.in_time_zone(cron_time_zone)
is_expected.to eq(time.change(sec: 0, min: 0, hour: 1, day: time.day+1))
end
end
end
context 'when cron is given and cron_time_zone is not given' do
let(:cron) { '0 1 * * *' }
it 'returns next time from now in utc' do
obj = described_class.new(cron).next_time_from_now
time = Time.now.in_time_zone('UTC')
expect(obj).to eq(time.change(sec: 0, min: 0, hour: 1, day: time.day+1))
end
end
context 'when cron and cron_time_zone are invalid' do
let(:cron) { 'hack' }
let(:cron_time_zone) { 'hack' }
it 'returns nil' do
is_expected.to be_nil
end
end
end
describe '#valid_syntax?' do
subject { described_class.new(cron, cron_time_zone).valid_syntax? }
context 'when cron and cron_time_zone are valid' do
let(:cron) { '* * * * *' }
let(:cron_time_zone) { 'Europe/Istanbul' }
it 'returns true' do
is_expected.to eq(true)
end
end
context 'when cron and cron_time_zone are invalid' do
let(:cron) { 'hack' }
let(:cron_time_zone) { 'hack' }
it 'returns false' do
is_expected.to eq(false)
end
end
end
end
end

View file

@ -0,0 +1,38 @@
require 'spec_helper'
require 'rufus-scheduler' # Included in sidekiq-cron
describe Ci::ScheduledTrigger, models: true do
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:owner) }
end
describe '#schedule_next_run!' do
context 'when cron and cron_time_zone are vaild' do
context 'when nightly build' do
it 'schedules next run' do
scheduled_trigger = create(:ci_scheduled_trigger, :cron_nightly_build)
scheduled_trigger.schedule_next_run!
puts "scheduled_trigger: #{scheduled_trigger.inspect}"
expect(scheduled_trigger.cron).to be_nil
end
end
context 'when weekly build' do
end
context 'when monthly build' do
end
end
context 'when cron and cron_time_zone are invaild' do
it 'schedules nothing' do
end
end
end
end

View file

@ -0,0 +1,11 @@
require 'spec_helper'
describe ScheduledTriggerWorker do
subject { described_class.new.perform }
context '#perform' do # TODO:
it 'does' do
is_expected.to be_nil
end
end
end