Merge "Add secret top-level config object" into feature/zuulv3
diff --git a/tests/encrypt_secret.py b/tests/encrypt_secret.py
new file mode 100644
index 0000000..ab45018
--- /dev/null
+++ b/tests/encrypt_secret.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import sys
+import os
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.asymmetric import padding
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives import hashes
+
+FIXTURE_DIR = os.path.join(os.path.dirname(__file__),
+ 'fixtures')
+
+
+def main():
+ private_key_file = os.path.join(FIXTURE_DIR, 'private.pem')
+ with open(private_key_file, "rb") as f:
+ private_key = serialization.load_pem_private_key(
+ f.read(),
+ password=None,
+ backend=default_backend()
+ )
+
+ # Extract public key from private
+ public_key = private_key.public_key()
+
+ # https://cryptography.io/en/stable/hazmat/primitives/asymmetric/rsa/#encryption
+ ciphertext = public_key.encrypt(
+ sys.argv[1],
+ padding.OAEP(
+ mgf=padding.MGF1(algorithm=hashes.SHA1()),
+ algorithm=hashes.SHA1(),
+ label=None
+ )
+ )
+ print(ciphertext.encode('base64'))
+
+if __name__ == '__main__':
+ main()
diff --git a/tests/fixtures/config/ansible/git/common-config/zuul.yaml b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
index 5c6c998..c9fba3e 100644
--- a/tests/fixtures/config/ansible/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
@@ -34,6 +34,21 @@
verified: 0
precedence: high
+- secret:
+ name: test_secret
+ data:
+ username: test-username
+ password: !encrypted/pkcs1 |
+ BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71YUsi1wGZZ
+ L0LveZjUN0t6OU1VZKSG8R5Ly7urjaSo1pPVIq5Rtt/H7W14Lecd+cUeKb4joeusC9drN3AA8a4o
+ ykcVpt1wVqUnTbMGC9ARMCQP6eopcs1l7tzMseprW4RDNhIuz3CRgd0QBMPl6VDoFgBPB8vxtJw+
+ 3m0rqBYZCLZgCXekqlny8s2s92nJMuUABbJOEcDRarzibDsSXsfJt1y+5n7yOURsC7lovMg4GF/v
+ Cl/0YMKjBO5bpv9EM5fToeKYyPGSKQoHOnCYceb3cAVcv5UawcCic8XjhEhp4K7WPdYf2HVAC/qt
+ xhbpjTxG4U5Q/SoppOJ60WqEkQvbXs6n5Dvy7xmph6GWmU/bAv3eUK3pdD3xa2Ue1lHWz3U+rsYr
+ aI+AKYsMYx3RBlfAmCeC1ve2BXPrqnOo7G8tnUvfdYPbK4Aakk0ds/AVqFHEZN+S6hRBmBjLaRFW
+ Z3QSO1NjbBxWnaHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd
+ +150AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZHvIs=
+
- job:
name: python27
pre-run: pre
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
index ee7c6ab..335d7c3 100644
--- a/tests/unit/test_model.py
+++ b/tests/unit/test_model.py
@@ -302,6 +302,29 @@
tenant = model.Tenant('tenant')
layout = model.Layout()
+ conf = yaml.safe_load('''
+- secret:
+ name: pypi-credentials
+ data:
+ username: test-username
+ password: !encrypted/pkcs1 |
+ BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71YUsi1wGZZ
+ L0LveZjUN0t6OU1VZKSG8R5Ly7urjaSo1pPVIq5Rtt/H7W14Lecd+cUeKb4joeusC9drN3AA8a4o
+ ykcVpt1wVqUnTbMGC9ARMCQP6eopcs1l7tzMseprW4RDNhIuz3CRgd0QBMPl6VDoFgBPB8vxtJw+
+ 3m0rqBYZCLZgCXekqlny8s2s92nJMuUABbJOEcDRarzibDsSXsfJt1y+5n7yOURsC7lovMg4GF/v
+ Cl/0YMKjBO5bpv9EM5fToeKYyPGSKQoHOnCYceb3cAVcv5UawcCic8XjhEhp4K7WPdYf2HVAC/qt
+ xhbpjTxG4U5Q/SoppOJ60WqEkQvbXs6n5Dvy7xmph6GWmU/bAv3eUK3pdD3xa2Ue1lHWz3U+rsYr
+ aI+AKYsMYx3RBlfAmCeC1ve2BXPrqnOo7G8tnUvfdYPbK4Aakk0ds/AVqFHEZN+S6hRBmBjLaRFW
+ Z3QSO1NjbBxWnaHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd
+ +150AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZHvIs=
+''')[0]['secret']
+
+ conf['_source_context'] = self.context
+ conf['_start_mark'] = self.start_mark
+
+ secret = configloader.SecretParser.fromYaml(layout, conf)
+ layout.addSecret(secret)
+
base = configloader.JobParser.fromYaml(tenant, layout, {
'_source_context': self.context,
'_start_mark': self.start_mark,
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 8439fc3..ae980ac 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -87,7 +87,7 @@
class ZuulSafeLoader(yaml.SafeLoader):
- zuul_node_types = frozenset(('job', 'nodeset', 'pipeline',
+ zuul_node_types = frozenset(('job', 'nodeset', 'secret', 'pipeline',
'project', 'project-template'))
def __init__(self, stream, context):
@@ -125,6 +125,18 @@
loader.dispose()
+class EncryptedPKCS1(yaml.YAMLObject):
+ yaml_tag = u'!encrypted/pkcs1'
+ yaml_loader = yaml.SafeLoader
+
+ def __init__(self, ciphertext):
+ self.ciphertext = ciphertext
+
+ @classmethod
+ def from_yaml(cls, loader, node):
+ return cls(node.value)
+
+
class NodeSetParser(object):
@staticmethod
def getSchema():
@@ -151,6 +163,28 @@
return ns
+class SecretParser(object):
+ @staticmethod
+ def getSchema():
+ data = {str: vs.Any(str, EncryptedPKCS1)}
+
+ secret = {vs.Required('name'): str,
+ vs.Required('data'): data,
+ '_source_context': model.SourceContext,
+ '_start_mark': yaml.Mark,
+ }
+
+ return vs.Schema(secret)
+
+ @staticmethod
+ def fromYaml(layout, conf):
+ with configuration_exceptions('secret', conf):
+ SecretParser.getSchema()(conf)
+ s = model.Secret(conf['name'])
+ s.secret_data = conf['data']
+ return s
+
+
class JobParser(object):
@staticmethod
def getSchema():
@@ -906,6 +940,9 @@
for config_nodeset in data.nodesets:
layout.addNodeSet(NodeSetParser.fromYaml(layout, config_nodeset))
+ for config_secret in data.secrets:
+ layout.addSecret(SecretParser.fromYaml(layout, config_secret))
+
for config_job in data.jobs:
layout.addJob(JobParser.fromYaml(tenant, layout, config_job))
diff --git a/zuul/model.py b/zuul/model.py
index d9b05f7..0f9e021 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -515,6 +515,35 @@
self.state_time = data['state_time']
+class Secret(object):
+ """A collection of private data.
+
+ In configuration, Secrets are collections of private data in
+ key-value pair format. They are defined as top-level
+ configuration objects and then referenced by Jobs.
+
+ """
+
+ def __init__(self, name):
+ self.name = name
+ # The secret data may or may not be encrypted. This attribute
+ # is named 'secret_data' to make it easy to search for and
+ # spot where it is directly used.
+ self.secret_data = {}
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def __eq__(self, other):
+ if not isinstance(other, Secret):
+ return False
+ return (self.name == other.name and
+ self.secret_data == other.secret_data)
+
+ def __repr__(self):
+ return '<Secret %s>' % (self.name,)
+
+
class SourceContext(object):
"""A reference to the branch of a project in configuration.
@@ -2104,6 +2133,7 @@
self.project_templates = []
self.projects = {}
self.nodesets = []
+ self.secrets = []
def copy(self):
r = UnparsedTenantConfig()
@@ -2112,6 +2142,7 @@
r.project_templates = copy.deepcopy(self.project_templates)
r.projects = copy.deepcopy(self.projects)
r.nodesets = copy.deepcopy(self.nodesets)
+ r.secrets = copy.deepcopy(self.secrets)
return r
def extend(self, conf):
@@ -2122,6 +2153,7 @@
for k, v in conf.projects.items():
self.projects.setdefault(k, []).extend(v)
self.nodesets.extend(conf.nodesets)
+ self.secrets.extend(conf.secrets)
return
if not isinstance(conf, list):
@@ -2150,6 +2182,8 @@
self.pipelines.append(value)
elif key == 'nodeset':
self.nodesets.append(value)
+ elif key == 'secret':
+ self.secrets.append(value)
else:
raise Exception("Configuration item `%s` not recognized "
"(when parsing %s)" %
@@ -2172,6 +2206,7 @@
# inherit from the reference definition.
self.jobs = {'noop': [Job('noop')]}
self.nodesets = {}
+ self.secrets = {}
def getJob(self, name):
if name in self.jobs:
@@ -2205,6 +2240,11 @@
raise Exception("NodeSet %s already defined" % (nodeset.name,))
self.nodesets[nodeset.name] = nodeset
+ def addSecret(self, secret):
+ if secret.name in self.secrets:
+ raise Exception("Secret %s already defined" % (secret.name,))
+ self.secrets[secret.name] = secret
+
def addPipeline(self, pipeline):
self.pipelines[pipeline.name] = pipeline