Skip to content

Commit

Permalink
S3 bucket inventories support
Browse files Browse the repository at this point in the history
  • Loading branch information
abraverm committed Aug 27, 2024
1 parent e7db692 commit 6556f79
Show file tree
Hide file tree
Showing 5 changed files with 405 additions and 1 deletion.
3 changes: 3 additions & 0 deletions changelogs/fragments/2074-s3_bucket-inventory-feature.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
minor_changes:
- s3_bucket - Add support for bucket inventories (https://docs.aws.amazon.com/AmazonS3/latest/userguide/storage-inventory.html)
23 changes: 23 additions & 0 deletions plugins/module_utils/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,26 @@ def s3_extra_params(options, sigv4=False):
config["signature_version"] = "s3v4"
extra_params["config"] = config
return extra_params


# _list_backup_inventory_configurations is a workaround for a missing paginator for listing
# bucket inventory configuration in boto3:
# https://github.com/boto/botocore/blob/1.34.141/botocore/data/s3/2006-03-01/paginators-1.json
def _list_bucket_inventory_configurations(client, bucket_name):
first_iteration = False
next_token = None

response = client.list_bucket_inventory_configurations(Bucket=bucket_name)
next_token = response.get("NextToken", None)

if next_token is None:
return response.get("InventoryConfigurationList", [])

entries = []
while next_token is not None:
if first_iteration:
response = client.list_bucket_inventory_configurations(NextToken=next_token)
first_iteration = True
entries.extend(response["InventoryConfigurationList"])
next_token = response.get("NextToken")
return entries
249 changes: 248 additions & 1 deletion plugins/modules/s3_bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,65 @@
type: int
type: dict
version_added: 8.1.0
inventory:
description:
- Enable S3 Inventory, saving list of the objects and their corresponding
metadata on a daily or weekly basis for an S3 bucket.
type: list
elements: dict
default: []
suboptions:
destination:
description: Contains information about where to publish the inventory results.
type: dict
required: True
suboptions:
account_id:
description: The account ID that owns the destination S3 bucket. If no account ID is provided, the owner is not validated before exporting data.
type: str
bucket:
description: The Amazon Resource Name (ARN) of the bucket where inventory results will be published.
type: str
required: True
format:
description: Specifies the output format of the inventory results.
type: str
choices: [ 'CSV', 'ORC', 'Parquet' ]
default: CSV
prefix:
description: The prefix that is prepended to all inventory results.
type: str
filter:
description: The prefix that an object must have to be included in the inventory results.
type: str
id:
description: The ID used to identify the inventory configuration.
type: str
required: True
schedule:
description: Specifies the schedule for generating inventory results.
type: str
default: Weekly
choices: [ 'Daily', 'Weekly' ]
included_object_versions:
description: |
Object versions to include in the inventory list. If set to All, the list includes all the object versions,
which adds the version-related fields VersionId, IsLatest, and DeleteMarker to the list. If set to Current,
the list does not contain these version-related fields.
type: str
default: All
choices: [ 'All', 'Current' ]
optional_fields:
description: Contains the optional fields that are included in the inventory results.
type: list
elements: str
default: []
choices: [ "Size", "LastModifiedDate", "StorageClass", "ETag",
"IsMultipartUploaded", "ReplicationStatus", "EncryptionStatus",
"ObjectLockRetainUntilDate", "ObjectLockMode",
"ObjectLockLegalHoldStatus", "IntelligentTieringAccessTier",
"BucketKeyStatus", "ChecksumAlgorithm", "ObjectAccessControlList",
"ObjectOwner" ]
extends_documentation_fragment:
- amazon.aws.common.modules
- amazon.aws.region.modules
Expand Down Expand Up @@ -332,6 +390,16 @@
object_lock_default_retention:
mode: governance
days: 1
# Bucket with inventory configuration:
- amazon.aws.s3_bucket:
name: mys3bucket
state: present
inventory:
- id: mys3bucket-inventory-id
destination:
bucket: "arn:aws:s3:::mys3inventorybucket"
optional_fields:
- "Size"
"""

RETURN = r"""
Expand Down Expand Up @@ -451,6 +519,27 @@
type: bool
returned: O(state=present)
sample: true
bucket_inventory:
description: S3 bucket inventory configuration.
type: list
returned: when O(state=present)
sample: [
{
"IsEnabled": true,
"Id": "9c2a337ba5fd64de777f499441f83093-inventory-target",
"Destination": {
"S3BucketDestination": {
"Bucket": "arn:aws:s3:::9c2a337ba5fd64de777f499441f83093-inventory-target",
"Format": "CSV"
}
},
"IncludedObjectVersions": "All",
"Schedule": {
"Frequency": "Daily"
},
"OptionalFields": []
}
]
"""

import json
Expand All @@ -472,6 +561,7 @@
from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule
from ansible_collections.amazon.aws.plugins.module_utils.policy import compare_policies
from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry
from ansible_collections.amazon.aws.plugins.module_utils.s3 import _list_bucket_inventory_configurations
from ansible_collections.amazon.aws.plugins.module_utils.s3 import s3_extra_params
from ansible_collections.amazon.aws.plugins.module_utils.s3 import validate_bucket_name
from ansible_collections.amazon.aws.plugins.module_utils.tagging import ansible_dict_to_boto3_tag_list
Expand Down Expand Up @@ -1053,6 +1143,86 @@ def handle_bucket_object_lock_retention(s3_client, module: AnsibleAWSModule, nam
return object_lock_default_retention_changed, object_lock_default_retention_result


def handle_bucket_inventory(s3_client, module: AnsibleAWSModule, name: str) -> tuple[bool, dict]:
"""
Manage inventory configuration for an S3 bucket.
Parameters:
s3_client (boto3.client): The Boto3 S3 client object.
module (AnsibleAWSModule): The Ansible module object.
name (str): The name of the bucket to handle inventory for.
Returns:
A tuple containing a boolean indicating whether inventory settings were changed
and a dictionary containing the updated inventory.
"""
declared_inventories = module.params.get("inventory")
results = []
bucket_changed = False

try:
present_inventories = _list_bucket_inventory_configurations(s3_client, name)
except is_boto3_error_code(["NotImplemented", "XNotImplemented"]) as e:
if declared_inventories is not None:
module.fail_json_aws(e, msg="Fetching bucket inventories is not supported")
except is_boto3_error_code("AccessDenied") as e: # pylint: disable=duplicate-except
if declared_inventories is not None:
module.fail_json_aws(e, msg="Permission denied fetching bucket inventories")

except (
botocore.exceptions.BotoCoreError,
botocore.exceptions.ClientError,
) as e: # pylint: disable=duplicate-except
module.fail_json_aws(e, msg="Failed to fetch bucket inventories")
else:
if not declared_inventories and present_inventories != []:
for present_inventory in present_inventories:
inventory_id = present_inventory.get("Id")
if inventory_id:
try:
delete_bucket_inventory(s3_client, name, inventory_id)
except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
module.fail_json_aws(e, msg="Failed to delete bucket inventory")
bucket_changed = True
if declared_inventories:
for declared_inventory in declared_inventories:
camel_destination = snake_dict_to_camel_dict(declared_inventory.get("destination", {}), True)
declared_inventory_api = {
"IsEnabled": True,
"Id": declared_inventory.get("id"),
"Destination": {
"S3BucketDestination": {k: v for k, v in camel_destination.items() if v is not None}
},
"IncludedObjectVersions": declared_inventory.get("included_object_versions"),
"Schedule": {"Frequency": declared_inventory.get("schedule")},
"OptionalFields": [],
}

for field in declared_inventory.get("optional_fields", []):
declared_inventory_api["OptionalFields"].append(field)
if declared_inventory.get("filter") is not None:
declared_inventory_api["Filter"] = {"Prefix": declared_inventory.get("filter")}
update = True
for present_inventory in present_inventories:
if present_inventory.get("Id", "") == declared_inventory_api["Id"]:
if declared_inventory_api == present_inventory:
update = False

if update:
try:
put_bucket_inventory(s3_client, name, declared_inventory_api)
bucket_changed = True
except is_boto3_error_code("InvalidS3DestinationBucket") as e:
module.fail_json_aws(e, msg="Invalid destination bucket ARN")
except (
botocore.exceptions.BotoCoreError,
botocore.exceptions.ClientError,
) as e: # pylint: disable=duplicate-except
module.fail_json_aws(e, msg="Failed to set bucket inventory setting")

results.append(declared_inventory_api)
module.warn(json.dumps(results))
return bucket_changed, results


def create_or_update_bucket(s3_client, module: AnsibleAWSModule):
"""
Create or update an S3 bucket along with its associated configurations.
Expand Down Expand Up @@ -1138,6 +1308,9 @@ def create_or_update_bucket(s3_client, module: AnsibleAWSModule):
s3_client, module, name
)
result["object_lock_default_retention"] = bucket_object_lock_retention_result
# -- Inventory
bucket_inventory_changed, bucket_inventory_result = handle_bucket_inventory(s3_client, module, name)
result["bucket_inventory"] = bucket_inventory_result

# Module exit
changed = (
Expand All @@ -1152,6 +1325,7 @@ def create_or_update_bucket(s3_client, module: AnsibleAWSModule):
or bucket_acl_changed
or bucket_accelerate_changed
or bucket_object_lock_retention_changed
or bucket_inventory_changed
)
module.exit_json(changed=changed, name=name, **result)

Expand Down Expand Up @@ -1277,6 +1451,22 @@ def get_bucket_accelerate_status(s3_client, bucket_name) -> bool:
return accelerate_configuration.get("Status") == "Enabled"


@AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=["NoSuchBucket", "OperationAborted"])
def put_bucket_inventory(s3_client, bucket_name: str, inventory: dict) -> None:
"""
Set inventory settings for an S3 bucket.
Parameters:
s3_client (boto3.client): The Boto3 S3 client object.
bucket_name (str): The name of the S3 bucket.
tags (dict): A dictionary containing the inventory settings to be set on the bucket.
Returns:
None
"""
s3_client.put_bucket_inventory_configuration(
Bucket=bucket_name, InventoryConfiguration=inventory, Id=inventory.get("Id")
)


@AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=["NoSuchBucket", "OperationAborted"])
def put_bucket_tagging(s3_client, bucket_name: str, tags: dict):
"""
Expand All @@ -1291,6 +1481,20 @@ def put_bucket_tagging(s3_client, bucket_name: str, tags: dict):
s3_client.put_bucket_tagging(Bucket=bucket_name, Tagging={"TagSet": ansible_dict_to_boto3_tag_list(tags)})


@AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=["NoSuchBucket", "OperationAborted"])
def delete_bucket_inventory(s3_client, bucket_name: str, id: str) -> None:
"""
Delete the inventory settings for an S3 bucket.
Parameters:
s3_client (boto3.client): The Boto3 S3 client object.
bucket_name (str): The name of the S3 bucket.
id (str): The ID used to identify the inventory configuration
Returns:
None
"""
s3_client.delete_bucket_inventory_configuration(Bucket=bucket_name, Id=id)


@AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=["NoSuchBucket", "OperationAborted"])
def put_bucket_policy(s3_client, bucket_name: str, policy: dict):
"""
Expand Down Expand Up @@ -2052,6 +2256,49 @@ def main():
mutually_exclusive=[("days", "years")],
required_one_of=[("days", "years")],
),
inventory=dict(
type="list",
elements="dict",
default=[],
options=dict(
destination=dict(
type="dict",
options=dict(
account_id=dict(type="str"),
bucket=dict(type="str", required=True),
format=dict(type="str", choices=["CSV", "ORC", "Parquet"], default="CSV"),
prefix=dict(type="str"),
),
required=True,
),
filter=dict(type="str"),
optional_fields=dict(
type="list",
elements="str",
choices=[
"Size",
"LastModifiedDate",
"StorageClass",
"ETag",
"IsMultipartUploaded",
"ReplicationStatus",
"EncryptionStatus",
"ObjectLockRetainUntilDate",
"ObjectLockMode",
"ObjectLockLegalHoldStatus",
"IntelligentTieringAccessTier",
"BucketKeyStatus",
"ChecksumAlgorithm",
"ObjectAccessControlList",
"ObjectOwner",
],
default=[],
),
id=dict(type="str", required=True),
schedule=dict(type="str", default="Weekly", choices=["Daily", "Weekly"]),
included_object_versions=dict(type="str", default="All", choices=["All", "Current"]),
),
),
)

required_by = dict(
Expand Down
1 change: 1 addition & 0 deletions tests/integration/targets/s3_bucket/inventory
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ acl
object_lock
accelerate
default_retention
inventory

[all:vars]
ansible_connection=local
Expand Down
Loading

0 comments on commit 6556f79

Please sign in to comment.