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) and REDIS_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 by build.name (used by MR 3's worker)
    • .persist_mappings(pipeline:, context:) class method that writes a Redis hash (scan_profile_builds:<pipeline_id>) mapping build_name -> profile_id with a 24-hour TTL via Gitlab::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::BaseError and reports via Gitlab::ErrorTracking to 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_id instance method that yields (job_name, profile_id) pairs, skipping jobs without a profile_id in their _security_metadata_context metadata
  • CreateCrossDatabaseAssociations chain (ee/lib/ee/gitlab/ci/pipeline/chain/create_cross_database_associations.rb):
    • Adds persist_scan_profile_mappings called after create_dast_associations in perform!
    • Delegates to ScanProfileMappingStore.persist_mappings passing the pipeline and command.scan_profile_context
  • Integration test (ee/spec/services/ci/create_pipeline_service/set_build_sources_spec.rb):
    • Adds _security_metadata_context: { profile_id: scan_profile_id } (the METADATA_KEY) to the test fixture so the PipelineContext captures the profile mapping
    • Adds end-to-end test verifying Redis contains the mapping for profile-triggered builds and not for regular builds

How to set up and validate locally

  1. In GDK, create a group and a project with a repository
  2. Attach a scan profile (e.g., Secret Detection) to the group and apply it to the project
  3. Push a commit to the project's default branch to trigger a pipeline
  4. 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"}
  1. 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
  1. 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_id on PipelineContext (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)

Changelog: added
EE: true

Edited by Nicolae Rotaru

Merge request reports

Loading
Loading