-
Notifications
You must be signed in to change notification settings - Fork 193
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for AWS EBS Snapshot Lock mode #1005
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -463,21 +463,41 @@ class AwsCloudSnapshotInterface(CloudSnapshotInterface): | |||||
https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-creating-snapshot.html | ||||||
""" | ||||||
|
||||||
def __init__(self, profile_name=None, region=None, await_snapshots_timeout=3600): | ||||||
def __init__( | ||||||
self, | ||||||
profile_name=None, | ||||||
region=None, | ||||||
await_snapshots_timeout=3600, | ||||||
lock_mode=None, | ||||||
lock_duration=None, | ||||||
lock_cool_off_period=None, | ||||||
lock_expiration_date=None | ||||||
): | ||||||
""" | ||||||
Creates the client necessary for creating and managing snapshots. | ||||||
:param str profile_name: AWS auth profile identifier. | ||||||
:param str region: The AWS region in which snapshot resources are located. | ||||||
:param int await_snapshots_timeout: The maximum time in seconds to wait for | ||||||
snapshots to complete. | ||||||
:param str lock_mode: The lock mode to apply to the snapshot. | ||||||
:param int lock_duration: The duration (in days) for which the snapshot | ||||||
should be locked. | ||||||
:param int lock_cool_off_period: The cool-off period (in hours) for the snapshot. | ||||||
:param str lock_expiration_date: The expiration date for the snapshot in the format | ||||||
YYYY-MM-DDThh:mm:ss.sssZ. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Sphinx recommendation for literals. |
||||||
""" | ||||||
|
||||||
self.session = boto3.Session(profile_name=profile_name) | ||||||
# If a specific region was provided then this overrides any region which may be | ||||||
# defined in the profile | ||||||
self.region = region or self.session.region_name | ||||||
self.ec2_client = self.session.client("ec2", region_name=self.region) | ||||||
self.await_snapshots_timeout = await_snapshots_timeout | ||||||
self.lock_mode = lock_mode | ||||||
self.lock_duration = lock_duration | ||||||
self.lock_cool_off_period = lock_cool_off_period | ||||||
self.lock_expiration_date = lock_expiration_date | ||||||
|
||||||
def _get_waiter_config(self): | ||||||
delay = 15 | ||||||
|
@@ -761,10 +781,12 @@ def take_snapshot_backup(self, backup_info, instance_identifier, volumes): | |||||
snapshot_name, snapshot_resp = self._create_snapshot( | ||||||
backup_info, volume_identifier, volume_metadata.id | ||||||
) | ||||||
|
||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
snapshots.append( | ||||||
AwsSnapshotMetadata( | ||||||
snapshot_id=snapshot_resp["SnapshotId"], | ||||||
snapshot_name=snapshot_name, | ||||||
snapshot_lock_mode=self.lock_mode, | ||||||
device_name=attached_volumes[0]["DeviceName"], | ||||||
mount_options=volume_metadata.mount_options, | ||||||
mount_point=volume_metadata.mount_point, | ||||||
|
@@ -783,14 +805,57 @@ def take_snapshot_backup(self, backup_info, instance_identifier, volumes): | |||||
WaiterConfig=self._get_waiter_config(), | ||||||
) | ||||||
|
||||||
# Apply lock on snapshots if lock mode is specified | ||||||
if self.lock_mode: | ||||||
self._lock_snapshots( | ||||||
snapshots, | ||||||
self.lock_mode, | ||||||
self.lock_duration, | ||||||
self.lock_cool_off_period, | ||||||
self.lock_expiration_date | ||||||
) | ||||||
|
||||||
backup_info.snapshots_info = AwsSnapshotsInfo( | ||||||
snapshots=snapshots, | ||||||
region=self.region, | ||||||
# All snapshots will have the same OwnerId so we get it from the last | ||||||
# snapshot response. | ||||||
account_id=snapshot_resp["OwnerId"], | ||||||
|
||||||
) | ||||||
|
||||||
def _lock_snapshots(self, snapshots, lock_mode, lock_duration, lock_cool_off_period, lock_expiration_date): | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be revisited and refactored.
To summarize, try to use this method to lock one snapshot, pass all the arguments, lock the snapshot and output information dynamically from the response object. In line 768, where we have Also, you should add a mutually exclusive test in the beginning of the backup process for fields that cannot be passed together, like duration and expiration_date OR governance mode with cool_off period. You did it for the client, but its not being checked when using this with a barman server I can give you two examples of response object that i have from my tests:
|
||||||
lock_snapshot_default_args = { | ||||||
"LockMode": lock_mode | ||||||
} | ||||||
|
||||||
if lock_duration: | ||||||
lock_snapshot_default_args["LockDuration"] = lock_duration | ||||||
|
||||||
if lock_cool_off_period: | ||||||
lock_snapshot_default_args["CoolOffPeriod"] = lock_cool_off_period | ||||||
|
||||||
if lock_expiration_date: | ||||||
lock_snapshot_default_args["ExpirationDate"] = lock_expiration_date | ||||||
|
||||||
for snapshot in snapshots: | ||||||
lock_snapshot_args = lock_snapshot_default_args.copy() | ||||||
lock_snapshot_args["SnapshotId"] = snapshot.identifier | ||||||
|
||||||
resp = self.ec2_client.lock_snapshot(**lock_snapshot_args) | ||||||
|
||||||
logging.info( | ||||||
"Snapshot %s locked in state '%s' (lock duration: %s days, cool-off period: %s hours, " | ||||||
"cool-off period expires on: %s, lock expires on: %s, lock duration time: %s)", | ||||||
snapshot.identifier, | ||||||
resp["LockState"], | ||||||
resp["LockDuration"], | ||||||
resp["CoolOffPeriod"], | ||||||
resp["CoolOffPeriodExpiresOn"], | ||||||
resp["LockExpiresOn"], | ||||||
resp["LockDurationStartTime"] | ||||||
) | ||||||
Comment on lines
+847
to
+857
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In accordance with what @andremagui said, I think this is the biggest problem here. It seems the response from AWS is not always the same depending on the request parameters you pass, so you should ideally not rely on their response for logging. E.g. if i run
Because
The snapshot itself was created and locked but the process errored out and the backup is not completed successfully in Barman. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we want to use a formatted message, we should at least use |
||||||
|
||||||
def _delete_snapshot(self, snapshot_id): | ||||||
""" | ||||||
Delete the specified snapshot. | ||||||
|
@@ -824,6 +889,16 @@ def delete_snapshot_backup(self, backup_info): | |||||
snapshot.identifier, | ||||||
backup_info.backup_id, | ||||||
) | ||||||
|
||||||
if snapshot.snapshot_lock_mode is not None: | ||||||
resp = self.ec2_client.describe_locked_snapshots( | ||||||
SnapshotIds=[snapshot.identifier], | ||||||
) | ||||||
|
||||||
if resp["Snapshots"] and resp["Snapshots"][0]["LockState"] != "expired": | ||||||
logging.warning("Skipping deletion of snapshot %s as it not expired yet", snapshot.identifier) | ||||||
continue | ||||||
|
||||||
Comment on lines
+893
to
+901
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This has do be done earlier in the code. By doing it here you are only avoiding the deletion of the snapshot itself, but the backup metadata in the s3 bucket is still deleted. This would make Barman lose track of the backup while the snapshot itself will still exist. E.g.
I believe you would have to handle this in |
||||||
self._delete_snapshot(snapshot.identifier) | ||||||
|
||||||
def get_attached_volumes( | ||||||
|
@@ -1003,11 +1078,11 @@ class AwsSnapshotMetadata(SnapshotMetadata): | |||||
""" | ||||||
Specialization of SnapshotMetadata for AWS EBS snapshots. | ||||||
Stores the device_name, snapshot_id and snapshot_name in the provider-specific | ||||||
Stores the device_name, snapshot_id, snapshot_name and snapshot_lock_mode in the provider-specific | ||||||
field. | ||||||
""" | ||||||
|
||||||
_provider_fields = ("device_name", "snapshot_id", "snapshot_name") | ||||||
_provider_fields = ("device_name", "snapshot_id", "snapshot_name", "snapshot_lock_mode") | ||||||
|
||||||
def __init__( | ||||||
self, | ||||||
|
@@ -1016,6 +1091,7 @@ def __init__( | |||||
device_name=None, | ||||||
snapshot_id=None, | ||||||
snapshot_name=None, | ||||||
snapshot_lock_mode=None, | ||||||
): | ||||||
""" | ||||||
Constructor saves additional metadata for AWS snapshots. | ||||||
|
@@ -1027,12 +1103,14 @@ def __init__( | |||||
:param str device_name: The device name used in the AWS API. | ||||||
:param str snapshot_id: The snapshot ID used in the AWS API. | ||||||
:param str snapshot_name: The snapshot name stored in the `Name` tag. | ||||||
:param str snapshot_lock_mode: The mode with which the snapshot has been locked, if set. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
:param str project: The AWS project name. | ||||||
""" | ||||||
super(AwsSnapshotMetadata, self).__init__(mount_options, mount_point) | ||||||
self.device_name = device_name | ||||||
self.snapshot_id = snapshot_id | ||||||
self.snapshot_name = snapshot_name | ||||||
self.snapshot_lock_mode = snapshot_lock_mode | ||||||
|
||||||
@property | ||||||
def identifier(self): | ||||||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -232,6 +232,17 @@ def parse_time_interval(value): | |||||||||||||||||||||||||||||||||||||
return time_delta | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
def parse_datetime(value): | ||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||
Parse a string, transforming it in a datetime object. | ||||||||||||||||||||||||||||||||||||||
Accepted format: YYYY-MM-DDThh:mm:ss.sssZ | ||||||||||||||||||||||||||||||||||||||
:param str value: the string to evaluate | ||||||||||||||||||||||||||||||||||||||
Comment on lines
+237
to
+240
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ") | ||||||||||||||||||||||||||||||||||||||
Comment on lines
+235
to
+243
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a little confusing in the sense that it is parsing a string to datetime but it is constraining it to a specific format, which is in fact just for AWS in this case.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any ideas here @gustabowill? Maybe in the future we could re-think this if we have other formats. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can keep it as it is for now as this date format is very common. I think you mistyped the return statement of your suggestion @andremagui. |
||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
def parse_si_suffix(value): | ||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||
Parse a string, transforming it into integer and multiplying by | ||||||||||||||||||||||||||||||||||||||
|
@@ -489,6 +500,10 @@ class ServerConfig(BaseConfig): | |||||||||||||||||||||||||||||||||||||
"archiver_batch_size", | ||||||||||||||||||||||||||||||||||||||
"autogenerate_manifest", | ||||||||||||||||||||||||||||||||||||||
"aws_await_snapshots_timeout", | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_mode", | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_duration", | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_cool_off_period", | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_expiration_date", | ||||||||||||||||||||||||||||||||||||||
"aws_profile", | ||||||||||||||||||||||||||||||||||||||
"aws_region", | ||||||||||||||||||||||||||||||||||||||
"azure_credential", | ||||||||||||||||||||||||||||||||||||||
|
@@ -587,6 +602,10 @@ class ServerConfig(BaseConfig): | |||||||||||||||||||||||||||||||||||||
"archiver_batch_size", | ||||||||||||||||||||||||||||||||||||||
"autogenerate_manifest", | ||||||||||||||||||||||||||||||||||||||
"aws_await_snapshots_timeout", | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_mode", | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_duration", | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_cool_off_period", | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_expiration_date", | ||||||||||||||||||||||||||||||||||||||
"aws_profile", | ||||||||||||||||||||||||||||||||||||||
"aws_region", | ||||||||||||||||||||||||||||||||||||||
"azure_credential", | ||||||||||||||||||||||||||||||||||||||
|
@@ -710,6 +729,9 @@ class ServerConfig(BaseConfig): | |||||||||||||||||||||||||||||||||||||
"archiver_batch_size": int, | ||||||||||||||||||||||||||||||||||||||
"autogenerate_manifest": parse_boolean, | ||||||||||||||||||||||||||||||||||||||
"aws_await_snapshots_timeout": int, | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_duration": int, | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_cool_off_period": int, | ||||||||||||||||||||||||||||||||||||||
"aws_snapshot_lock_expiration_date": parse_datetime, | ||||||||||||||||||||||||||||||||||||||
Comment on lines
+732
to
+734
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could somehow leverage of the utils functions that you created for validating the CLI arguments of |
||||||||||||||||||||||||||||||||||||||
"backup_compression": parse_backup_compression, | ||||||||||||||||||||||||||||||||||||||
"backup_compression_format": parse_backup_compression_format, | ||||||||||||||||||||||||||||||||||||||
"backup_compression_level": int, | ||||||||||||||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -726,6 +726,44 @@ def check_positive(value): | |||||||||||
return int_value | ||||||||||||
|
||||||||||||
|
||||||||||||
def check_aws_snapshot_lock_duration_range(value): | ||||||||||||
""" | ||||||||||||
Check for AWS Snapshot Lock duration range option | ||||||||||||
:param value: str containing the value to check | ||||||||||||
""" | ||||||||||||
if value is None: | ||||||||||||
return None | ||||||||||||
try: | ||||||||||||
int_value = int(value) | ||||||||||||
except Exception: | ||||||||||||
raise ArgumentTypeError("'%s' is not a valid input" % value) | ||||||||||||
|
||||||||||||
if int_value < 1 or int_value > 36500: | ||||||||||||
raise ArgumentTypeError("'%s' is outside supported range of 1-36500 days" % value) | ||||||||||||
|
||||||||||||
Comment on lines
+742
to
+744
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
This way is more efficient because the value is compared only once in a single operation and It directly expresses the idea that the value should fall within a specific range. |
||||||||||||
return int_value | ||||||||||||
|
||||||||||||
|
||||||||||||
def check_aws_snapshot_lock_cool_off_period_range(value): | ||||||||||||
""" | ||||||||||||
Check for AWS Snapshot Lock cool-off period range option | ||||||||||||
:param value: str containing the value to check | ||||||||||||
""" | ||||||||||||
if value is None: | ||||||||||||
return None | ||||||||||||
try: | ||||||||||||
int_value = int(value) | ||||||||||||
except Exception: | ||||||||||||
raise ArgumentTypeError("'%s' is not a valid input" % value) | ||||||||||||
|
||||||||||||
if int_value < 1 or int_value > 72: | ||||||||||||
raise ArgumentTypeError("'%s' is outside supported range of 1-72 hours" % value) | ||||||||||||
Comment on lines
+761
to
+762
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
same here. |
||||||||||||
|
||||||||||||
return int_value | ||||||||||||
|
||||||||||||
|
||||||||||||
def check_tli(value): | ||||||||||||
""" | ||||||||||||
Check for a positive integer option, and also make "current" and "latest" acceptable values | ||||||||||||
|
@@ -783,6 +821,16 @@ def check_size(value): | |||||||||||
return int_value | ||||||||||||
|
||||||||||||
|
||||||||||||
def check_timestamp(value): | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
More specific name to the job. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please, add docstrings like the other aws checks |
||||||||||||
try: | ||||||||||||
# Attempt to parse the input date string into a datetime object | ||||||||||||
return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ") | ||||||||||||
except ValueError: | ||||||||||||
raise ArgumentTypeError( | ||||||||||||
"Invalid expiration date: '%s'. Expected format is 'YYYY-MM-DDThh:mm:ss.sssZ'." % value | ||||||||||||
) | ||||||||||||
|
||||||||||||
|
||||||||||||
def check_backup_name(backup_name): | ||||||||||||
""" | ||||||||||||
Verify that a backup name is not a backup ID or reserved identifier. | ||||||||||||
|
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -1761,6 +1761,7 @@ def test_help_output(self, minimal_parser, capsys): | |||
if sys.version_info < (3, 10): | ||||
options_label = "optional arguments" | ||||
expected_output = self._expected_help_output.format(options_label=options_label) | ||||
|
||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. revert or remove this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||
assert expected_output == out | ||||
|
||||
|
||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move and refactor this in the
_validate_config
function in this module.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As this is only about mutually exclusiveness, we can also leverage of
argparse
to handle that for us. Something like this:If you attempt to specify both,
argparse
would throw an error:barman-cloud-backup: error: argument --aws-snapshot-lock-expiration-date: not allowed with argument --aws-snapshot-lock-duration