Document the process and considerations for Security and Build mirrors
We will want the projects in https://gitlab.com/gitlab-org/security to have consistent settings for things like pull mirroring with the security/
prefix, setting the branch_name_regex
push rule, protecting branches, as well as consistent naming and messaging around the fact that it's a security mirror.
I created a spike to give an idea of what I mean, but we should flesh it out and make it a ChatOps command so we can do something like:
[User]: /chatops run security fork gitlab-org/gitlab
[SlackBot]: Forked `gitlab-org/gitlab` to <https://gitlab.com/gitlab-org/security/gitlab|gitlab-org/security/gitlab>.
# frozen_string_literal: true
require 'gitlab'
token = ENV.fetch('GITLAB_TOKEN') do |name|
raise "Define your gitlab.com token in #{name}"
end
CLIENT = Gitlab::Client.new(private_token: token, endpoint: 'https://gitlab.com/api/v4')
NAMESPACE = 'gitlab-org/security'
ACCESS_NONE = 0
ACCESS_MASTER = 40
# Branches not matching these patterns will be deleted
SECURITY_BRANCH_PATTERNS = [
/\Asecurity\/master\z/,
/\Asecurity\/\d+-\d+-stable(-ee)?\z/,
/\Asecurity\/\d+-\d+-auto-deploy-\d+\z/
]
# Same as above but no `security/` prefix
PROTECTED_BRANCH_PATTERNS = [
/\Amaster\z/,
/\A\d+-\d+-stable(-ee)?\z/,
/\A\d+-\d+-auto-deploy-\d+\z/
]
# Protected branch rules
PROTECTED_BRANCHES = {
'master' => ACCESS_MASTER,
'*-auto-deploy-*' => ACCESS_MASTER,
'*-stable' => ACCESS_MASTER,
'*-stable-ee' => ACCESS_MASTER, # `-ee` gets removed for FOSS
# 'security/master' => ACCESS_NONE,
# 'security/master-canonical' => ACCESS_NONE,
# 'security/upstream' => ACCESS_NONE,
# 'security/*-stable-ee' => ACCESS_NONE, # `-ee` gets removed for FOSS
# 'security/*-auto-deploy-*' => ACCESS_NONE
}
source_project = CLIENT.project(ARGV.shift)
def create_fork(source_project)
source_name = source_project.path
puts "Source: #{source_project.path_with_namespace} as #{source_project.path}"
CLIENT.create_fork(
source_project.id,
namespace: NAMESPACE,
path: source_project.path,
name: "🔒 #{source_project.path}",
description: ":lock: Security mirror of #{source_project.path_with_namespace} :lock:"
).tap do |fork_project|
puts "Fork: #{fork_project.path_with_namespace}"
end
rescue Gitlab::Error::Conflict
CLIENT.project("#{NAMESPACE}/#{source_name}").tap do |fork_project|
CLIENT.edit_project(
fork_project.id,
description: ":lock: Security mirror of #{source_project.path_with_namespace} :lock:"
)
puts "Fork: #{fork_project.path_with_namespace}"
end
end
def configure_fork(fork_project)
CLIENT.edit_project(
fork_project.id,
issues_enabled: true,
wiki_enabled: false,
snippets_enabled: false,
container_registry_enabled: false,
public_builds: false,
request_access_enabled: false,
auto_devops_enabled: false,
packages_enabled: false,
only_allow_merge_if_pipeline_succeeds: true,
only_allow_merge_if_all_discussions_are_resolved: true,
# These should default to `private`, but just in case...
visibility: 'private',
repository_access_level: 'private',
merge_requests_access_level: 'private',
builds_access_level: 'private',
# TODO (rspeicher): Configure mirror settings with prefix
# mirror: true,
# mirror_user_id: client.user.id,
# mirror_trigger_builds: true,
# only_mirror_protected_branches: true,
# pull_mirror_branch_prefix: 'security/',
)
end
def default_security_branch(fork_project)
begin
# Branch `security/master` off of `master`
security_master = CLIENT.create_branch(fork_project.id, 'security/master', 'master')
rescue Gitlab::Error::BadRequest
# Already exists, fetch it
security_master = CLIENT.branch(fork_project.id, 'security/master')
end
begin
# Protect the security master
CLIENT.protect_branch(fork_project.id, security_master.name)
rescue Gitlab::Error::Conflict
# Already protected, no-op
end
# Change the default branch to the security master
CLIENT.edit_project(fork_project.id, default_branch: security_master.name)
begin
# Unprotect the normal master
CLIENT.unprotect_branch(fork_project.id, 'master')
rescue Gitlab::Error::NotFound
# Already unprotected, no-op
end
end
def delete_unprotected_branches(fork_project)
CLIENT.branches(fork_project.id, per_page: 100).auto_paginate do |branch|
unless branch.protected
puts "Deleting #{fork_project.path_with_namespace}@#{branch.name}"
begin
CLIENT.delete_branch(fork_project.id, branch.name)
rescue Gitlab::Error::BadRequest
# Probably attempted to delete the default branch; no-op
end
end
end
end
def unprotect_branches(fork_project)
branches = CLIENT.protected_branches(fork_project.id)
branches.each do |branch|
puts "Unprotecting #{branch.name}"
CLIENT.unprotect_branch(fork_project.id, branch.name)
end
end
def delete_invalid_protected_branches(fork_project)
CLIENT.branches(fork_project.id).auto_paginate do |branch|
if PROTECTED_BRANCH_PATTERNS.none? { |p| branch.name.match?(p) }
puts "Deleting #{fork_project.path_with_namespace}@#{branch.name}"
begin
CLIENT.delete_branch(fork_project.id, branch.name)
rescue Gitlab::Error::BadRequest
# Probably attempted to delete the default branch; no-op
end
end
end
end
def protect_branches(fork_project)
PROTECTED_BRANCHES.each do |name, level|
# HACK: FOSS, grumble grumble
name = name.delete_suffix('-ee') if fork_project.path.include?('foss')
puts "Protecting #{name}"
CLIENT.protect_branch(
fork_project.id,
name,
push_access_level: level,
merge_access_level: level
)
end
rescue Gitlab::Error::Conflict
# Already protected, no-op
end
def create_push_rule(fork_project)
CLIENT.add_push_rule(fork_project.id, branch_name_regex: '^security\/.+$')
rescue Gitlab::Error::Unprocessable
# Already exists, no-op
CLIENT.push_rule(fork_project.id)
end
# -----------------------------------------------------------------------------
fork_project = create_fork(source_project)
# default_security_branch(fork_project)
delete_unprotected_branches(fork_project)
unprotect_branches(fork_project)
delete_invalid_protected_branches(fork_project)
protect_branches(fork_project)
# create_push_rule(fork_project)
configure_fork(fork_project)
# TODO: Fork builds will fail unless we transfer CI variables from the source project
# TODO: Protect tags
# TODO: Apparently we can't *remove* a Project avatar via the API, so upload a
# generic lock one.