[backend] Add pipeline execution schedule policy schema and models
Following the introduction of the pipeline execution policy, which allows for enforcing CI jobs/scripts within triggered pipelines, we plan to extend support for scheduled enforcement.
The feature can be introduced as a new policy type. This will make it easier to enforce limits, keeps the schema simple, and needs fewer conditions in backend code. The schema can be similar to the pipeline execution policy schema, with the following differences:
-
pipeline_config_strategyis not needed because there will only be one strategy. It behaves likeoverride_project_ciexcept that other policies can't be injected into it. - The schema needs schedule conditions containing the cadence and time zone. Later we can add branch filters to it.
Example policy
pipeline_execution_schedule_policy:
- name: test schedule
description: ''
enabled: true
schedule:
cadence: 0 0 * * *
content:
include:
- project: my-group/policy-ci-config
file: policy-ci.yml
Following the pattern of other policy types, the new type needs a model concern ee/app/models/concerns/security/pipeline_execution_schedule_policy.rb and some modifications in the Security::Policy model.
To keep track of scheduled pipeline runs, we need to introduce a new table similar to security_orchestration_policy_rule_schedules, that references security_policies and projects.
Implementation plan
Diff
diff --git a/db/migrate/20241107122451_create_security_pipeline_execution_schedules.rb b/db/migrate/20241107122451_create_security_pipeline_execution_schedules.rb
new file mode 100644
index 000000000000..5f611ca8d8f7
--- /dev/null
+++ b/db/migrate/20241107122451_create_security_pipeline_execution_schedules.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class CreateSecurityPipelineExecutionSchedules < Gitlab::Database::Migration[2.2]
+ milestone '17.6'
+
+ def change
+ create_table :security_pipeline_execution_schedules do |t|
+ t.timestamps_with_timezone null: false
+ t.references :security_policy, foreign_key: { on_delete: :cascade }, null: false, index: false
+ t.references :project, foreign_key: { on_delete: :cascade }, null: false, index: false
+ t.text :cron, null: false, limit: 255
+ t.datetime_with_timezone :next_run_at, null: false
+ end
+ end
+end
diff --git a/ee/app/models/concerns/security/pipeline_execution_schedule_policy.rb b/ee/app/models/concerns/security/pipeline_execution_schedule_policy.rb
new file mode 100644
index 000000000000..738f4977f487
--- /dev/null
+++ b/ee/app/models/concerns/security/pipeline_execution_schedule_policy.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Security
+ module PipelineExecutionSchedulePolicy
+ POLICY_LIMIT = 5
+
+ def active_pipeline_execution_schedule_policies
+ pipeline_execution_policy.select { |config| config[:enabled] }.first(POLICY_LIMIT)
+ end
+
+ def pipeline_execution_schedule_policy
+ policy_by_type(:pipeline_execution_schedule_policy)
+ end
+ end
+end
diff --git a/ee/app/models/security/orchestration_policy_configuration.rb b/ee/app/models/security/orchestration_policy_configuration.rb
index 57f3afcb894d..6fadca07e618 100644
--- a/ee/app/models/security/orchestration_policy_configuration.rb
+++ b/ee/app/models/security/orchestration_policy_configuration.rb
@@ -8,6 +8,7 @@ class OrchestrationPolicyConfiguration < ApplicationRecord
include Security::PipelineExecutionPolicy
include Security::CiComponentPublishingPolicy
include Security::VulnerabilityManagementPolicy
+ include Security::PipelineExecutionSchedulePolicy
include EachBatch
include Gitlab::Utils::StrongMemoize
include FromUnion
diff --git a/ee/app/models/security/pipeline_execution_policy/schedule.rb b/ee/app/models/security/pipeline_execution_policy/schedule.rb
new file mode 100644
index 000000000000..ed39e20fd975
--- /dev/null
+++ b/ee/app/models/security/pipeline_execution_policy/schedule.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Security
+ module PipelineExecutionPolicy
+ class Schedule < ApplicationRecord
+ before_save :set_cron
+
+ include Schedulable
+ include CronSchedulable
+
+ self.table_name = 'security_pipeline_execution_schedules'
+
+ belongs_to :project
+ belongs_to :security_policy, class_name: 'Security::Policy', inverse_of: :security_policy_project_links
+
+ validates :security_policy, uniqueness: { scope: :project_id }
+
+ scope :for_project, ->(project) { where(project: project) }
+ scope :runnable, -> { where("next_run_at < ?", Time.zone.now) }
+
+ private
+
+ def set_cron
+ self.cron = security_policy.schedule
+ end
+
+ def cron_timezone
+ #TODO: Store time zone in DB with default Time.zone.name
+
+ Time.zone.name
+ end
+
+ def worker_cron_expression
+ Settings.cron_jobs['security_pipeline_execution_policies_schedule_worker']['cron']
+ end
+ end
+ end
+end
diff --git a/ee/app/models/security/policy.rb b/ee/app/models/security/policy.rb
index 9129c476f472..80e7e0871f64 100644
--- a/ee/app/models/security/policy.rb
+++ b/ee/app/models/security/policy.rb
@@ -13,7 +13,8 @@ class Policy < ApplicationRecord
approval_policy: %i[actions approval_settings fallback_behavior policy_tuning],
scan_execution_policy: %i[actions],
pipeline_execution_policy: %i[content pipeline_config_strategy suffix],
- vulnerability_management_policy: %i[actions]
+ vulnerability_management_policy: %i[actions],
+ pipeline_execution_schedule_policy: %i[content schedule]
}.freeze
belongs_to :security_orchestration_policy_configuration, class_name: 'Security::OrchestrationPolicyConfiguration'
@@ -25,6 +26,8 @@ class Policy < ApplicationRecord
foreign_key: 'security_policy_id', inverse_of: :security_policy
has_many :security_policy_project_links, class_name: 'Security::PolicyProjectLink',
foreign_key: :security_policy_id, inverse_of: :security_policy
+ has_many :security_pipeline_execution_schedules, class_name: 'Security::PipelineExecutionPolicy::Schedule',
+ foreign_key: :security_policy_id, inverse_of: :security_policy
has_many :projects, through: :security_policy_project_links
@@ -32,7 +35,8 @@ class Policy < ApplicationRecord
approval_policy: 0,
scan_execution_policy: 1,
pipeline_execution_policy: 2,
- vulnerability_management_policy: 3
+ vulnerability_management_policy: 3,
+ pipeline_execution_schedule_policy: 4
}, _prefix: true
validates :security_orchestration_policy_configuration_id,
@@ -93,6 +97,7 @@ def self.delete_by_ids(ids)
def link_project!(project)
transaction do
security_policy_project_links.for_project(project).first_or_create!
+ security_pipeline_execution_schedules.for_project(project).first_or_create! if type_pipeline_execution_schedule_policy?
link_policy_rules_project!(project)
end
end
@@ -100,6 +105,7 @@ def link_project!(project)
def unlink_project!(project)
transaction do
security_policy_project_links.for_project(project).delete_all
+ security_pipeline_execution_schedules.for_project(project).delete_all
unlink_policy_rules_project!(project)
end
end
@@ -171,6 +177,10 @@ def delete_scan_execution_policy_rules
scan_execution_policy_rules.delete_all(:delete_all)
end
+ def schedule
+ content.dig('schedule', 'cadence')
+ end
+
private
def link_policy_rules_project!(project, policy_rules = approval_policy_rules.undeleted)
diff --git a/ee/app/services/security/security_orchestration_policies/persist_policy_service.rb b/ee/app/services/security/security_orchestration_policies/persist_policy_service.rb
index 4d3ea598eb82..2ee2eb145f9a 100644
--- a/ee/app/services/security/security_orchestration_policies/persist_policy_service.rb
+++ b/ee/app/services/security/security_orchestration_policies/persist_policy_service.rb
@@ -7,16 +7,16 @@ class PersistPolicyService
include Gitlab::Loggable
include Gitlab::Utils::StrongMemoize
+ POLICY_TYPE_ALIAS = {
+ scan_result_policy: :approval_policy
+ }.freeze
+
def initialize(policy_configuration:, policies:, policy_type:)
@policy_configuration = policy_configuration
@policies = policies
- @policy_type = case policy_type
- when :approval_policy, :scan_result_policy then :approval_policy
- when :scan_execution_policy then :scan_execution_policy
- when :pipeline_execution_policy then :pipeline_execution_policy
- when :vulnerability_management_policy then :vulnerability_management_policy
- else raise ArgumentError, "unrecognized policy_type"
- end
+ @policy_type = POLICY_TYPE_ALIAS[policy_type] || policy_type
+
+ raise ArgumentError, "unrecognized policy_type" unless Security::Policy.types.symbolize_keys.key?(@policy_type)
end
def execute
@@ -104,6 +104,7 @@ def relation_scope
when :scan_execution_policy then Security::Policy.type_scan_execution_policy
when :pipeline_execution_policy then Security::Policy.type_pipeline_execution_policy
when :vulnerability_management_policy then Security::Policy.type_vulnerability_management_policy
+ when :pipeline_execution_schedule_policy then Security::Policy.type_pipeline_execution_schedule_policy
end
end
end
diff --git a/ee/app/validators/json_schemas/security_orchestration_policy.json b/ee/app/validators/json_schemas/security_orchestration_policy.json
index f9af0db8a48c..a669f6a4e8e0 100644
--- a/ee/app/validators/json_schemas/security_orchestration_policy.json
+++ b/ee/app/validators/json_schemas/security_orchestration_policy.json
@@ -312,6 +312,96 @@
}
}
},
+ "pipeline_execution_schedule_policy": {
+ "type": "array",
+ "description": "Starts pipelines on a schedule with custom pipeline configuration.",
+ "additionalItems": false,
+ "maxItems": 5,
+ "items": {
+ "required": [
+ "name",
+ "enabled",
+ "content",
+ "schedule"
+ ],
+ "type": "object",
+ "properties": {
+ "name": {
+ "description": "Name for the policy.",
+ "minLength": 1,
+ "maxLength": 255,
+ "type": "string"
+ },
+ "description": {
+ "description": "Specifies the longer description of the policy.",
+ "type": "string"
+ },
+ "content": {
+ "description": "Specifies the content of custom configuration.",
+ "type": "object",
+ "properties": {
+ "include": {
+ "type": "array",
+ "maxItems": 1,
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "properties": {
+ "project": {
+ "type": "string"
+ },
+ "file": {
+ "type": "string"
+ },
+ "ref": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "project",
+ "file"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [
+ "include"
+ ],
+ "additionalProperties": false
+ },
+ "schedule": {
+ "description": "Specifies the condition when this policy should be triggered.",
+ "type": "object",
+ "properties": {
+ "cadence": {
+ "description": "Specifies when this policy should schedule a new pipeline with enforced `actions`. Uses cron expression as a format (ie. `0 22 * * 1-5`). Supported only when `type` is set to `schedule`.",
+ "type": "string",
+ "pattern": "(@(yearly|annually|monthly|weekly|daily|midnight|noon|hourly))|(((\\*|(\\-?\\d+\\,?)+)(\\/\\d+)?|last|L|(sun|mon|tue|wed|thu|fri|sat|SUN|MON|TUE|WED|THU|FRI|SAT\\-|\\,)+|(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC|\\-|\\,)+)\\s?){5,6}"
+ },
+ "timezone": {
+ "type": "string",
+ "description": "Time zone to apply to the cadence. Value must be an IANA Time Zone Database identifier, for example: `America/New_York`."
+ }
+ },
+ "required": [
+ "cadence"
+ ],
+ "additionalProperties": false
+ },
+ "policy_scope": {
+ "$ref": "#/$defs/policy_scope"
+ },
+ "metadata": {
+ "$ref": "#/$defs/metadata"
+ },
+ "enabled": {
+ "description": "Whether to enforce this policy or not.",
+ "type": "boolean"
+ }
+ }
+ }
+ },
"scan_execution_policy": {
"type": "array",
"description": "Declares required security scans to be run on a specified schedule or with the project pipeline.",
diff --git a/ee/app/workers/security/persist_security_policies_worker.rb b/ee/app/workers/security/persist_security_policies_worker.rb
index ce0c2461a868..f9b7a578071e 100644
--- a/ee/app/workers/security/persist_security_policies_worker.rb
+++ b/ee/app/workers/security/persist_security_policies_worker.rb
@@ -20,6 +20,11 @@ def perform(configuration_id)
persist_policy(configuration, configuration.scan_execution_policy, :scan_execution_policy)
persist_policy(configuration, configuration.pipeline_execution_policy, :pipeline_execution_policy)
persist_policy(configuration, configuration.vulnerability_management_policy, :vulnerability_management_policy)
+ persist_policy(
+ configuration,
+ configuration.pipeline_execution_schedule_policy,
+ :pipeline_execution_schedule_policy
+ )
end
private