Add per-repo public and private keys

Every project should have a public and private key to encrypt secrets.
Zuul expects them to already exist under /var/lib/zuul/keys on the
scheduler host.  If an operator manages these keys externally, they
should simply be placed there.  If they are not found, Zuul will
create them on startup and store them there so they will be found on
the next run.

The test framework uses a pre-generated keypair most of the time to
save time, however, a test is added to ensure that the auto-generate
code path is run.

Co-Authored-By: James E. Blair <jeblair@redhat.com>
Change-Id: Iedf7ce6ca97fab2a8b800158ed1561e45899bc51
This commit is contained in:
Ricardo Carrillo Cruz 2016-12-02 11:41:58 +00:00 committed by James E. Blair
parent f8aec83b3b
commit 22994f9a09
8 changed files with 259 additions and 12 deletions

View File

@ -5,3 +5,11 @@ mysql-client [test]
mysql-server [test]
libjpeg-dev [test]
zookeeperd [platform:dpkg]
build-essential [platform:dpkg]
gcc [platform:rpm]
libssl-dev [platform:dpkg]
openssl-devel [platform:rpm]
libffi-dev [platform:dpkg]
libffi-devel [platform:rpm]
python-dev [platform:dpkg]
python-devel [platform:rpm]

View File

@ -19,3 +19,4 @@ ansible>=2.0.0.1
kazoo
sqlalchemy
alembic
cryptography>=1.6

View File

@ -50,6 +50,7 @@ import testtools
import testtools.content
import testtools.content_type
from git.exc import NoSuchPathError
import yaml
import zuul.driver.gerrit.gerritsource as gerritsource
import zuul.driver.gerrit.gerritconnection as gerritconnection
@ -1209,6 +1210,11 @@ class ZuulTestCase(BaseTestCase):
different tenant/project layout while using the standard main
configuration.
:cvar bool create_project_keys: Indicates whether Zuul should
auto-generate keys for each project, or whether the test
infrastructure should insert dummy keys to save time during
startup. Defaults to False.
The following are instance variables that are useful within test
methods:
@ -1240,6 +1246,7 @@ class ZuulTestCase(BaseTestCase):
config_file = 'zuul.conf'
run_ansible = False
create_project_keys = False
def _startMerger(self):
self.merge_server = zuul.merger.server.MergeServer(self.config,
@ -1434,6 +1441,39 @@ class ZuulTestCase(BaseTestCase):
project = reponame.replace('_', '/')
self.copyDirToRepo(project,
os.path.join(git_path, reponame))
self.setupAllProjectKeys()
def setupAllProjectKeys(self):
if self.create_project_keys:
return
path = self.config.get('zuul', 'tenant_config')
with open(os.path.join(FIXTURE_DIR, path)) as f:
tenant_config = yaml.safe_load(f.read())
for tenant in tenant_config:
sources = tenant['tenant']['source']
for source, conf in sources.items():
for project in conf.get('config-repos', []):
self.setupProjectKeys(source, project)
for project in conf.get('project-repos', []):
self.setupProjectKeys(source, project)
def setupProjectKeys(self, source, project):
# Make sure we set up an RSA key for the project so that we
# don't spend time generating one:
key_root = os.path.join(self.state_root, 'keys')
if not os.path.isdir(key_root):
os.mkdir(key_root, 0o700)
private_key_file = os.path.join(key_root, source, project + '.pem')
private_key_dir = os.path.dirname(private_key_file)
self.log.debug("Installing test keys for project %s at %s" % (
project, private_key_file))
if not os.path.isdir(private_key_dir):
os.makedirs(private_key_dir)
with open(os.path.join(FIXTURE_DIR, 'private.pem')) as i:
with open(private_key_file, 'w') as o:
o.write(i.read())
def setupZK(self):
self.zk_chroot_fixture = self.useFixture(ChrootedKazooFixture())
@ -1469,6 +1509,22 @@ class ZuulTestCase(BaseTestCase):
self.assertFalse(node['_lock'], "Node %s is locked" %
(node['_oid'],))
def assertNoGeneratedKeys(self):
# Make sure that Zuul did not generate any project keys
# (unless it was supposed to).
if self.create_project_keys:
return
with open(os.path.join(FIXTURE_DIR, 'private.pem')) as i:
test_key = i.read()
key_root = os.path.join(self.state_root, 'keys')
for root, dirname, files in os.walk(key_root):
for fn in files:
with open(os.path.join(root, fn)) as f:
self.assertEqual(test_key, f.read())
def assertFinalState(self):
# Make sure that git.Repo objects have been garbage collected.
repos = []
@ -1480,6 +1536,7 @@ class ZuulTestCase(BaseTestCase):
self.assertEqual(len(repos), 0)
self.assertEmptyQueues()
self.assertNodepoolState()
self.assertNoGeneratedKeys()
ipm = zuul.manager.independent.IndependentPipelineManager
for tenant in self.sched.abide.tenants.values():
for pipeline in tenant.layout.pipelines.values():
@ -1846,6 +1903,7 @@ class ZuulTestCase(BaseTestCase):
f.close()
self.config.set('zuul', 'tenant_config',
os.path.join(FIXTURE_DIR, f.name))
self.setupAllProjectKeys()
def addCommitToRepo(self, project, message, files,
branch='master', tag=None):

51
tests/fixtures/private.pem vendored Normal file
View File

@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----

View File

@ -17,10 +17,12 @@
import os
import textwrap
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import testtools
import zuul.configloader
from tests.base import AnsibleZuulTestCase, ZuulTestCase
from tests.base import AnsibleZuulTestCase, ZuulTestCase, FIXTURE_DIR
class TestMultipleTenants(AnsibleZuulTestCase):
@ -303,3 +305,36 @@ class TestBrokenConfig(ZuulTestCase):
def test_broken_config_on_startup(self):
pass
class TestProjectKeys(ZuulTestCase):
# Test that we can generate project keys
# Normally the test infrastructure copies a static key in place
# for each project before starting tests. This saves time because
# Zuul's automatic key-generation on startup can be slow. To make
# sure we exercise that code, in this test we allow Zuul to create
# keys for the project on startup.
create_project_keys = True
tenant_config_file = 'config/in-repo/main.yaml'
def test_key_generation(self):
key_root = os.path.join(self.state_root, 'keys')
private_key_file = os.path.join(key_root, 'gerrit/org/project.pem')
# Make sure that a proper key was created on startup
with open(private_key_file, "rb") as f:
private_key = serialization.load_pem_private_key(
f.read(),
password=None,
backend=default_backend()
)
with open(os.path.join(FIXTURE_DIR, 'private.pem')) as i:
fixture_private_key = i.read()
# Make sure that we didn't just end up with the static fixture
# key
self.assertNotEqual(fixture_private_key, private_key)
# Make sure it's the right length
self.assertEqual(4096, private_key.key_size)

View File

@ -21,6 +21,9 @@ import textwrap
import voluptuous as vs
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from zuul import model
import zuul.manager.dependent
import zuul.manager.independent
@ -465,6 +468,7 @@ class ProjectParser(object):
project_pipeline.queue_name = queue_name
if pipeline_defined:
project.pipelines[pipeline.name] = project_pipeline
return project
@ -673,13 +677,15 @@ class TenantParser(object):
return vs.Schema(tenant)
@staticmethod
def fromYaml(base, connections, scheduler, merger, conf, cached):
def fromYaml(base, project_key_dir, connections, scheduler, merger, conf,
cached):
TenantParser.getSchema(connections)(conf)
tenant = model.Tenant(conf['name'])
tenant.unparsed_config = conf
unparsed_config = model.UnparsedTenantConfig()
tenant.config_repos, tenant.project_repos = \
TenantParser._loadTenantConfigRepos(connections, conf)
TenantParser._loadTenantConfigRepos(
project_key_dir, connections, conf)
for source, repo in tenant.config_repos:
tenant.addConfigRepo(source, repo)
for source, repo in tenant.project_repos:
@ -699,7 +705,70 @@ class TenantParser(object):
return tenant
@staticmethod
def _loadTenantConfigRepos(connections, conf_tenant):
def _loadProjectKeys(project_key_dir, connection_name, project):
project.private_key_file = (
os.path.join(project_key_dir, connection_name,
project.name + '.pem'))
TenantParser._generateKeys(project)
TenantParser._loadKeys(project)
@staticmethod
def _generateKeys(project):
if os.path.isfile(project.private_key_file):
return
key_dir = os.path.dirname(project.private_key_file)
if not os.path.isdir(key_dir):
os.makedirs(key_dir)
TenantParser.log.info(
"Generating RSA keypair for project %s" % (project.name,)
)
# Generate private RSA key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
backend=default_backend()
)
# Serialize private key
pem_private_key = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
TenantParser.log.info(
"Saving RSA keypair for project %s to %s" % (
project.name, project.private_key_file)
)
# Dump keys to filesystem
with open(project.private_key_file, 'wb') as f:
f.write(pem_private_key)
@staticmethod
def _loadKeys(project):
# Check the key files specified are there
if not os.path.isfile(project.private_key_file):
raise Exception(
'Private key file {0} not found'.format(
project.private_key_file))
# Load private key
with open(project.private_key_file, "rb") as f:
project.private_key = serialization.load_pem_private_key(
f.read(),
password=None,
backend=default_backend()
)
# Extract public key from private
project.public_key = project.private_key.public_key()
@staticmethod
def _loadTenantConfigRepos(project_key_dir, connections, conf_tenant):
config_repos = []
project_repos = []
@ -708,10 +777,14 @@ class TenantParser(object):
for conf_repo in conf_source.get('config-repos', []):
project = source.getProject(conf_repo)
TenantParser._loadProjectKeys(
project_key_dir, source_name, project)
config_repos.append((source, project))
for conf_repo in conf_source.get('project-repos', []):
project = source.getProject(conf_repo)
TenantParser._loadProjectKeys(
project_key_dir, source_name, project)
project_repos.append((source, project))
return config_repos, project_repos
@ -861,7 +934,8 @@ class ConfigLoader(object):
config_path)
return config_path
def loadConfig(self, config_path, scheduler, merger, connections):
def loadConfig(self, config_path, project_key_dir, scheduler, merger,
connections):
abide = model.Abide()
config_path = self.expandConfigPath(config_path)
@ -874,13 +948,14 @@ class ConfigLoader(object):
for conf_tenant in config.tenants:
# When performing a full reload, do not use cached data.
tenant = TenantParser.fromYaml(base, connections, scheduler,
merger, conf_tenant, cached=False)
tenant = TenantParser.fromYaml(
base, project_key_dir, connections, scheduler, merger,
conf_tenant, cached=False)
abide.tenants[tenant.name] = tenant
return abide
def reloadTenant(self, config_path, scheduler, merger, connections,
abide, tenant):
def reloadTenant(self, config_path, project_key_dir, scheduler,
merger, connections, abide, tenant):
new_abide = model.Abide()
new_abide.tenants = abide.tenants.copy()
@ -888,9 +963,9 @@ class ConfigLoader(object):
base = os.path.dirname(os.path.realpath(config_path))
# When reloading a tenant only, use cached data if available.
new_tenant = TenantParser.fromYaml(base, connections, scheduler,
merger, tenant.unparsed_config,
cached=True)
new_tenant = TenantParser.fromYaml(
base, project_key_dir, connections, scheduler, merger,
tenant.unparsed_config, cached=True)
new_abide.tenants[tenant.name] = new_tenant
return new_abide

View File

@ -2084,6 +2084,7 @@ class ProjectConfig(object):
self.name = name
self.merge_mode = None
self.pipelines = {}
self.private_key_file = None
class UnparsedAbideConfig(object):

View File

@ -452,6 +452,22 @@ class Scheduler(threading.Thread):
os.mkdir(d)
return d
def _get_project_key_dir(self):
if self.config.has_option('zuul', 'state_dir'):
state_dir = os.path.expanduser(self.config.get('zuul',
'state_dir'))
else:
state_dir = '/var/lib/zuul'
key_dir = os.path.join(state_dir, 'keys')
if not os.path.exists(key_dir):
os.mkdir(key_dir, 0o700)
st = os.stat(key_dir)
mode = st.st_mode & 0o777
if mode != 0o700:
raise Exception("Project key directory %s must be mode 0700; "
"current mode is %o" % (key_dir, mode))
return key_dir
def _save_queue(self):
pickle_file = self._get_queue_pickle_file()
events = []
@ -507,6 +523,7 @@ class Scheduler(threading.Thread):
loader = configloader.ConfigLoader()
abide = loader.loadConfig(
self.config.get('zuul', 'tenant_config'),
self._get_project_key_dir(),
self, self.merger, self.connections)
for tenant in abide.tenants.values():
self._reconfigureTenant(tenant)
@ -523,6 +540,7 @@ class Scheduler(threading.Thread):
loader = configloader.ConfigLoader()
abide = loader.reloadTenant(
self.config.get('zuul', 'tenant_config'),
self._get_project_key_dir(),
self, self.merger, self.connections,
self.abide, event.tenant)
tenant = abide.tenants[event.tenant.name]