diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index b8beba01915f0167822bcb1f39eb4b45853967bc..f973674dc04e6b59dda2ac9943d8b3b1e40468b8 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -94,6 +94,7 @@ export default { this.cronTimezone = schedule.cronTimezone; this.savedInputs = schedule.inputs?.nodes || []; this.scheduleRef = schedule.ref || this.defaultBranch; + this.scheduledPipelineSkipWindow = schedule.scheduledPipelineSkipWindow; this.variables = variables.map((variable) => { return { id: variable.id, @@ -119,6 +120,7 @@ export default { activated: true, cron: '', cronTimezone: '', + scheduledPipelineSkipWindow: null, description: '', pipelineInputs: [], savedInputs: [], @@ -144,6 +146,10 @@ export default { createScheduleBtnText: s__('PipelineSchedules|Create pipeline schedule'), cancel: __('Cancel'), targetBranchTag: __('Select target branch or tag'), + scheduledPipelineSkipWindow: __('Scheduled pipeline skip window'), + scheduledPipelineSkipWindowDescription: s__( + 'PipelineSchedules|Define a custom pipeline execution window that allows the pipeline to run for a given amount of time after its scheduled time. This window must be specified in seconds (86,400 seconds equal one day).', + ), intervalPattern: s__('PipelineSchedules|Interval Pattern'), scheduleCreateError: s__( @@ -157,6 +163,14 @@ export default { ), }, computed: { + scheduledPipelineSkipWindowNumber: { + get() { + return this.scheduledPipelineSkipWindow; + }, + set(value) { + this.scheduledPipelineSkipWindow = value !== '' ? Number(value) : null; + }, + }, dropdownTranslations() { return { dropdownHeader: this.$options.i18n.targetBranchTag, @@ -215,6 +229,7 @@ export default { cronTimezone: this.cronTimezone, ref: this.scheduleRef, variables: this.preparedVariablesCreate, + scheduledPipelineSkipWindow: this.scheduledPipelineSkipWindow, active: this.activated, projectPath: this.projectPath, inputs: this.pipelineInputs, @@ -246,6 +261,7 @@ export default { description: this.description, cron: this.cron, cronTimezone: this.cronTimezone, + scheduledPipelineSkipWindow: this.scheduledPipelineSkipWindow, ref: this.scheduleRef, variables: this.preparedVariablesUpdate, active: this.activated, @@ -362,6 +378,23 @@ export default { class="gl-w-full" /> + + + + {{ $options.i18n.scheduledPipelineSkipWindowDescription }} + + + 0 && + (old_next_run_at + scheduled_pipeline_skip_window.to_i).future? + end + private def ambiguous_ref? diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index 9484c5b0ffe818afd05c0255e8fd862f7136693a..a0f4474f57c82f1909cdec0b0c1ef0bc54de11ab 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -19,6 +19,11 @@ def initialize(pipeline) def execute return unless pipeline.needs_processing? + if Feature.enabled?(:scheduled_pipeline_skip_window, + pipeline.project) && !pipeline.within_scheduled_window_after_creation? + pipeline.skip + return + end # Run the process only if we can obtain an exclusive lease; returns nil if lease is unavailable success = try_obtain_lease { process! } diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index f0bd5a262cd77d7ee1b7e9cf42187086dd3bce18..0bb1392e625b0c51b095c15b0a1ce3c17d7dbad2 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -19,8 +19,18 @@ def perform(schedule_id, user_id, options = {}) return unless schedule_valid?(schedule, schedule_id, user, options) + old_next_run_at = schedule.next_run_at if Feature.enabled?(:scheduled_pipeline_skip_window, schedule.project) + update_next_run_at_for(schedule) if options['scheduling'] + if Feature.enabled?(:scheduled_pipeline_skip_window, schedule.project) && !schedule.within_window?(old_next_run_at) + message = 'Pipeline schedule runnable window exceeded.' + response = ServiceResponse.error(message: message) + log_error(schedule.id, message) + + return response + end + response = run_pipeline_schedule(schedule, user) log_error(schedule.id, response.message) if response&.error? diff --git a/config/feature_flags/gitlab_com_derisk/scheduled_pipeline_skip_window.yml b/config/feature_flags/gitlab_com_derisk/scheduled_pipeline_skip_window.yml new file mode 100644 index 0000000000000000000000000000000000000000..9c54831b4486e8d40c8e8435453e18b2305cf8c2 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/scheduled_pipeline_skip_window.yml @@ -0,0 +1,10 @@ +--- +name: scheduled_pipeline_skip_window +description: +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/539393 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/202610 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/571092 +milestone: '18.4' +group: group::pipeline execution +type: gitlab_com_derisk +default_enabled: false diff --git a/db/migrate/20250828154201_add_scheduled_pipeline_skip_window_to_ci_pipeline_schedules.rb b/db/migrate/20250828154201_add_scheduled_pipeline_skip_window_to_ci_pipeline_schedules.rb new file mode 100644 index 0000000000000000000000000000000000000000..6939bb53c559e21c76dea0b7898ea7039375712e --- /dev/null +++ b/db/migrate/20250828154201_add_scheduled_pipeline_skip_window_to_ci_pipeline_schedules.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddScheduledPipelineSkipWindowToCiPipelineSchedules < Gitlab::Database::Migration[2.3] + milestone '18.4' + + def change + add_column :ci_pipeline_schedules, :scheduled_pipeline_skip_window, :integer, default: nil + end +end diff --git a/db/schema_migrations/20250828154201 b/db/schema_migrations/20250828154201 new file mode 100644 index 0000000000000000000000000000000000000000..217d28f9ed3328152246172827e0a8418fc85a22 --- /dev/null +++ b/db/schema_migrations/20250828154201 @@ -0,0 +1 @@ +ce599a8032f3d6ae743365703df3b41e1d120679b54c34ef0f24a88b26db4991 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index f81c3477eade48b91b9d551335258dea19b97c7e..732dbdc572795a79af6f1f61a3522bab6d2f46ca 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -13251,6 +13251,7 @@ CREATE TABLE ci_pipeline_schedules ( active boolean DEFAULT true, created_at timestamp without time zone, updated_at timestamp without time zone, + scheduled_pipeline_skip_window integer, CONSTRAINT check_4a0f7b994d CHECK ((project_id IS NOT NULL)) ); diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 2a776e60b2ab1d7337705a13a9b47d66239b2705..fbd4a8b707ac43180f7c265a79c70567be898285 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -10101,6 +10101,7 @@ Input type: `PipelineScheduleCreateInput` | `inputs` {{< icon name="warning-solid" >}} | [`[CiInputsInput!]`](#ciinputsinput) | **Deprecated**: **Status**: Experiment. Introduced in GitLab 17.10. | | `projectPath` | [`ID!`](#id) | Full path of the project the pipeline schedule is associated with. | | `ref` | [`String!`](#string) | Ref of the pipeline schedule. | +| `scheduledPipelineSkipWindow` | [`Int`](#int) | Scheduled pipeline skip window (in seconds). | | `variables` | [`[PipelineScheduleVariableInput!]`](#pipelineschedulevariableinput) | Variables for the pipeline schedule. | #### Fields @@ -10183,6 +10184,7 @@ Input type: `PipelineScheduleUpdateInput` | `id` | [`CiPipelineScheduleID!`](#cipipelinescheduleid) | ID of the pipeline schedule to mutate. | | `inputs` {{< icon name="warning-solid" >}} | [`[CiInputsInput!]`](#ciinputsinput) | **Deprecated**: **Status**: Experiment. Introduced in GitLab 17.11. | | `ref` | [`String`](#string) | Ref of the pipeline schedule. | +| `scheduledPipelineSkipWindow` | [`Int`](#int) | Scheduled pipeline skip window (in seconds). | | `variables` | [`[PipelineScheduleVariableInput!]`](#pipelineschedulevariableinput) | Variables for the pipeline schedule. | #### Fields @@ -38173,6 +38175,7 @@ Represents a pipeline schedule. | `ref` | [`String`](#string) | Ref of the pipeline schedule. | | `refForDisplay` | [`String`](#string) | Git ref for the pipeline schedule. | | `refPath` | [`String`](#string) | Path to the ref that triggered the pipeline. | +| `scheduledPipelineSkipWindow` | [`Int`](#int) | Scheduled pipeline skip window (in seconds). | | `updatedAt` | [`Time!`](#time) | Timestamp of when the pipeline schedule was last updated. | | `userPermissions` | [`PipelineSchedulePermissions!`](#pipelineschedulepermissions) | Permissions for the current user on the resource. | | `variables` | [`PipelineScheduleVariableConnection`](#pipelineschedulevariableconnection) | Pipeline schedule variables. (see [Connections](#connections)) | diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 602843c478d8cb6ccfeeaac23fe6699ed4dfe2de..760413d00621f0cd3eb9aa732222f45822c66a64 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -22103,6 +22103,9 @@ msgstr "" msgid "Define a custom pattern with cron syntax" msgstr "" +msgid "Define a custom pipeline execution window (in seconds)" +msgstr "" + msgid "Define custom rules for what constitutes spam, independent of Akismet" msgstr "" @@ -47613,6 +47616,9 @@ msgstr "" msgid "PipelineSchedules|Cron timezone" msgstr "" +msgid "PipelineSchedules|Define a custom pipeline execution window that allows the pipeline to run for a given amount of time after its scheduled time. This window must be specified in seconds (86,400 seconds equal one day)." +msgstr "" + msgid "PipelineSchedules|Delete scheduled pipeline" msgstr "" @@ -57090,6 +57096,9 @@ msgstr "" msgid "Scheduled a rebase of branch %{branch}." msgstr "" +msgid "Scheduled pipeline skip window" +msgstr "" + msgid "Scheduled pipelines" msgstr "" diff --git a/spec/fixtures/api/schemas/pipeline_schedule.json b/spec/fixtures/api/schemas/pipeline_schedule.json index 1a05770aacd1318ed49d00b99d13867791f5410e..446c94afa185b9243ae43c41049b5177bed3fef9 100644 --- a/spec/fixtures/api/schemas/pipeline_schedule.json +++ b/spec/fixtures/api/schemas/pipeline_schedule.json @@ -16,6 +16,9 @@ "cron_timezone": { "type": "string" }, + "scheduledPipelineSkipWindow": { + "type": "integer" + }, "next_run_at": { "type": "string" }, @@ -96,7 +99,10 @@ "type": "string" }, "public_email": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "id": { "type": "integer" diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js index 9e762265c759c77206afd872e0eeeee97f4194e7..4eaaea2785791df96f63f281315d11200afe6925 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js @@ -323,6 +323,7 @@ describe('Pipeline schedules form', () => { cronTimezone: 'America/New_York', description: 'My schedule', projectPath: 'gitlab-org/gitlab', + scheduledPipelineSkipWindow: null, ref: 'main', variables: updatedVariables, inputs: updatedInputs, @@ -501,6 +502,7 @@ describe('Pipeline schedules form', () => { cronTimezone: schedule.cronTimezone, id: schedule.id, ref: schedule.ref, + scheduledPipelineSkipWindow: null, description: 'Updated schedule', variables: updatedVariables, inputs: updatedInputs, diff --git a/spec/graphql/types/ci/pipeline_schedule_type_spec.rb b/spec/graphql/types/ci/pipeline_schedule_type_spec.rb index f5c6c7ada730d76006e9955f99240848054d4617..a4ab26f99e2b9ed9640835b07a6b1c5aeec2b210 100644 --- a/spec/graphql/types/ci/pipeline_schedule_type_spec.rb +++ b/spec/graphql/types/ci/pipeline_schedule_type_spec.rb @@ -21,6 +21,7 @@ forTag nextRunAt realNextRun + scheduledPipelineSkipWindow cron cronTimezone userPermissions diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb index 878146fc40bdc2f2be1e3a73674eb38bbb62b849..a279b90c70cdc7efa923dd2723581565c9aa0c53 100644 --- a/spec/models/ci/pipeline_schedule_spec.rb +++ b/spec/models/ci/pipeline_schedule_spec.rb @@ -10,6 +10,7 @@ it { is_expected.to belong_to(:project) } it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_numericality_of(:scheduled_pipeline_skip_window) } it { is_expected.to belong_to(:owner) } it { is_expected.to have_many(:pipelines).dependent(:nullify) } @@ -32,6 +33,30 @@ end describe 'validations' do + it 'does allow nil value scheduled_pipeline_skip_window' do + pipeline_schedule = build(:ci_pipeline_schedule, scheduled_pipeline_skip_window: nil, project: project) + + expect(pipeline_schedule).to be_valid + end + + it 'does allow value greater 0 for scheduled_pipeline_skip_window' do + pipeline_schedule = build(:ci_pipeline_schedule, scheduled_pipeline_skip_window: 3600, project: project) + + expect(pipeline_schedule).to be_valid + end + + it 'does allow 0 for scheduled_pipeline_skip_window' do + pipeline_schedule = build(:ci_pipeline_schedule, scheduled_pipeline_skip_window: 0, project: project) + + expect(pipeline_schedule).to be_valid + end + + it 'does not allow negative values for scheduled_pipeline_skip_window' do + pipeline_schedule = build(:ci_pipeline_schedule, scheduled_pipeline_skip_window: -1, project: project) + + expect(pipeline_schedule).not_to be_valid + end + it 'does not allow invalid cron patterns' do pipeline_schedule = build(:ci_pipeline_schedule, cron: '0 0 0 * *', project: project) diff --git a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb index fef18cd2cdb7cb19cd18c50b95b5a1c81bb19e79..fee70153c1e652cff07663cceb0e82be74e8c1e3 100644 --- a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb +++ b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb @@ -505,6 +505,51 @@ def event_on_pipeline(event) expect(pipeline.reload.status).to eq 'pending' end end + + context 'when a delayed job is allowed to be automatically skipped' do + let!(:project_pipeline_schedule) { create(:ci_pipeline_schedule, project: project, scheduled_pipeline_skip_window: 2.days) } + + before do + pipeline.update!(pipeline_schedule: project_pipeline_schedule) + create_build('delayed', **delayed_options, allow_failure: true, stage_idx: 0) + create_build('job', stage_idx: 1) + + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + end + + it 'skips the pipeline when it is out of the window' do + travel_to 3.days.from_now do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ delayed: 'scheduled' }) + enqueue_scheduled('delayed') + end + expect(pipeline.reload.status).to eq 'skipped' + end + + it 'does not skip the pipeline when it within the window' do + travel_to 1.day.from_now do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ delayed: 'scheduled' }) + enqueue_scheduled('delayed') + end + expect(pipeline.reload.status).to eq 'pending' + end + + context 'when derisk feature flag is disabled' do + before do + stub_feature_flags(scheduled_pipeline_skip_window: false) + end + + it 'does not skip the pipeline when it is out of the window' do + travel_to 3.days.from_now do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ delayed: 'scheduled' }) + enqueue_scheduled('delayed') + end + expect(pipeline.reload.status).to eq 'pending' + end + end + end end context 'when an exception is raised during a persistent ref creation' do diff --git a/spec/workers/run_pipeline_schedule_worker_spec.rb b/spec/workers/run_pipeline_schedule_worker_spec.rb index cbf837ac5dda34739aa1eaf2cee4551b8469dd85..146811740e0f47d68af5a56246cb168c164a2945 100644 --- a/spec/workers/run_pipeline_schedule_worker_spec.rb +++ b/spec/workers/run_pipeline_schedule_worker_spec.rb @@ -136,6 +136,29 @@ end end + context "when pipeline is outside of allowed scheduled window" do + let(:error_response) do + instance_double(ServiceResponse, + error?: true, + success?: false, + message: 'Pipeline schedule runnable window exceeded.' + ) + end + + before do + pipeline_schedule.update!(scheduled_pipeline_skip_window: 1) + allow(ServiceResponse).to receive(:error) + .with(message: 'Pipeline schedule runnable window exceeded.') + .and_return(error_response) + allow(Time).to receive(:now).and_return(Time.now.utc + 30.days) + end + + it "returns the a timeout response" do + expect(worker.perform(pipeline_schedule.id, + user.id)).to eq(error_response) + end + end + describe "#run_pipeline_schedule" do let(:create_pipeline_service) { instance_double(Ci::CreatePipelineService, execute: service_response) } let(:service_response) { instance_double(ServiceResponse, payload: pipeline, error?: false) }
+ {{ $options.i18n.scheduledPipelineSkipWindowDescription }} +