Browse Source

Add support for checksum matching

pull/1124/head
Ross McFarland 2 years ago
parent
commit
780fa2a24b
No known key found for this signature in database GPG Key ID: 943B179E15D3B22A
7 changed files with 126 additions and 5 deletions
  1. +3
    -0
      CHANGELOG.md
  2. +7
    -1
      octodns/cmds/sync.py
  3. +37
    -4
      octodns/manager.py
  4. +4
    -0
      octodns/provider/plan.py
  5. +16
    -0
      octodns/record/change.py
  6. +30
    -0
      tests/test_octodns_manager.py
  7. +29
    -0
      tests/test_octodns_plan.py

+ 3
- 0
CHANGELOG.md View File

@ -1,5 +1,8 @@
## v1.?.0 - 2023-??-?? -
* Beta support for Manager.enable_checksum and octodns-sync --checksum Allows a
safer plan & apply workflow where the apply only moves forward if the apply
phase plan exactly matches the previous round's planning.
* Fix for bug in MetaProcessor _up_to_date check that was failing when there was
a plan with a single change type with a single value, e.g. CNAME.
* Support added for config env variable expansion on nested levels, not just


+ 7
- 1
octodns/cmds/sync.py View File

@ -19,7 +19,7 @@ def main():
'--doit',
action='store_true',
default=False,
help='Whether to take action or just show what would change',
help='Whether to take action or just show what would change, ignored when Manager.enable_checksum is used',
)
parser.add_argument(
'--force',
@ -28,6 +28,11 @@ def main():
help='Acknowledge that significant changes are being '
'made and do them',
)
parser.add_argument(
'--checksum',
default=None,
help="Provide the expected checksum, apply will only continue if it matches the plan's computed checksum",
)
parser.add_argument(
'zone',
@ -60,6 +65,7 @@ def main():
eligible_targets=args.target,
dry_run=not args.doit,
force=args.force,
checksum=args.checksum,
)


+ 37
- 4
octodns/manager.py View File

@ -4,9 +4,11 @@
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from hashlib import sha256
from importlib import import_module
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as module_version
from json import dumps
from logging import getLogger
from os import environ
from sys import stdout
@ -87,7 +89,12 @@ class Manager(object):
return len(plan.changes[0].record.zone.name) if plan.changes else 0
def __init__(
self, config_file, max_workers=None, include_meta=False, auto_arpa=False
self,
config_file,
max_workers=None,
include_meta=False,
auto_arpa=False,
enable_checksum=False,
):
version = self._try_version('octodns', version=__version__)
self.log.info(
@ -108,6 +115,9 @@ class Manager(object):
self.include_meta = self._config_include_meta(
manager_config, include_meta
)
self.enable_checksum = self._config_enable_checksum(
manager_config, enable_checksum
)
self.auto_arpa = self._config_auto_arpa(manager_config, auto_arpa)
@ -195,6 +205,15 @@ class Manager(object):
self.log.info('_config_include_meta: include_meta=%s', include_meta)
return include_meta
def _config_enable_checksum(self, manager_config, enable_checksum=False):
enable_checksum = enable_checksum or manager_config.get(
'enable_checksum', False
)
self.log.info(
'_config_enable_checksum: enable_checksum=%s', enable_checksum
)
return enable_checksum
def _config_auto_arpa(self, manager_config, auto_arpa=False):
auto_arpa = auto_arpa or manager_config.get('auto_arpa', False)
self.log.info('_config_auto_arpa: auto_arpa=%s', auto_arpa)
@ -561,15 +580,16 @@ class Manager(object):
dry_run=True,
force=False,
plan_output_fh=stdout,
checksum=None,
):
self.log.info(
'sync: eligible_zones=%s, eligible_targets=%s, dry_run=%s, '
'force=%s, plan_output_fh=%s',
'sync: eligible_zones=%s, eligible_targets=%s, dry_run=%s, force=%s, plan_output_fh=%s, checksum=%s',
eligible_zones,
eligible_targets,
dry_run,
force,
getattr(plan_output_fh, 'name', plan_output_fh.__class__.__name__),
checksum,
)
zones = self.config['zones']
@ -759,13 +779,26 @@ class Manager(object):
for output in self.plan_outputs.values():
output.run(plans=plans, log=self.plan_log, fh=plan_output_fh)
computed_checksum = None
if plans and self.enable_checksum:
data = [p[1].data for p in plans]
data = dumps(data)
csum = sha256()
csum.update(data.encode('utf-8'))
computed_checksum = csum.hexdigest()
self.log.info('sync: checksum=%s', computed_checksum)
if not force:
self.log.debug('sync: checking safety')
for target, plan in plans:
plan.raise_if_unsafe()
if dry_run:
if dry_run and not checksum:
return 0
elif computed_checksum and computed_checksum != checksum:
raise ManagerException(
f'checksum={checksum} does not match computed={computed_checksum}'
)
total_changes = 0
self.log.debug('sync: applying')


+ 4
- 0
octodns/provider/plan.py View File

@ -78,6 +78,10 @@ class Plan(object):
existing_n,
)
@property
def data(self):
return {'changes': [c.data for c in self.changes]}
def raise_if_unsafe(self):
if (
self.existing


+ 16
- 0
octodns/record/change.py View File

@ -25,6 +25,10 @@ class Create(Change):
def __init__(self, new):
super().__init__(None, new)
@property
def data(self):
return {'type': 'create', 'new': self.new.data}
def __repr__(self, leader=''):
source = self.new.source.id if self.new.source else ''
return f'Create {self.new} ({source})'
@ -33,6 +37,14 @@ class Create(Change):
class Update(Change):
CLASS_ORDERING = 2
@property
def data(self):
return {
'type': 'update',
'existing': self.existing.data,
'new': self.new.data,
}
# Leader is just to allow us to work around heven eating leading whitespace
# in our output. When we call this from the Manager.sync plan summary
# section we'll pass in a leader, otherwise we'll just let it default and
@ -51,5 +63,9 @@ class Delete(Change):
def __init__(self, existing):
super().__init__(existing, None)
@property
def data(self):
return {'type': 'delete', 'existing': self.existing.data}
def __repr__(self, leader=''):
return f'Delete {self.existing}'

+ 30
- 0
tests/test_octodns_manager.py View File

@ -177,6 +177,36 @@ class TestManager(TestCase):
).sync(dry_run=False, force=True)
self.assertEqual(33, tc)
def test_enable_checksum(self):
with TemporaryDirectory() as tmpdir:
environ['YAML_TMP_DIR'] = tmpdir.dirname
environ['YAML_TMP_DIR2'] = tmpdir.dirname
manager = Manager(
get_config_filename('simple.yaml'), enable_checksum=True
)
# initial/dry run is fine w/o checksum
tc = manager.sync(dry_run=True)
self.assertEqual(0, tc)
# trying to apply it fails w/o required checksum
with self.assertRaises(ManagerException) as ctx:
manager.sync(dry_run=False)
msg, checksum = str(ctx.exception).rsplit('=', 1)
self.assertEqual('checksum=None does not match computed', msg)
self.assertTrue(checksum)
# wrong checksum fails
with self.assertRaises(ManagerException) as ctx:
manager.sync(checksum='xyz')
msg, checksum = str(ctx.exception).rsplit('=', 1)
self.assertEqual('checksum=xyz does not match computed', msg)
self.assertTrue(checksum)
# correct checksum applies (w/o dry_run=False)
tc = manager.sync(checksum=checksum)
self.assertEqual(28, tc)
def test_idna_eligible_zones(self):
# loading w/simple, but we'll be blowing it away and doing some manual
# stuff


+ 29
- 0
tests/test_octodns_plan.py View File

@ -295,3 +295,32 @@ class TestPlanSafety(TestCase):
with self.assertRaises(RootNsChange) as ctx:
plan.raise_if_unsafe()
self.assertTrue('Root Ns record change', str(ctx.exception))
def test_data(self):
data = plans[0][1].data
# plans should have a single key, changes
self.assertEqual(('changes',), tuple(data.keys()))
# it should be a list
self.assertIsInstance(data['changes'], list)
# w/4 elements
self.assertEqual(4, len(data['changes']))
# we'll test the change .data's here while we're at it since they don't
# have a dedicated test (file)
delete_data = data['changes'][0] # delete
self.assertEqual(['existing', 'type'], sorted(delete_data.keys()))
self.assertEqual('delete', delete_data['type'])
self.assertEqual(delete.existing.data, delete_data['existing'])
create_data = data['changes'][1] # create
self.assertEqual(['new', 'type'], sorted(create_data.keys()))
self.assertEqual('create', create_data['type'])
self.assertEqual(create.new.data, create_data['new'])
update_data = data['changes'][3] # update
self.assertEqual(
['existing', 'new', 'type'], sorted(update_data.keys())
)
self.assertEqual('update', update_data['type'])
self.assertEqual(update.existing.data, update_data['existing'])
self.assertEqual(update.new.data, update_data['new'])

Loading…
Cancel
Save