Merge branch '2989-run-cicd-pipelines-on-a-schedule-idea1-basic-backend-implementation' into 'master'

Resolve "Run CI/CD pipelines on a schedule" - "Basic backend implementation"

See merge request !10133
This commit is contained in:
Kamil Trzciński 2017-04-06 18:55:34 +00:00
commit 1815d44a7f
25 changed files with 478 additions and 1 deletions

View file

@ -144,6 +144,9 @@ gem 'sidekiq-cron', '~> 0.4.4'
gem 'redis-namespace', '~> 1.5.2'
gem 'sidekiq-limit_fetch', '~> 3.4'
# Cron Parser
gem 'rufus-scheduler', '~> 3.1.10'
# HTTP requests
gem 'httparty', '~> 0.13.3'

View file

@ -987,6 +987,7 @@ DEPENDENCIES
rubocop-rspec (~> 1.12.0)
ruby-fogbugz (~> 0.2.1)
ruby-prof (~> 0.16.2)
rufus-scheduler (~> 3.1.10)
rugged (~> 0.25.1.1)
sanitize (~> 2.0)
sass-rails (~> 5.0.6)

View file

@ -8,6 +8,7 @@ module Ci
belongs_to :owner, class_name: "User"
has_many :trigger_requests, dependent: :destroy
has_one :trigger_schedule, dependent: :destroy
validates :token, presence: true, uniqueness: true

View file

@ -0,0 +1,30 @@
module Ci
class TriggerSchedule < ActiveRecord::Base
extend Ci::Model
include Importable
acts_as_paranoid
belongs_to :project
belongs_to :trigger
delegate :ref, to: :trigger
validates :trigger, presence: { unless: :importing? }
validates :cron, cron: true, presence: { unless: :importing? }
validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? }
before_save :set_next_run_at
def set_next_run_at
self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now)
end
def schedule_next_run!
save! # with set_next_run_at
rescue ActiveRecord::RecordInvalid
update_attribute(:next_run_at, nil) # update without validation
end
end
end

View file

@ -0,0 +1,9 @@
# CronTimezoneValidator
#
# Custom validator for CronTimezone.
class CronTimezoneValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_timezone_valid?
end
end

View file

@ -0,0 +1,9 @@
# CronValidator
#
# Custom validator for Cron.
class CronValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid?
end
end

View file

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

View file

@ -0,0 +1,4 @@
---
title: Resolve "Run CI/CD pipelines on a schedule" - "Basic backend implementation"
merge_request: 10133
author: dosuken123

View file

@ -180,6 +180,9 @@ production: &base
# Flag stuck CI jobs as failed
stuck_ci_jobs_worker:
cron: "0 * * * *"
# Execute scheduled triggers
trigger_schedule_worker:
cron: "0 */12 * * *"
# Remove expired build artifacts
expire_build_artifacts_worker:
cron: "50 * * * *"

View file

@ -315,6 +315,9 @@ Settings['cron_jobs'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_ci_jobs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_ci_jobs_worker']['cron'] ||= '0 * * * *'
Settings.cron_jobs['stuck_ci_jobs_worker']['job_class'] = 'StuckCiJobsWorker'
Settings.cron_jobs['trigger_schedule_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['trigger_schedule_worker']['cron'] ||= '0 */12 * * *'
Settings.cron_jobs['trigger_schedule_worker']['job_class'] = 'TriggerScheduleWorker'
Settings.cron_jobs['expire_build_artifacts_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['expire_build_artifacts_worker']['cron'] ||= '50 * * * *'
Settings.cron_jobs['expire_build_artifacts_worker']['job_class'] = 'ExpireBuildArtifactsWorker'

View file

@ -0,0 +1,9 @@
class AddRefToTriggers < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :ci_triggers, :ref, :string
end
end

View file

@ -0,0 +1,21 @@
class CreateCiTriggerSchedules < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :ci_trigger_schedules do |t|
t.integer "project_id"
t.integer "trigger_id", null: false
t.datetime "deleted_at"
t.datetime "created_at"
t.datetime "updated_at"
t.string "cron"
t.string "cron_timezone"
t.datetime "next_run_at"
end
add_index :ci_trigger_schedules, :next_run_at
add_index :ci_trigger_schedules, :project_id
end
end

View file

@ -0,0 +1,15 @@
class AddTriggerIdForeignKey < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_foreign_key :ci_trigger_schedules, :ci_triggers, column: :trigger_id, on_delete: :cascade
end
def down
remove_foreign_key :ci_trigger_schedules, column: :trigger_id
end
end

View file

@ -300,6 +300,20 @@ ActiveRecord::Schema.define(version: 20170405080720) do
add_index "ci_trigger_requests", ["commit_id"], name: "index_ci_trigger_requests_on_commit_id", using: :btree
create_table "ci_trigger_schedules", force: :cascade do |t|
t.integer "project_id"
t.integer "trigger_id", null: false
t.datetime "deleted_at"
t.datetime "created_at"
t.datetime "updated_at"
t.string "cron"
t.string "cron_timezone"
t.datetime "next_run_at"
end
add_index "ci_trigger_schedules", ["next_run_at"], name: "index_ci_trigger_schedules_on_next_run_at", using: :btree
add_index "ci_trigger_schedules", ["project_id"], name: "index_ci_trigger_schedules_on_project_id", using: :btree
create_table "ci_triggers", force: :cascade do |t|
t.string "token"
t.datetime "deleted_at"
@ -308,6 +322,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.integer "project_id"
t.integer "owner_id"
t.string "description"
t.string "ref"
end
add_index "ci_triggers", ["project_id"], name: "index_ci_triggers_on_project_id", using: :btree
@ -1313,6 +1328,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_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade
add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade
add_foreign_key "container_repositories", "projects"
add_foreign_key "issue_metrics", "issues", on_delete: :cascade

View file

@ -0,0 +1,34 @@
module Gitlab
module Ci
class CronParser
VALID_SYNTAX_SAMPLE_TIME_ZONE = 'UTC'.freeze
VALID_SYNTAX_SAMPLE_CRON = '* * * * *'.freeze
def initialize(cron, cron_timezone = 'UTC')
@cron = cron
@cron_timezone = cron_timezone
end
def next_time_from(time)
@cron_line ||= try_parse_cron(@cron, @cron_timezone)
@cron_line.next_time(time).in_time_zone(Time.zone) if @cron_line.present?
end
def cron_valid?
try_parse_cron(@cron, VALID_SYNTAX_SAMPLE_TIME_ZONE).present?
end
def cron_timezone_valid?
try_parse_cron(VALID_SYNTAX_SAMPLE_CRON, @cron_timezone).present?
end
private
def try_parse_cron(cron, cron_timezone)
Rufus::Scheduler.parse("#{cron} #{cron_timezone}")
rescue
# noop
end
end
end
end

View file

@ -39,7 +39,8 @@ project_tree:
- :author
- :events
- :statuses
- :triggers
- triggers:
- :trigger_schedule
- :deploy_keys
- :services
- :hooks

View file

@ -5,6 +5,7 @@ module Gitlab
pipelines: 'Ci::Pipeline',
statuses: 'commit_status',
triggers: 'Ci::Trigger',
trigger_schedule: 'Ci::TriggerSchedule',
builds: 'Ci::Build',
hooks: 'ProjectHook',
merge_access_levels: 'ProtectedBranch::MergeAccessLevel',

View file

@ -0,0 +1,26 @@
FactoryGirl.define do
factory :ci_trigger_schedule, class: Ci::TriggerSchedule do
trigger factory: :ci_trigger_for_trigger_schedule
cron '0 1 * * *'
cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
after(:build) do |trigger_schedule, evaluator|
trigger_schedule.update!(project: trigger_schedule.trigger.project)
end
trait :nightly do
cron '0 1 * * *'
cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
end
trait :weekly do
cron '0 1 * * 6'
cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
end
trait :monthly do
cron '0 1 22 * *'
cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE
end
end
end

View file

@ -2,6 +2,13 @@ FactoryGirl.define do
factory :ci_trigger_without_token, class: Ci::Trigger do
factory :ci_trigger do
token 'token'
factory :ci_trigger_for_trigger_schedule do
token { SecureRandom.hex(15) }
owner factory: :user
project factory: :project
ref 'master'
end
end
end
end

View file

@ -0,0 +1,116 @@
require 'spec_helper'
describe Gitlab::Ci::CronParser do
shared_examples_for "returns time in the future" do
it { is_expected.to be > Time.now }
end
describe '#next_time_from' do
subject { described_class.new(cron, cron_timezone).next_time_from(Time.now) }
context 'when cron and cron_timezone are valid' do
context 'when specific time' do
let(:cron) { '3 4 5 6 *' }
let(:cron_timezone) { 'UTC' }
it_behaves_like "returns time in the future"
it 'returns exact time' do
expect(subject.min).to eq(3)
expect(subject.hour).to eq(4)
expect(subject.day).to eq(5)
expect(subject.month).to eq(6)
end
end
context 'when specific day of week' do
let(:cron) { '* * * * 0' }
let(:cron_timezone) { 'UTC' }
it_behaves_like "returns time in the future"
it 'returns exact day of week' do
expect(subject.wday).to eq(0)
end
end
context 'when slash used' do
let(:cron) { '*/10 */6 */10 */10 *' }
let(:cron_timezone) { 'UTC' }
it_behaves_like "returns time in the future"
it 'returns specific time' do
expect(subject.min).to be_in([0, 10, 20, 30, 40, 50])
expect(subject.hour).to be_in([0, 6, 12, 18])
expect(subject.day).to be_in([1, 11, 21, 31])
expect(subject.month).to be_in([1, 11])
end
end
context 'when range used' do
let(:cron) { '0,20,40 * 1-5 * *' }
let(:cron_timezone) { 'UTC' }
it_behaves_like "returns time in the future"
it 'returns specific time' do
expect(subject.min).to be_in([0, 20, 40])
expect(subject.day).to be_in((1..5).to_a)
end
end
context 'when cron_timezone is US/Pacific' do
let(:cron) { '0 0 * * *' }
let(:cron_timezone) { 'US/Pacific' }
it_behaves_like "returns time in the future"
it 'converts time in server time zone' do
expect(subject.hour).to eq((Time.zone.now.in_time_zone(cron_timezone).utc_offset / 60 / 60).abs)
end
end
end
context 'when cron and cron_timezone are invalid' do
let(:cron) { 'invalid_cron' }
let(:cron_timezone) { 'invalid_cron_timezone' }
it 'returns nil' do
is_expected.to be_nil
end
end
end
describe '#cron_valid?' do
subject { described_class.new(cron, Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE).cron_valid? }
context 'when cron is valid' do
let(:cron) { '* * * * *' }
it { is_expected.to eq(true) }
end
context 'when cron is invalid' do
let(:cron) { '*********' }
it { is_expected.to eq(false) }
end
end
describe '#cron_timezone_valid?' do
subject { described_class.new(Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_CRON, cron_timezone).cron_timezone_valid? }
context 'when cron is valid' do
let(:cron_timezone) { 'Europe/Istanbul' }
it { is_expected.to eq(true) }
end
context 'when cron is invalid' do
let(:cron_timezone) { 'Invalid-zone' }
it { is_expected.to eq(false) }
end
end
end

View file

@ -99,6 +99,9 @@ triggers:
- project
- trigger_requests
- owner
- trigger_schedule
trigger_schedule:
- trigger
deploy_keys:
- user
- deploy_keys_projects
@ -197,6 +200,7 @@ project:
- runners
- variables
- triggers
- trigger_schedules
- environments
- deployments
- project_feature

View file

@ -240,6 +240,17 @@ Ci::Trigger:
- updated_at
- owner_id
- description
- ref
Ci::TriggerSchedule:
- id
- project_id
- trigger_id
- deleted_at
- created_at
- updated_at
- cron
- cron_timezone
- next_run_at
DeployKey:
- id
- user_id

View file

@ -0,0 +1,76 @@
require 'spec_helper'
describe Ci::TriggerSchedule, models: true do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:trigger) }
it { is_expected.to respond_to(:ref) }
describe '#set_next_run_at' do
context 'when creates new TriggerSchedule' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
@expected_next_run_at = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone)
.next_time_from(Time.now)
end
it 'updates next_run_at automatically' do
expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at)
end
end
context 'when updates cron of exsisted TriggerSchedule' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
new_cron = '0 0 1 1 *'
trigger_schedule.update!(cron: new_cron) # Subject
@expected_next_run_at = Gitlab::Ci::CronParser.new(new_cron, trigger_schedule.cron_timezone)
.next_time_from(Time.now)
end
it 'updates next_run_at automatically' do
expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at)
end
end
end
describe '#schedule_next_run!' do
context 'when reschedules after 10 days from now' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
time_future = Time.now + 10.days
allow(Time).to receive(:now).and_return(time_future)
trigger_schedule.schedule_next_run! # Subject
@expected_next_run_at = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone)
.next_time_from(time_future)
end
it 'points to proper next_run_at' do
expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at)
end
end
context 'when cron is invalid' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
trigger_schedule.cron = 'Invalid-cron'
trigger_schedule.schedule_next_run! # Subject
end
it 'sets nil to next_run_at' do
expect(Ci::TriggerSchedule.last.next_run_at).to be_nil
end
end
context 'when cron_timezone is invalid' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
trigger_schedule.cron_timezone = 'Invalid-cron_timezone'
trigger_schedule.schedule_next_run! # Subject
end
it 'sets nil to next_run_at' do
expect(Ci::TriggerSchedule.last.next_run_at).to be_nil
end
end
end
end

View file

@ -7,6 +7,7 @@ describe Ci::Trigger, models: true do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:owner) }
it { is_expected.to have_many(:trigger_requests) }
it { is_expected.to have_one(:trigger_schedule) }
end
describe 'before_validation' do

View file

@ -0,0 +1,58 @@
require 'spec_helper'
describe TriggerScheduleWorker do
let(:worker) { described_class.new }
before do
stub_ci_pipeline_to_return_yaml_file
end
context 'when there is a scheduled trigger within next_run_at' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
time_future = Time.now + 10.days
allow(Time).to receive(:now).and_return(time_future)
@next_time = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone).next_time_from(time_future)
end
it 'creates a new trigger request' do
expect { worker.perform }.to change { Ci::TriggerRequest.count }.by(1)
end
it 'creates a new pipeline' do
expect { worker.perform }.to change { Ci::Pipeline.count }.by(1)
expect(Ci::Pipeline.last).to be_pending
end
it 'updates next_run_at' do
expect { worker.perform }.to change { Ci::TriggerSchedule.last.next_run_at }.to(@next_time)
end
end
context 'when there are no scheduled triggers within next_run_at' do
before { create(:ci_trigger_schedule, :nightly) }
it 'does not create a new pipeline' do
expect { worker.perform }.not_to change { Ci::Pipeline.count }
end
it 'does not update next_run_at' do
expect { worker.perform }.not_to change { Ci::TriggerSchedule.last.next_run_at }
end
end
context 'when next_run_at is nil' do
before do
trigger_schedule = create(:ci_trigger_schedule, :nightly)
trigger_schedule.update_attribute(:next_run_at, nil)
end
it 'does not create a new pipeline' do
expect { worker.perform }.not_to change { Ci::Pipeline.count }
end
it 'does not update next_run_at' do
expect { worker.perform }.not_to change { Ci::TriggerSchedule.last.next_run_at }
end
end
end