diff --git a/Gemfile b/Gemfile index 6a45b3d7339..b16505b3aa2 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 50ca9af7a7a..e4603df5f7f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index cba1d81a861..0a89f3e0640 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -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 diff --git a/app/models/ci/trigger_schedule.rb b/app/models/ci/trigger_schedule.rb new file mode 100644 index 00000000000..10ea381ee31 --- /dev/null +++ b/app/models/ci/trigger_schedule.rb @@ -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 diff --git a/app/validators/cron_timezone_validator.rb b/app/validators/cron_timezone_validator.rb new file mode 100644 index 00000000000..542c7d006ad --- /dev/null +++ b/app/validators/cron_timezone_validator.rb @@ -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 diff --git a/app/validators/cron_validator.rb b/app/validators/cron_validator.rb new file mode 100644 index 00000000000..981fade47a6 --- /dev/null +++ b/app/validators/cron_validator.rb @@ -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 diff --git a/app/workers/trigger_schedule_worker.rb b/app/workers/trigger_schedule_worker.rb new file mode 100644 index 00000000000..440c579b99d --- /dev/null +++ b/app/workers/trigger_schedule_worker.rb @@ -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 diff --git a/changelogs/unreleased/2989-run-cicd-pipelines-on-a-schedule-idea1-basic-backend-implementation.yml b/changelogs/unreleased/2989-run-cicd-pipelines-on-a-schedule-idea1-basic-backend-implementation.yml new file mode 100644 index 00000000000..dd56409c35b --- /dev/null +++ b/changelogs/unreleased/2989-run-cicd-pipelines-on-a-schedule-idea1-basic-backend-implementation.yml @@ -0,0 +1,4 @@ +--- +title: Resolve "Run CI/CD pipelines on a schedule" - "Basic backend implementation" +merge_request: 10133 +author: dosuken123 diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 4314e902564..3c70f35b9d0 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -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 * * * *" diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index f7cae84088e..4c9d829aa9f 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -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' diff --git a/db/migrate/20170329095325_add_ref_to_triggers.rb b/db/migrate/20170329095325_add_ref_to_triggers.rb new file mode 100644 index 00000000000..4aa52dd8f8f --- /dev/null +++ b/db/migrate/20170329095325_add_ref_to_triggers.rb @@ -0,0 +1,9 @@ +class AddRefToTriggers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_triggers, :ref, :string + end +end diff --git a/db/migrate/20170329095907_create_ci_trigger_schedules.rb b/db/migrate/20170329095907_create_ci_trigger_schedules.rb new file mode 100644 index 00000000000..cfcfa27ebb5 --- /dev/null +++ b/db/migrate/20170329095907_create_ci_trigger_schedules.rb @@ -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 diff --git a/db/migrate/20170404163427_add_trigger_id_foreign_key.rb b/db/migrate/20170404163427_add_trigger_id_foreign_key.rb new file mode 100644 index 00000000000..6679a95ca11 --- /dev/null +++ b/db/migrate/20170404163427_add_trigger_id_foreign_key.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index d8e116b7903..a7a8f5bd38f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/lib/gitlab/ci/cron_parser.rb b/lib/gitlab/ci/cron_parser.rb new file mode 100644 index 00000000000..a3cc350ef22 --- /dev/null +++ b/lib/gitlab/ci/cron_parser.rb @@ -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 diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index f69288f7d67..f5e1e385ff9 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -39,7 +39,8 @@ project_tree: - :author - :events - :statuses - - :triggers + - triggers: + - :trigger_schedule - :deploy_keys - :services - :hooks diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index fb43e7ccdbb..2ba12f5f924 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -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', diff --git a/spec/factories/ci/trigger_schedules.rb b/spec/factories/ci/trigger_schedules.rb new file mode 100644 index 00000000000..315bce16995 --- /dev/null +++ b/spec/factories/ci/trigger_schedules.rb @@ -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 diff --git a/spec/factories/ci/triggers.rb b/spec/factories/ci/triggers.rb index a27b04424e5..2295455575d 100644 --- a/spec/factories/ci/triggers.rb +++ b/spec/factories/ci/triggers.rb @@ -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 diff --git a/spec/lib/gitlab/ci/cron_parser_spec.rb b/spec/lib/gitlab/ci/cron_parser_spec.rb new file mode 100644 index 00000000000..0864bc7258d --- /dev/null +++ b/spec/lib/gitlab/ci/cron_parser_spec.rb @@ -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 diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index a2e3b06989e..cb9b6a007b1 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -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 diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 1ad16a9b57d..0c43c5662e8 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -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 diff --git a/spec/models/ci/trigger_schedule_spec.rb b/spec/models/ci/trigger_schedule_spec.rb new file mode 100644 index 00000000000..75d21541cee --- /dev/null +++ b/spec/models/ci/trigger_schedule_spec.rb @@ -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 diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb index 1bcb673cb16..42170de0180 100644 --- a/spec/models/ci/trigger_spec.rb +++ b/spec/models/ci/trigger_spec.rb @@ -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 diff --git a/spec/workers/trigger_schedule_worker_spec.rb b/spec/workers/trigger_schedule_worker_spec.rb new file mode 100644 index 00000000000..151e1c2f7b9 --- /dev/null +++ b/spec/workers/trigger_schedule_worker_spec.rb @@ -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