#!/usr/bin/env python3
"""Offline tests for maintenance isolation, selection, inventory and protections."""
import contextlib
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
import test_maintenance_review as prior
MAINT = prior.MAINT
BASH = prior.BASH
sdputils = prior.sdputils
sys.path.insert(0, str(MAINT))
import file_inventory
class Workspaces(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='sdp-workspace-test-')
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.script = self.root / 'runner.sh'
self.script.write_text('''#!/bin/bash
source "$1" || exit 1
MAINT_DIR=$2
P4TMP=$3
_sdp_on_exit() { echo "$1" > "$MAINT_DIR/exit.$4"; }
maintenance_workspace_init "$4" || exit $?
echo "$PWD"
echo report > clients.txt
read -r ignored
exit "${TEST_EXIT:-0}"
'''.replace('"$MAINT_DIR/exit.$4"', '"$MAINT_DIR/exit"'))
def start(self, instance='1', exitcode=0):
process = subprocess.Popen([BASH, str(self.script), str(MAINT / 'maintenance_workspace.sh'),
str(self.root), str(self.root / instance), instance],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, env=dict(os.environ, TEST_EXIT=str(exitcode)))
self.addCleanup(lambda: process.poll() is None and process.kill())
return process
def test_same_instance_excluded_other_instance_independent_cleanup_and_status(self):
first = self.start(exitcode=7)
self.assertIn('Maintenance run directory:', first.stdout.readline())
first_dir = Path(first.stdout.readline().strip())
self.assertTrue(first_dir.is_dir())
blocked = self.start()
out, err = blocked.communicate(timeout=10)
self.assertEqual(blocked.returncode, 75, err)
self.assertIn('Another maintenance run', out)
second = self.start('2')
self.assertIn('Maintenance run directory:', second.stdout.readline())
second_dir = Path(second.stdout.readline().strip())
self.assertNotEqual(first_dir, second_dir)
self.assertTrue((first_dir / 'clients.txt').exists())
second.communicate('\n', timeout=10)
self.assertFalse(second_dir.exists())
self.assertTrue(first_dir.exists())
first.communicate('\n', timeout=10)
self.assertEqual(first.returncode, 7)
self.assertEqual((self.root / 'exit').read_text().strip(), '7')
self.assertFalse(first_dir.exists())
# Kernel lock releases despite the persistent lock filename.
again = self.start()
self.assertIn('Maintenance run directory:', again.stdout.readline())
again.communicate('\n', timeout=10)
self.assertEqual(again.returncode, 0)
def test_workspace_setup_failure_does_not_remove_unowned_directory(self):
path = self.root / '1'
path.write_text('keep')
result = self.start()
out, err = result.communicate(timeout=10)
self.assertNotEqual(result.returncode, 0, out + err)
self.assertEqual(path.read_text(), 'keep')
def test_wrappers_share_helper_keep_limit_and_server_lock_action(self):
for name in ['maintenance', 'edge_maintenance']:
text = (MAINT / name).read_text()
self.assertIn('maintenance_workspace_init "$P4INSTANCE"', text)
self.assertIn('--limit 200 --clients-file client_snapshot.json', text)
self.assertIn('rm -rf "/p4/${P4INSTANCE}/root/server.locks"', text)
class Selection(unittest.TestCase):
NOW = 2_000_000_000
WEEK = 604800
def records(self):
return [{'client': 'z-old', 'Access': str(self.NOW - 5*self.WEEK)},
{'client': 'at-cutoff', 'Access': str(self.NOW - 4*self.WEEK)},
{'client': 'recent', 'Access': str(self.NOW - 4*self.WEEK + 1)},
{'client': 'a-SWARM-old', 'Access': '1'},
{'client': 'invalid', 'Access': 'bad'},
{'client': 'z-old', 'Access': '1'}]
def test_snapshot_preserves_server_order_age_boundary_and_swarm_exclusion(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'clients.json'
sdputils.write_client_snapshot(path, '1', 'server', 4, self.records(), self.NOW)
self.assertEqual(sdputils.read_client_snapshot(path, '1', 'server', 4, self.NOW),
['z-old', 'at-cutoff'])
def test_snapshot_rejects_other_instance_policy_server_stale_future_or_active(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'clients.json'
sdputils.write_client_snapshot(path, '1', 'server', 4, self.records(), self.NOW)
data = json.loads(path.read_text())
invalid = [('instance', '2'), ('server', 'different'), ('weeks', 5),
('generated_at', self.NOW - 3601), ('generated_at', self.NOW + 1),
('clients', [{'client': 'active', 'access': self.NOW}]),
('clients', [{'client': 'bad\nname', 'access': 1}]),
('clients', [{'client': 'x', 'access': True}])]
for key, value in invalid:
with self.subTest(key=key, value=value):
path.write_text(json.dumps(dict(data, **{key: value})))
with self.assertRaises(ValueError):
sdputils.read_client_snapshot(path, '1', 'server', 4, self.NOW)
def test_snapshot_unload_rechecks_candidates_and_keeps_200_cap(self):
with tempfile.TemporaryDirectory() as tmp, prior.module('unload_clients') as mod:
path = Path(tmp) / 'clients.json'
records = [{'client': str(index), 'Access': '1'} for index in range(250)]
sdputils.write_client_snapshot(path, '1', 'test:1666', 4, records)
api = Mock()
api.run.side_effect = lambda *args: [{'client': args[-1], 'Access': '1'}] if args[0] == 'clients' else []
with patch.object(mod, 'connect_p4', return_value=api):
self.assertEqual(mod.main(['1', '--limit', '200', '--clients-file', str(path)]), 0)
self.assertEqual(api.run.call_count, 400)
self.assertEqual([call.args[0] for call in api.run.call_args_list], ['clients', 'unload'] * 200)
self.assertEqual(api.run.call_args_list[0].args[-1], '0')
self.assertEqual(api.run.call_args_list[-1].args[-1], '199')
api.disconnect.assert_called_once()
def test_snapshot_rechecks_each_client_after_previous_mutation_and_fills_limit_after_skips(self):
with prior.module('unload_clients') as mod:
api = Mock()
unloaded = []
def command(*args):
if args[0] == 'clients':
# Client second became active while the first was unloaded.
access = self.NOW if args[-1] == 'second' and unloaded else 1
return [{'client': args[-1], 'Access': str(access)}]
unloaded.append(args[-1])
return []
api.run.side_effect = command
with patch.object(mod.time, 'time', return_value=self.NOW):
self.assertTrue(mod.unload_snapshot_clients(api, ['first', 'second', 'third', 'fourth'], 4, 2))
self.assertEqual(unloaded, ['first', 'third'])
self.assertNotIn(('clients', '-e', 'fourth'), [call.args for call in api.run.call_args_list])
def test_snapshot_recheck_ignores_missing_other_name_and_invalid_access(self):
with prior.module('unload_clients') as mod:
api = Mock()
api.run.side_effect = [[], [{'client': 'different', 'Access': '1'}],
[{'client': 'invalid', 'Access': 'bad'}]]
self.assertTrue(mod.unload_snapshot_clients(api, ['missing', 'pattern', 'invalid'], 4))
self.assertTrue(all(call.args[0] == 'clients' for call in api.run.call_args_list))
def test_invalid_snapshot_never_unloads(self):
with tempfile.TemporaryDirectory() as tmp, prior.module('unload_clients') as mod:
path = Path(tmp) / 'clients.json'
path.write_text('{}')
api = Mock()
with patch.object(mod, 'connect_p4', return_value=api), self.assertRaises(ValueError):
mod.main(['1', '--clients-file', str(path)])
api.run.assert_not_called()
api.disconnect.assert_called_once()
def test_report_queries_clients_once_and_publishes_only_after_all_queries_succeed(self):
with tempfile.TemporaryDirectory() as tmp, prior.module('accessdates') as mod:
path = Path(tmp) / 'clients.json'
api = Mock()
api.run.side_effect = lambda cmd: self.records() if cmd == 'clients' else []
with patch.object(mod, 'connect_p4', return_value=api), patch.object(mod, 'write_list'), patch.object(mod.time, 'time', return_value=self.NOW):
self.assertEqual(mod.main(['1', '--client-snapshot', str(path)]), 0)
self.assertEqual([c.args for c in api.run.call_args_list].count(('clients',)), 1)
previous = path.read_bytes()
api.run.side_effect = lambda cmd: (_ for _ in ()).throw(prior.P4Exception('labels denied')) if cmd == 'labels' else []
with patch.object(mod, 'connect_p4', return_value=api), patch.object(mod, 'write_list'), self.assertRaises(prior.P4Exception):
mod.main(['1', '--client-snapshot', str(path)])
self.assertEqual(path.read_bytes(), previous)
class Notifications(unittest.TestCase):
def test_single_client_scan_cached_owner_lookup_and_same_threshold_messages(self):
now, week = 2_000_000_000, 604800
records = [{'client': 'two-weeks', 'Access': str(now - 2*week), 'Owner': 'owner'},
{'client': 'one-week', 'Access': str(now - 3*week), 'Owner': 'owner'},
{'client': 'swarm-internal', 'Access': '1', 'Owner': 'owner'},
{'client': 'recent', 'Access': str(now), 'Owner': 'owner'}]
with prior.module('email_pending_client_deletes') as mod:
api = Mock()
api.run.side_effect = lambda *args: records if args[0] == 'clients' else [{'Email': '[email protected]'}]
with patch.object(mod.time, 'time', return_value=now), patch.object(mod.smtplib, 'SMTP') as smtp, patch.object(mod, '_send_warning') as send:
mod.warn_users(api, 2, 3, 'smtp', '[email protected]', '', None, 'test')
self.assertEqual([c.args for c in api.run.call_args_list], [('clients', '-a'), ('user', '-o', 'owner')])
self.assertEqual([(c.args[4], c.args[5]) for c in send.call_args_list],
[('two-weeks', 'two weeks'), ('one-week', 'one week')])
smtp.assert_called_once_with('smtp')
def test_no_candidates_does_not_connect_to_mail_server(self):
with prior.module('email_pending_client_deletes') as mod:
api = Mock()
api.run.return_value = []
with patch.object(mod.smtplib, 'SMTP') as smtp:
mod.warn_users(api, 2, 3, 'smtp', '', '', None, 'test')
api.run.assert_called_once_with('clients', '-a')
smtp.assert_not_called()
def test_missing_list_owner_falls_back_to_spec_and_caches_user(self):
with prior.module('email_pending_client_deletes') as mod:
api = Mock()
api.run.side_effect = [[{'Owner': 'alice'}], [{'Email': '[email protected]'}], [{'Owner': 'alice'}]]
cache = {}
self.assertEqual(mod._get_owner_email(api, 'a', email_cache=cache), '[email protected]')
self.assertEqual(mod._get_owner_email(api, 'b', email_cache=cache), '[email protected]')
self.assertEqual(api.run.call_count, 3)
class Inventory(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='sdp-inventory-test-')
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.output = self.root / 'p4_file_list.txt'
self.output.write_text('previous\n')
self.fixture = self.root / 'fixture.json'
self.fake = self.root / 'p4'
self.fake.write_text('#!' + sys.executable + '\n' + '''import json, marshal, sys
from pathlib import Path
record = json.loads(Path(__file__).with_name('fixture.json').read_text())[' '.join(sys.argv[3:])]
if 'raw' in record:
sys.stdout.buffer.write(record['raw'].encode())
else:
for item in record.get('records', []):
marshal.dump({key.encode(): value.encode() if isinstance(value, str) else value for key, value in item.items()}, sys.stdout.buffer)
if record.get('stderr'): print(record['stderr'], file=sys.stderr)
sys.exit(record.get('rc', 0))
''')
self.fake.chmod(0o755)
self.data = {
'depots': {'records': [{'code': 'stat', 'name': 'depot'}]},
'files //depot/*': {'records': [{'code': 'stat', 'depotFile': '//depot/root file'}]},
'dirs -D //depot/*': {'records': [{'code': 'stat', 'dir': '//depot/space directory'},
{'code': 'stat', 'dir': '//depot/deleted'}]},
'files //depot/space directory/...': {'records': [{'code': 'stat', 'depotFile': '//depot/space directory/deep/file'}]},
'files //depot/deleted/...': {'records': [{'code': 'stat', 'depotFile': '//depot/deleted/old', 'action': 'delete'}]},
}
def run_inventory(self):
self.fixture.write_text(json.dumps(self.data))
return file_inventory.create_inventory(str(self.fake), self.output)
def test_complete_root_space_recursive_deleted_paths_and_preserved_output_mode(self):
self.output.chmod(0o640)
self.assertEqual(self.run_inventory(), 3)
self.assertEqual(self.output.read_text().splitlines(),
['//depot/root file', '//depot/space directory/deep/file', '//depot/deleted/old'])
self.assertEqual(self.output.stat().st_mode & 0o777, 0o640)
self.assertEqual(list(self.root.glob('.p4_file_list.txt.*')), [])
def test_empty_server_publishes_empty_list(self):
self.data = {'depots': {'records': []}}
self.assertEqual(self.run_inventory(), 0)
self.assertEqual(self.output.read_text(), '')
def test_no_matching_files_warning_is_valid_empty(self):
empty = {'records': [{'code': 'error', 'generic': 17, 'severity': 2, 'data': 'no such file(s)'}], 'rc': 1}
self.data['files //depot/*'] = empty
self.data['dirs -D //depot/*'] = empty
self.assertEqual(self.run_inventory(), 0)
self.assertEqual(self.output.read_text(), '')
def test_late_permission_failure_preserves_previous_list_and_exit_status(self):
self.data['files //depot/deleted/...'] = {'records': [{'code': 'error', 'generic': 6, 'severity': 3, 'data': 'permission denied'}], 'rc': 7}
with self.assertRaises(file_inventory.InventoryError) as result:
self.run_inventory()
self.assertEqual(result.exception.returncode, 7)
self.assertIn('permission denied', str(result.exception))
self.assertEqual(self.output.read_text(), 'previous\n')
self.assertEqual(list(self.root.glob('.p4_file_list.txt.*')), [])
def test_unknown_empty_error_is_not_silently_accepted(self):
self.data['files //depot/*'] = {'records': [{'code': 'error', 'generic': 17, 'severity': 3, 'data': 'hard error'}], 'rc': 1}
with self.assertRaises(file_inventory.InventoryError):
self.run_inventory()
self.assertEqual(self.output.read_text(), 'previous\n')
def test_truncated_marshaled_output_preserves_previous_list(self):
self.data['dirs -D //depot/*'] = {'raw': '{'}
with self.assertRaises(file_inventory.InventoryError):
self.run_inventory()
self.assertEqual(self.output.read_text(), 'previous\n')
def test_publication_failure_preserves_previous_list_and_cleans_temporary(self):
with patch.object(file_inventory.os, 'replace', side_effect=PermissionError('publish denied')):
with self.assertRaises(PermissionError):
self.run_inventory()
self.assertEqual(self.output.read_text(), 'previous\n')
self.assertEqual(list(self.root.glob('.p4_file_list.txt.*')), [])
def test_missing_filename_refuses_partial_publication(self):
self.data['files //depot/*'] = {'records': [{'code': 'stat', 'wrong': 'field'}]}
with self.assertRaises(file_inventory.InventoryError):
self.run_inventory()
self.assertEqual(self.output.read_text(), 'previous\n')
class Protections(unittest.TestCase):
def test_selected_group_removed_for_all_hosts_others_preserved_byte_for_byte(self):
source = ('# Comment group target * //...\r\nProtections:\r\n'
'\tread group target * //...\r\n'
'\twrite group target 10.2.0.0/16 //depot/...\r\n'
'\tread group "two words" proxy-10.0.0.1 "-//depot/space path/..."\r\n'
'\tread group keeper 10.0.0.1 //...\r\n'
'\tread user target * //...\r\n'
'\tread group target * "unterminated\r\n'
'\tread group last * //...')
expected = ('# Comment group target * //...\r\nProtections:\r\n'
'\tread group keeper 10.0.0.1 //...\r\n'
'\tread user target * //...\r\n'
'\tread group target * "unterminated\r\n'
'\tread group last * //...')
with tempfile.TemporaryDirectory() as tmp, prior.module('clean_protect') as mod:
groups, protect = Path(tmp) / 'groups', Path(tmp) / 'protect'
groups.write_text('# ignore\nTARGET\ntwo words\n')
protect.write_bytes(source.encode())
output = io.StringIO(newline='')
with patch.object(sys, 'argv', ['clean_protect.py', str(groups), str(protect)]), contextlib.redirect_stdout(output):
mod.main()
self.assertEqual(output.getvalue(), expected)
class ScratchFiles(unittest.TestCase):
@contextlib.contextmanager
def working_directory(self):
with tempfile.TemporaryDirectory(prefix='sdp-scratch-test-') as tmp:
previous = os.getcwd()
os.chdir(tmp)
try:
yield Path(tmp)
finally:
os.chdir(previous)
def test_private_workspaces_are_unique_preserve_cwd_and_keep_failed_recovery(self):
with self.working_directory() as root:
with sdputils.private_workspace('test') as first, sdputils.private_workspace('test') as second:
self.assertNotEqual(first, second)
self.assertEqual(Path.cwd(), root.resolve())
self.assertFalse(first.exists())
self.assertFalse(second.exists())
with self.assertLogs(level='ERROR') as logs, self.assertRaises(RuntimeError):
with sdputils.private_workspace('recovery', preserve_on_failure=True) as recovery:
(recovery / 'owner.txt').write_text('original-owner')
raise RuntimeError('failed restore')
self.assertEqual((recovery / 'owner.txt').read_text(), 'original-owner')
self.assertIn(str(recovery), '\n'.join(logs.output))
self.assertEqual(recovery.stat().st_mode & 0o777, 0o700)
def test_user_and_group_creation_leave_existing_scratch_names_untouched(self):
with self.working_directory() as root:
(root / 'user.txt').write_text('unrelated user file')
(root / 'group.txt').write_text('unrelated group file')
with prior.module('createusers', ['input.csv']) as mod, patch.object(sdputils, 'run_checked') as run:
mod.process_servers([('alice', '[email protected]', 'Alice')])
self.assertIn('User:\talice\n', run.call_args.kwargs['input'])
(root / 'groups.txt').write_text('group,team\nalice\ngroup,other\nbob\n')
with prior.module('creategroups') as mod, patch.object(sdputils, 'run_checked') as run:
mod.main()
self.assertEqual([c.kwargs['input'] for c in run.call_args_list],
['Group:\tteam\nUsers:\n\talice\n', 'Group:\tother\nUsers:\n\tbob\n'])
self.assertEqual((root / 'user.txt').read_text(), 'unrelated user file')
self.assertEqual((root / 'group.txt').read_text(), 'unrelated group file')
def test_group_removal_uses_distinct_files_for_previously_colliding_names(self):
with self.working_directory() as root, prior.module('removeuserfromgroups', ['alice']) as mod:
(root / 'a_2fb.group').write_text('unrelated group file')
def command(args, **kwargs):
if args[1] == 'groups':
return subprocess.CompletedProcess(args, 0, 'a/b\na_2fb\n')
if args[1:3] == ['group', '-o']:
return subprocess.CompletedProcess(args, 0, 'Group: ' + args[-1] + '\nUsers:\n\talice\n\tbob\n')
return subprocess.CompletedProcess(args, 0)
with patch.object(sdputils, 'run_checked', side_effect=command) as run:
mod.automain(['alice'])
specs = [c.kwargs['input'] for c in run.call_args_list if 'input' in c.kwargs]
self.assertEqual(len(specs), 2)
self.assertIn('Group: a/b\n', specs[0])
self.assertIn('Group: a_2fb\n', specs[1])
self.assertTrue(all('\talice\n' not in spec and '\tbob\n' in spec for spec in specs))
self.assertEqual((root / 'a_2fb.group').read_text(), 'unrelated group file')
self.assertEqual(list(root.glob('.sdp-group-removal-*')), [])
def test_maintain_users_keeps_existing_report_files(self):
with self.working_directory() as root, prior.module('maintain_user_from_groups') as mod:
for name in ['users.txt', 'groups.txt', 'ztagfiles.txt']:
(root / name).write_text('existing ' + name)
with patch.object(sdputils, 'run_checked', return_value=subprocess.CompletedProcess([], 0, '')):
mod.main()
for name in ['users.txt', 'groups.txt', 'ztagfiles.txt']:
self.assertEqual((root / name).read_text(), 'existing ' + name)
def test_failed_label_conversion_keeps_specs_files_owner_and_label_name(self):
with self.working_directory() as root, prior.module('convert_label_to_autoreload', ['label']) as mod:
(root / 'owner.txt').write_text('unrelated owner')
def command(args, **kwargs):
if '-x' in args:
raise subprocess.CalledProcessError(7, args)
text = '//depot/file#1 - add change 1\n' if args[1] == 'files' else 'Label: label\nOptions: noautoreload\n'
return subprocess.CompletedProcess(args, 0, text)
def unlock(label, owner_file):
Path(owner_file).write_text('original owner')
with patch.object(mod, 'check_auto', return_value=0), patch.object(sdputils, 'run_checked', side_effect=command), patch.object(mod.p4unlock, 'unlock', side_effect=unlock), patch.object(mod.p4lock, 'lock') as lock, self.assertRaises(subprocess.CalledProcessError):
mod.convertlabel('label')
recovery, = root.glob('.sdp-label-recovery-*')
self.assertEqual((recovery / 'owner.txt').read_text(), 'original owner')
self.assertEqual((recovery / 'label-name.txt').read_text(), 'label\n')
self.assertIn('autoreload', (recovery / 'label.labelspec').read_text())
self.assertEqual((recovery / 'label.labelfiles').read_text(), '//depot/file#1\n')
self.assertEqual((root / 'owner.txt').read_text(), 'unrelated owner')
lock.assert_not_called()
def test_successful_label_conversion_removes_only_private_workspace(self):
with self.working_directory() as root, prior.module('convert_label_to_autoreload', ['label']) as mod:
(root / 'owner.txt').write_text('unrelated owner')
result = subprocess.CompletedProcess([], 0, 'Label: label\nOptions: noautoreload\n')
with patch.object(mod, 'check_auto', return_value=0), patch.object(sdputils, 'run_checked', return_value=result), patch.object(mod.p4unlock, 'unlock') as unlock, patch.object(mod.p4lock, 'lock') as lock:
mod.convertlabel('label')
self.assertEqual(unlock.call_args.kwargs['owner_file'], lock.call_args.kwargs['owner_file'])
self.assertNotEqual(unlock.call_args.kwargs['owner_file'], 'owner.txt')
self.assertEqual(list(root.glob('.sdp-label-recovery-*')), [])
self.assertEqual((root / 'owner.txt').read_text(), 'unrelated owner')
if __name__ == '__main__':
unittest.main()
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #1 | 34004 | Russell C. Jackson (Rusty) |
Improve SDP backup/recovery, Maintenance, logging and cloud operation. Add owned checkpoint locks, recovery-aware retention, preserved database switches, bounded cleanup and coordinated AWS/Azure snapshots. Fix service-user password initialization, edge staging, standby promotion state parsing, proxy cache setup, typed depot verification and historical-server configuration/error handling. Remove unused OTP programs; keep Windows removed, server.locks cleanup and the journal-count condition unchanged. Add no global shell strict modes. Update operational documentation and add reproducible mkdirs.sh/mkdirs.cfg container assurance. Validate Ubuntu 26.04 and UBI 8.10/9.8/10.2 across both Python runtimes: 44 stages; 428 offline tests per runtime/OS, zero offline skips; 19 deployed-archive comparisons per combination. Three six-node topology runs pass all 30 recovery/failure scenarios. P4D 2023.1 through 2026.1 on UBI 8/9 pass all 14 cells and 98 scenarios. Static, documentation and provenance checks pass. Containers do not certify every fleet kernel, patch build or integration. Tested source manifest SHA-256: e1a23e17b8ced8c1148d9a250589977c6f6e98606bf2a81dc3ff5d7c909e3695 Deployed comparison archive SHA-256: 1bd23217356ee3808e040d39aea5be9519bda7c8f2b4924656a70aff8ad7293a |