#!/usr/bin/env python3
"""Create a coordinated cloud backup set, then expire only owned complete sets.
Run through snapshot.sh to initialize the SDP environment and journal metadata.
Only standard-library modules and the provider's authenticated CLI are required.
"""
import argparse
from datetime import datetime, timedelta, timezone
import hashlib
import json
import logging
import os
import re
import subprocess
import sys
import uuid
MANAGER = 'sdp-cloud-snapshot'
SCHEMA = '1'
RUN_PATTERN = r'\d{8}T\d{6}Z-[0-9a-f]{12}'
POINT_PATTERN = re.compile(r'sdp-v1-j[0-9]+-' + RUN_PATTERN + r'\Z')
class SnapshotError(RuntimeError):
pass
def require(condition, message):
if not condition:
raise SnapshotError(message)
def command(argv):
"""Structured arguments and JSON responses; no shell or credential logging."""
try:
result = subprocess.run(argv, capture_output=True, text=True, check=False)
except OSError as exc:
raise SnapshotError('Could not start cloud CLI: ' + str(exc)) from None
if result.stderr.strip():
logging.warning('%s', result.stderr.strip())
if result.returncode:
raise SnapshotError('Cloud command failed (exit %d): %s' %
(result.returncode, result.stderr.strip() or result.stdout.strip()))
if not result.stdout.strip():
return None
try:
return json.loads(result.stdout)
except ValueError:
raise SnapshotError('Cloud command returned invalid JSON') from None
def timestamp(value):
require(isinstance(value, str) and bool(value), 'Missing snapshot creation time')
try:
result = datetime.fromisoformat(value.replace('Z', '+00:00'))
except ValueError:
raise SnapshotError('Invalid snapshot creation time: ' + value) from None
require(result.tzinfo is not None, 'Snapshot creation time must include a timezone')
return result.astimezone(timezone.utc)
def positive_int(value):
try:
result = int(value)
except (TypeError, ValueError):
raise SnapshotError('Retention/wait interval must be a positive integer') from None
require(result > 0, 'Retention/wait interval must be a positive integer')
return result
def tags_dict(items):
require(isinstance(items, list), 'Malformed AWS tags')
result = {}
for item in items:
require(isinstance(item, dict) and isinstance(item.get('Key'), str), 'Malformed AWS tag')
require(item['Key'] not in result, 'Duplicate AWS tag key')
result[item['Key']] = item.get('Value')
return result
def owned(tags, expected):
return isinstance(tags, dict) and all(tags.get(key) == value for key, value in expected.items())
def properties(resource):
"""Support both ARM properties objects and flattened Azure CLI responses."""
require(isinstance(resource, dict), 'Malformed Azure resource')
return resource.get('properties', resource)
class Backup:
def __init__(self, instance, host, journal, retention=90, run_id=None, now=None, runner=command, environ=None):
require(bool(instance) and bool(host), 'SDP instance and host are required')
require(re.fullmatch(r'[0-9]+', str(journal)) is not None, 'Journal must be numeric')
self.instance, self.host, self.journal = str(instance), host, str(journal)
self.now = now or datetime.now(timezone.utc)
self.run_id = run_id or self.now.strftime('%Y%m%dT%H%M%SZ-') + uuid.uuid4().hex[:12]
require(re.fullmatch(RUN_PATTERN, self.run_id) is not None, 'Invalid backup run identifier')
self.cutoff = self.now - timedelta(days=positive_int(retention))
self.runner = runner
self.env = os.environ if environ is None else environ
def scope(self, account):
return {'SDPManagedBy': MANAGER, 'SDPSchema': SCHEMA,
'SDPAccount': account, 'SDPHost': self.host, 'SDPInstance': self.instance}
def aws(self, *args):
return self.runner(['aws', *args, '--output', 'json'])
def run_aws(self):
account = self.aws('sts', 'get-caller-identity')['Account']
require(isinstance(account, str) and re.fullmatch(r'[0-9]{12}', account), 'Invalid AWS account identity')
selected = []
source_instance = None
for suffix in ('root', 'hxdepots', 'hxlogs'):
name = self.host + '-' + suffix
response = self.aws('ec2', 'describe-volumes', '--filters',
json.dumps([{'Name': 'tag:Name', 'Values': [name]}]))
volumes = response.get('Volumes')
require(isinstance(volumes, list) and len(volumes) == 1,
'Expected exactly one volume named ' + name)
volume = volumes[0]
require(tags_dict(volume.get('Tags', [])).get('Name') == name,
'Volume Name tag differs from requested name ' + name)
volume_id = volume.get('VolumeId', '')
require(re.fullmatch(r'vol-[0-9a-f]+', volume_id) is not None and volume_id not in selected,
'Invalid or duplicate selected volume')
attachments = volume.get('Attachments', [])
require(len(attachments) == 1 and attachments[0].get('State') == 'attached',
'Selected volumes must each be attached to exactly one instance')
instance_id = attachments[0].get('InstanceId', '')
require(re.fullmatch(r'i-[0-9a-f]+', instance_id) is not None, 'Invalid EC2 instance ID')
if source_instance is None:
source_instance = instance_id
require(instance_id == source_instance, 'Selected volumes span EC2 instances')
selected.append(volume_id)
expected_instance = self.env.get('SDP_AWS_INSTANCE_ID')
require(not expected_instance or expected_instance == source_instance,
'Selected volumes do not belong to SDP_AWS_INSTANCE_ID')
response = self.aws('ec2', 'describe-instances', '--instance-ids', source_instance)
instances = [vm for reservation in response.get('Reservations', []) for vm in reservation.get('Instances', [])]
require(len(instances) == 1 and instances[0].get('InstanceId') == source_instance,
'Could not identify the selected EC2 instance')
vm = instances[0]
devices = vm.get('BlockDeviceMappings', [])
attached = [item['Ebs']['VolumeId'] for item in devices if 'Ebs' in item]
roots = [item['Ebs']['VolumeId'] for item in devices
if 'Ebs' in item and item.get('DeviceName') == vm.get('RootDeviceName')]
require(len(roots) == 1 and len(attached) == len(set(attached)) and set(selected) <= set(attached),
'Ambiguous or changed EC2 volume attachment mapping')
excluded = sorted(set(attached) - set(selected) - set(roots))
require(len(excluded) <= 40, 'AWS supports at most 40 excluded data volumes')
spec = {'InstanceId': source_instance, 'ExcludeBootVolume': roots[0] not in selected}
if excluded:
spec['ExcludeDataVolumeIds'] = excluded
scope = dict(self.scope(account), SDPSourceInstance=source_instance)
tags = dict(scope, SDPRun=self.run_id, SDPJournal=self.journal, SDPSetSize=str(len(selected)),
Contents='Perforce Checkpoints and Archives', Host=self.host,
Name=self.host + '-sdp-' + self.instance + '-j' + self.journal)
tag_spec = [{'ResourceType': 'snapshot', 'Tags': [{'Key': key, 'Value': value} for key, value in tags.items()]}]
logging.info('Creating AWS snapshot set %s for %s, journal %s', self.run_id, source_instance, self.journal)
response = self.aws('ec2', 'create-snapshots', '--instance-specification', json.dumps(spec),
'--tag-specifications', json.dumps(tag_spec), '--description',
'SDP %s host %s journal %s run %s' % (self.instance, self.host, self.journal, self.run_id))
snapshots = response.get('Snapshots', [])
ids = self.validate_aws_set(snapshots, selected, account, tags, completed=False)
logging.info('Snapshot set requested: %s', ', '.join(ids))
self.aws('ec2', 'wait', 'snapshot-completed', '--snapshot-ids', *ids)
final = self.aws('ec2', 'describe-snapshots', '--owner-ids', 'self', '--snapshot-ids', *ids)
self.validate_aws_set(final.get('Snapshots', []), selected, account, tags, completed=True)
logging.info('Snapshot set completed: %s', ', '.join(ids))
self.expire_aws(scope, account)
def validate_aws_set(self, snapshots, volumes, account, tags, completed):
require(isinstance(snapshots, list) and len(snapshots) == len(volumes), 'Incomplete AWS snapshot set; expiration blocked')
require({snap.get('VolumeId') for snap in snapshots} == set(volumes), 'AWS snapshot volume set changed; expiration blocked')
ids = []
for snap in snapshots:
sid = snap.get('SnapshotId', '')
require(re.fullmatch(r'snap-[0-9a-f]+', sid) is not None and sid not in ids, 'Invalid or duplicate AWS snapshot ID')
require(snap.get('OwnerId') == account and owned(tags_dict(snap.get('Tags', [])), tags),
'AWS snapshot ownership/tags could not be verified; expiration blocked')
require(snap.get('State') in (('completed',) if completed else ('pending', 'completed')),
'AWS snapshot did not complete successfully; expiration blocked')
ids.append(sid)
return ids
def expire_aws(self, scope, account):
filters = [{'Name': 'tag:' + key, 'Values': [value]} for key, value in scope.items()]
response = self.aws('ec2', 'describe-snapshots', '--owner-ids', 'self', '--filters', json.dumps(filters))
groups = {}
for snap in response.get('Snapshots', []):
tags = tags_dict(snap.get('Tags', []))
if snap.get('OwnerId') != account or not owned(tags, scope):
continue
run_id = tags.get('SDPRun', '')
if not re.fullmatch(RUN_PATTERN, run_id) or run_id == self.run_id:
continue
groups.setdefault(run_id, []).append((snap, tags))
expired = []
for run_id, records in groups.items():
# Incomplete or partly failed sets are retained for manual inspection.
if any(snap.get('State') != 'completed' or tags.get('SDPSetSize') != str(len(records))
for snap, tags in records):
continue
if len({snap.get('VolumeId') for snap, _ in records}) != len(records):
continue
if all(timestamp(snap.get('StartTime')) <= self.cutoff for snap, _ in records):
for snap, _ in records:
sid = snap.get('SnapshotId', '')
require(re.fullmatch(r'snap-[0-9a-f]+', sid) is not None, 'Invalid owned snapshot ID; expiration blocked')
expired.append(sid)
# Validate the entire candidate list before deleting anything.
for sid in expired:
logging.info('Deleting expired owned snapshot %s', sid)
try:
self.aws('ec2', 'delete-snapshot', '--snapshot-id', sid)
except SnapshotError as exc:
raise SnapshotError('Could not delete expired snapshot %s: %s' % (sid, exc)) from None
def az(self, *args):
return self.runner(['az', *args, '--subscription', self.subscription, '--output', 'json', '--only-show-errors'])
def azure_collection(self, collection, expected_id, source_id, scope):
require(collection.get('id', '').lower() == expected_id.lower(), 'Unexpected Azure restore point collection ID')
require(properties(collection).get('source', {}).get('id', '').lower() == source_id.lower(),
'Azure collection belongs to another VM; refusing to modify it')
require(owned(collection.get('tags'), scope), 'Azure collection ownership tags differ; refusing to modify it')
require(properties(collection).get('provisioningState') == 'Succeeded', 'Azure collection is not ready')
def run_azure(self):
def setting(name):
value = self.env.get(name, '')
require(bool(value), name + ' must be configured explicitly for Azure')
return value
self.subscription = setting('SDP_AZURE_SUBSCRIPTION')
group, vm_name = setting('SDP_AZURE_RESOURCE_GROUP'), setting('SDP_AZURE_VM')
timeout = str(positive_int(self.env.get('SDP_AZURE_WAIT_SECONDS', '3600')))
account = self.az('account', 'show')
self.subscription = account.get('id', '')
require(re.fullmatch(r'[0-9a-fA-F-]{36}', self.subscription) is not None, 'Invalid Azure subscription identity')
vm = self.az('vm', 'show', '--resource-group', group, '--name', vm_name)
source_id = '/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Compute/virtualMachines/%s' % (self.subscription, group, vm_name)
require(vm.get('id', '').lower() == source_id.lower(), 'Azure VM identity differs from configured scope')
vp = properties(vm)
vm_uuid = vp.get('vmId')
require(isinstance(vm_uuid, str) and bool(vm_uuid), 'Azure VM unique ID missing')
storage = vp.get('storageProfile', {})
os_disk = storage.get('osDisk', {})
require(os_disk.get('osType') == 'Linux', 'This SDP snapshot entry point supports Linux Azure VMs')
require(not vp.get('virtualMachineScaleSet'), 'Scale-set VMs require a separately validated restore-point workflow')
disks = [os_disk] + storage.get('dataDisks', [])
disk_ids = []
for disk in disks:
disk_id = disk.get('managedDisk', {}).get('id')
require(bool(disk_id) and not disk.get('diffDiskSettings') and not disk.get('writeAcceleratorEnabled'),
'Crash-consistent Azure restore points require supported managed disks; ephemeral/write-accelerated disks are unsupported')
detail = self.az('disk', 'show', '--ids', disk_id)
require(detail.get('id', '').lower() == disk_id.lower(), 'Azure disk identity mismatch')
require(detail.get('sku', {}).get('name') not in ('UltraSSD_LRS', 'PremiumV2_LRS')
and (properties(detail).get('maxShares') or 1) == 1,
'Ultra, Premium SSD v2 and shared disks are unsupported for crash consistency')
disk_ids.append(disk_id.lower())
require(len(disk_ids) == len(set(disk_ids)), 'Duplicate Azure managed disk IDs')
scope = dict(self.scope(self.subscription), SDPSourceVM=vm_uuid)
digest = hashlib.sha256((source_id.lower() + '\0' + self.host + '\0' + self.instance).encode()).hexdigest()[:10]
default_collection = 'sdp-' + re.sub(r'[^a-zA-Z0-9_-]', '-', self.host)[:25] + '-' + re.sub(r'[^a-zA-Z0-9_-]', '-', self.instance)[:15] + '-' + digest
collection_name = self.env.get('SDP_AZURE_COLLECTION') or default_collection
require(re.fullmatch(r'[a-zA-Z0-9_.-]{1,80}', collection_name) is not None, 'Invalid Azure restore point collection name')
collection_id = '/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Compute/restorePointCollections/%s' % (self.subscription, group, collection_name)
coll_args = ['--resource-group', group, '--collection-name', collection_name]
collections = self.az('restore-point', 'collection', 'list', '--resource-group', group)
require(isinstance(collections, list), 'Azure collection listing is incomplete or malformed')
matches = [item for item in collections if item.get('name', '').lower() == collection_name.lower()]
require(len(matches) <= 1, 'Duplicate Azure collection names')
if matches:
collection = self.az('restore-point', 'collection', 'show', *coll_args)
else:
logging.info('Creating dedicated Azure collection %s', collection_name)
collection = self.az('restore-point', 'collection', 'create', *coll_args,
'--source-id', source_id, '--location', vm['location'],
'--tags', *[key + '=' + value for key, value in scope.items()])
self.azure_collection(collection, collection_id, source_id, scope)
name = 'sdp-v1-j' + self.journal + '-' + self.run_id
point_args = [*coll_args, '--name', name]
logging.info('Creating Azure restore point %s for %s', name, source_id)
self.az('restore-point', 'create', *point_args, '--consistency-mode', 'CrashConsistent')
self.az('restore-point', 'wait', *point_args, '--created', '--timeout', timeout)
# Provisioning success can precede completion of background disk copying.
count = str(len(disk_ids))
complete = 'length(instanceView.diskRestorePoints) == `' + count + '` && length(instanceView.diskRestorePoints[?replicationStatus.completionPercent == `100`]) == `' + count + '`'
self.az('restore-point', 'wait', *point_args, '--expand', 'instanceView', '--custom', complete, '--timeout', timeout)
point = self.az('restore-point', 'show', *point_args, '--instance-view')
self.validate_azure_point(point, collection_id, vm_uuid, name, disk_ids)
logging.info('Azure restore point completed: %s', point['id'])
collection = self.az('restore-point', 'collection', 'show', *coll_args, '--restore-points')
self.azure_collection(collection, collection_id, source_id, scope)
candidates = properties(collection).get('restorePoints')
require(isinstance(candidates, list), 'Azure restore point listing is incomplete')
expired = []
for candidate in candidates:
old_name = candidate.get('name', '')
if not POINT_PATTERN.fullmatch(old_name) or old_name == name:
continue
props = properties(candidate)
if (props.get('provisioningState') != 'Succeeded'
or props.get('consistencyMode') != 'CrashConsistent'
or props.get('sourceMetadata', {}).get('vmId') != vm_uuid):
continue
expected_id = collection_id + '/restorePoints/' + old_name
require(candidate.get('id', '').lower() == expected_id.lower(), 'Azure restore point lies outside owned collection')
if timestamp(props.get('timeCreated')) <= self.cutoff:
detail = self.az('restore-point', 'show', *coll_args, '--name', old_name, '--instance-view')
self.validate_azure_point(detail, collection_id, vm_uuid, old_name)
expired.append(old_name)
for old_name in expired:
logging.info('Deleting expired owned Azure restore point %s', old_name)
self.az('restore-point', 'delete', *coll_args, '--name', old_name, '--yes')
def validate_azure_point(self, point, collection_id, vm_uuid, name, disk_ids=None):
require(point.get('id', '').lower() == (collection_id + '/restorePoints/' + name).lower(), 'Unexpected Azure restore point identity')
props = properties(point)
require(props.get('provisioningState') == 'Succeeded' and props.get('consistencyMode') == 'CrashConsistent',
'Azure restore point is not a successful crash-consistent backup; expiration blocked')
metadata = props.get('sourceMetadata', {})
require(metadata.get('vmId') == vm_uuid, 'Azure restore point source VM differs; expiration blocked')
profile = metadata.get('storageProfile', {})
disks = [profile.get('osDisk', {})] + profile.get('dataDisks', [])
source_disks = [disk.get('managedDisk', {}).get('id', '').lower() for disk in disks]
restore_ids = [disk.get('diskRestorePoint', {}).get('id', '').lower() for disk in disks]
require(all(source_disks) and all(restore_ids) and len(restore_ids) == len(set(restore_ids)),
'Azure restore point disk set is incomplete')
if disk_ids is not None:
require(len(source_disks) == len(disk_ids) and set(source_disks) == set(disk_ids), 'Azure restore point disk set changed; expiration blocked')
views = props.get('instanceView', {}).get('diskRestorePoints', [])
require(len(views) == len(restore_ids) and {v.get('id', '').lower() for v in views} == set(restore_ids),
'Azure disk copy status is incomplete; expiration blocked')
require(all(view.get('replicationStatus', {}).get('completionPercent') == 100
and view.get('replicationStatus', {}).get('status', {}).get('code', '').lower() == 'replicationstate/succeeded'
for view in views), 'Azure disk copy has not completed; expiration blocked')
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--provider', choices=('aws', 'azure'), default='aws')
parser.add_argument('--instance', required=True)
parser.add_argument('--host', required=True)
parser.add_argument('--journal', required=True)
parser.add_argument('--retention-days', default=os.environ.get('SDP_SNAPSHOT_RETENTION_DAYS', '90'))
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s')
try:
backup = Backup(args.instance, args.host, args.journal, args.retention_days)
logging.info('Starting %s backup run %s', args.provider, backup.run_id)
getattr(backup, 'run_' + args.provider)()
except (SnapshotError, KeyError, TypeError, ValueError) as exc:
logging.error('Snapshot processing failed: %s', exc)
return 1
logging.info('Snapshot processing completed OK')
return 0
if __name__ == '__main__':
sys.exit(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 |