From 4d006e94a212f2babce6aeeebaf3eaba3961e4d0 Mon Sep 17 00:00:00 2001 From: Phelps Williams Date: Wed, 15 Jul 2020 18:17:33 -0700 Subject: [PATCH 1/3] Adding environment variable record injection Per the discussion on https://github.com/github/octodns/issues/583 here is a work in progress of environment variable injection for discussion. --- octodns/source/envvar.py | 105 ++++++++++++++++++++++++++++ tests/test_octodns_source_envvar.py | 34 +++++++++ 2 files changed, 139 insertions(+) create mode 100644 octodns/source/envvar.py create mode 100644 tests/test_octodns_source_envvar.py diff --git a/octodns/source/envvar.py b/octodns/source/envvar.py new file mode 100644 index 0000000..b755632 --- /dev/null +++ b/octodns/source/envvar.py @@ -0,0 +1,105 @@ + +import logging +import os + +from ..record import Record +from .base import BaseSource + + +class EnvVarSourceException(Exception): + pass + + +class EnvironmentVariableNotFoundException(EnvVarSourceException): + def __init__(self, data): + super(EnvironmentVariableNotFoundException, self).__init__( + 'Unknown environment variable {}'.format(data)) + + +class EnvVarSource(BaseSource): + ''' + This source allows for environment variables to be embedded at octodns + execution time into zones. Intended to capture artifacts of deployment to + facilitate operational objectives. + + The TXT record generated will only have a single value. + + The record name cannot conflict with any other co-existing sources. If + this occurs, an exception will be thrown. + + Possible use cases include: + - Embedding a version number into a TXT record to monitor update + propagation across authoritative providers. + - Capturing identifying information about the deployment process to + record where and when the zone was updated. + + version: + class: octodns.source.envvar.EnvVarSource + # The environment variable in question, in this example the username + # currently executing octodns + variable: USER + # The TXT record name to embed the value found at the above + # environment variable + record: deployuser + # The TTL of the TXT record (optional, default 60) + ttl: 3600 + + This source is then combined with other sources in the octodns config + file: + + zones: + netflix.com.: + sources: + - yaml + - version + targets: + - ultra + - ns1 + ''' + SUPPORTS_GEO = False + SUPPORTS_DYNAMIC = False + SUPPORTS = set(('TXT')) + + DEFAULT_TTL = 60 + + def __init__(self, id, variable, record, ttl=DEFAULT_TTL): + self.log = logging.getLogger('{}[{}]'.format( + self.__class__.__name__, id)) + self.log.debug('__init__: id=%s, variable=%s, record=%s, ' + 'ttl=%d', id, variable, record, ttl) + super(EnvVarSource, self).__init__(id) + self.envvar = variable + self.record = record + self.ttl = ttl + self.value = None + + def _read_variable(self): + self.value = os.environ.get(self.envvar) + if self.value is None: + raise EnvironmentVariableNotFoundException(self.envvar) + + self.log.debug('_read_variable: successfully loaded var=%s val=%s', + self.envvar, self.value) + + def populate(self, zone, target=False, lenient=False): + self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, + target, lenient) + + # if target: + # TODO: Environment Variable Source cannot act as a target, + # throw exception? + # return + + before = len(zone.records) + + self._read_variable() + + # We don't need to worry about conflicting records here because the + # manager will deconflict sources on our behalf. + payload = {'ttl': self.ttl, 'type': 'TXT', 'values': [self.value]} + record = Record.new(zone, self.record, payload, source=self, + lenient=lenient) + zone.add_record(record, lenient=lenient) + + self.log.info('populate: found %s records, exists=False', + len(zone.records) - before) diff --git a/tests/test_octodns_source_envvar.py b/tests/test_octodns_source_envvar.py new file mode 100644 index 0000000..e562aa0 --- /dev/null +++ b/tests/test_octodns_source_envvar.py @@ -0,0 +1,34 @@ +from six import text_type +from unittest import TestCase +from unittest.mock import patch + +from octodns.source.envvar import EnvVarSource +from octodns.source.envvar import EnvironmentVariableNotFoundException +from octodns.zone import Zone + + +class TestEnvVarSource(TestCase): + + def test_read_variable(self): + envvar = 'OCTODNS_TEST_ENVIRONMENT_VARIABLE' + source = EnvVarSource('testid', envvar, 'recordname', ttl=120) + with self.assertRaises(EnvironmentVariableNotFoundException) as ctx: + source._read_variable() + msg = 'Unknown environment variable {}'.format(envvar) + self.assertEquals(msg, text_type(ctx.exception)) + + with patch.dict('os.environ', {envvar: 'testvalue'}): + source._read_variable() + self.assertEquals(source.value, 'testvalue') + + def test_populate(self): + envvar = 'TEST_VAR' + value = 'somevalue' + record = 'testrecord' + source = EnvVarSource('testid', envvar, record) + zone = Zone('unit.tests.', []) + + with patch.dict('os.environ', {envvar: value}): + source.populate(zone) + + # TODO: Validate zone and record From 0a342aa6c2586a87b8a0a7df74ce85e579a4aa96 Mon Sep 17 00:00:00 2001 From: Phelps Williams Date: Fri, 17 Jul 2020 12:09:20 -0700 Subject: [PATCH 2/3] EnvVar: Integrating review feedback and finishing tests --- octodns/source/envvar.py | 29 ++++++++++++----------------- tests/test_octodns_source_envvar.py | 19 +++++++++++++------ 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/octodns/source/envvar.py b/octodns/source/envvar.py index b755632..adf267a 100644 --- a/octodns/source/envvar.py +++ b/octodns/source/envvar.py @@ -40,7 +40,7 @@ class EnvVarSource(BaseSource): variable: USER # The TXT record name to embed the value found at the above # environment variable - record: deployuser + name: deployuser # The TTL of the TXT record (optional, default 60) ttl: 3600 @@ -62,42 +62,37 @@ class EnvVarSource(BaseSource): DEFAULT_TTL = 60 - def __init__(self, id, variable, record, ttl=DEFAULT_TTL): + def __init__(self, id, variable, name, ttl=DEFAULT_TTL): self.log = logging.getLogger('{}[{}]'.format( self.__class__.__name__, id)) - self.log.debug('__init__: id=%s, variable=%s, record=%s, ' - 'ttl=%d', id, variable, record, ttl) + self.log.debug('__init__: id=%s, variable=%s, name=%s, ' + 'ttl=%d', id, variable, name, ttl) super(EnvVarSource, self).__init__(id) self.envvar = variable - self.record = record + self.name = name self.ttl = ttl - self.value = None def _read_variable(self): - self.value = os.environ.get(self.envvar) - if self.value is None: + value = os.environ.get(self.envvar) + if value is None: raise EnvironmentVariableNotFoundException(self.envvar) self.log.debug('_read_variable: successfully loaded var=%s val=%s', - self.envvar, self.value) + self.envvar, value) + return value def populate(self, zone, target=False, lenient=False): self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, target, lenient) - # if target: - # TODO: Environment Variable Source cannot act as a target, - # throw exception? - # return - before = len(zone.records) - self._read_variable() + value = self._read_variable() # We don't need to worry about conflicting records here because the # manager will deconflict sources on our behalf. - payload = {'ttl': self.ttl, 'type': 'TXT', 'values': [self.value]} - record = Record.new(zone, self.record, payload, source=self, + payload = {'ttl': self.ttl, 'type': 'TXT', 'values': [value]} + record = Record.new(zone, self.name, payload, source=self, lenient=lenient) zone.add_record(record, lenient=lenient) diff --git a/tests/test_octodns_source_envvar.py b/tests/test_octodns_source_envvar.py index e562aa0..0714883 100644 --- a/tests/test_octodns_source_envvar.py +++ b/tests/test_octodns_source_envvar.py @@ -18,17 +18,24 @@ class TestEnvVarSource(TestCase): self.assertEquals(msg, text_type(ctx.exception)) with patch.dict('os.environ', {envvar: 'testvalue'}): - source._read_variable() - self.assertEquals(source.value, 'testvalue') + value = source._read_variable() + self.assertEquals(value, 'testvalue') def test_populate(self): envvar = 'TEST_VAR' value = 'somevalue' - record = 'testrecord' - source = EnvVarSource('testid', envvar, record) - zone = Zone('unit.tests.', []) + name = 'testrecord' + zone_name = 'unit.tests.' + source = EnvVarSource('testid', envvar, name) + zone = Zone(zone_name, []) with patch.dict('os.environ', {envvar: value}): source.populate(zone) - # TODO: Validate zone and record + self.assertEquals(1, len(zone.records)) + record = list(zone.records)[0] + self.assertEquals(name, record.name) + self.assertEquals('{}.{}'.format(name, zone_name), record.fqdn) + self.assertEquals('TXT', record._type) + self.assertEquals(1, len(record.values)) + self.assertEquals(value, record.values[0]) From c75df0d8ed5a7fee2f7ff08d4987cfc390a870d6 Mon Sep 17 00:00:00 2001 From: Phelps Williams Date: Fri, 17 Jul 2020 12:29:17 -0700 Subject: [PATCH 3/3] Adding entry in readme for environment variable support --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d4e7171..995776a 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ The above command pulled the existing data out of Route53 and placed the results | [DnsimpleProvider](/octodns/provider/dnsimple.py) | | All | No | CAA tags restricted | | [DynProvider](/octodns/provider/dyn.py) | dyn | All | Both | | | [EtcHostsProvider](/octodns/provider/etc_hosts.py) | | A, AAAA, ALIAS, CNAME | No | | +| [EnvVarSource](/octodns/source/envvar.py) | | TXT | No | read-only environment variable injection | | [GoogleCloudProvider](/octodns/provider/googlecloud.py) | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | | | [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | | | [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | No CNAME support, missing `NA` geo target |