Persist scan profile mappings (Redis)
What does this MR do and why?
Persists a build_name -> security_scan_profile_id mapping in Redis during pipeline creation so the status update worker (MR 3) can resolve which scan profile triggered a given CI job after the pipeline completes.
Why: The in-memory PipelineContext that tracks which profile triggered each job is garbage collected after pipeline creation. The CI Platform team has stated that p_ci_builds_metadata and p_ci_build_sources should not be used for this (thread). Redis was chosen as the persistence layer (approved by @minac) because production Redis is robust (Sentinel cluster + persistent shards) and if it fails, we simply show an incorrect status until the next pipeline -- acceptable since the whole system would suffer anyway.
Depends on: !227028 (merged) (adds security_scan_profiles as a CI job source)
Implementation details
ScanProfileMappingStore(new:ee/lib/gitlab/ci/pipeline/security_scan_profiles/scan_profile_mapping_store.rb):- New class responsible for all Redis persistence of scan profile mappings
- Defines
REDIS_KEY_PREFIX(scan_profile_builds) andREDIS_TTL(24 hours) constants .redis_key(pipeline_id)class method for consistent key generation.profile_id_for_build(build)class method that reads the profile ID from Redis bybuild.name(used by MR 3's worker).persist_mappings(pipeline:, context:)class method that writes a Redis hash (scan_profile_builds:<pipeline_id>) mappingbuild_name -> profile_idwith a 24-hour TTL viaGitlab::Redis::SharedState. Keyed by job name (not build ID) so retried jobs automatically inherit the mapping since they retain the same name within the pipeline scope- Guards with
context.injected_jobs?to skip Redis writes when no profile jobs exist - Rescues
Redis::BaseErrorand reports viaGitlab::ErrorTrackingto avoid breaking pipeline creation
PipelineContext(ee/lib/gitlab/ci/pipeline/security_scan_profiles/pipeline_context.rb):- Adds
#injected_jobs?instance method to check if any profile-triggered jobs were collected - Adds
#each_injected_job_with_profile_idinstance method that yields(job_name, profile_id)pairs, skipping jobs without aprofile_idin their_security_metadata_contextmetadata
- Adds
CreateCrossDatabaseAssociationschain (ee/lib/ee/gitlab/ci/pipeline/chain/create_cross_database_associations.rb):- Adds
persist_scan_profile_mappingscalled aftercreate_dast_associationsinperform! - Delegates to
ScanProfileMappingStore.persist_mappingspassing the pipeline andcommand.scan_profile_context
- Adds
- Integration test (
ee/spec/services/ci/create_pipeline_service/set_build_sources_spec.rb):- Adds
_security_metadata_context: { profile_id: scan_profile_id }(theMETADATA_KEY) to the test fixture so thePipelineContextcaptures the profile mapping - Adds end-to-end test verifying Redis contains the mapping for profile-triggered builds and not for regular builds
- Adds
How to set up and validate locally
- In GDK, create a group and a project with a repository
- Attach a scan profile (e.g., Secret Detection) to the group and apply it to the project
- Push a commit to the project's default branch to trigger a pipeline
- Verify the profile ID mapping is persisted in Redis:
pipeline = Ci::Pipeline.last
key = Gitlab::Ci::Pipeline::SecurityScanProfiles::ScanProfileMappingStore.redis_key(pipeline.id)
stored = Gitlab::Redis::SharedState.with { |redis| redis.hgetall(key) }
puts stored
# Should show { "<build_name>" => "<profile_id>" } for profile-triggered builds
# Multiple jobs from the same profile will share the same profile_id
# e.g. {"sast-0"=>"60", "semgrep-sast-0"=>"60", "secret-detection-1"=>"61"}- Verify the profile ID can be resolved from Redis:
pipeline = Ci::Pipeline.last
pipeline.builds.each do |build|
profile_id = Gitlab::Ci::Pipeline::SecurityScanProfiles::ScanProfileMappingStore.profile_id_for_build(build)
puts "#{build.name}: profile_id=#{profile_id.inspect}"
end ; nil
# Profile-injected jobs should have a numeric profile_id
# Regular YAML jobs should have profile_id=nil- Verify the TTL is set:
key = Gitlab::Ci::Pipeline::SecurityScanProfiles::ScanProfileMappingStore.redis_key(pipeline.id)
ttl = Gitlab::Redis::SharedState.with { |redis| redis.ttl(key) }
puts "TTL: #{ttl} seconds (#{ttl / 3600.0} hours)"
# Should be close to 86400 (24 hours)MR acceptance checklist
- Tests added for
ScanProfileMappingStore(.redis_key,.profile_id_for_build,.persist_mappings) (ee/spec/lib/gitlab/ci/pipeline/security_scan_profiles/scan_profile_mapping_store_spec.rb) - Tests added for
#injected_jobs?and#each_injected_job_with_profile_idonPipelineContext(ee/spec/lib/gitlab/ci/pipeline/security_scan_profiles/pipeline_context_spec.rb) - Tests added for Redis write via
CreateCrossDatabaseAssociations(ee/spec/lib/gitlab/ci/pipeline/chain/create_cross_database_associations_spec.rb) - Integration test added for end-to-end Redis persistence (
ee/spec/services/ci/create_pipeline_service/set_build_sources_spec.rb) - No database migration needed
- No performance impact (lightweight Redis hash write only for profile-triggered pipelines)
Related issues
- Related to #593130 (closed)
- Related to #591555 (closed) (parent issue)
- Blocks #593132 (closed) (status update worker)
Changelog: added
EE: true