diff --git a/playbooks/manual/communishift_delete_projects.yml b/playbooks/manual/communishift_delete_projects.yml new file mode 100644 index 0000000000..6e0fbf015e --- /dev/null +++ b/playbooks/manual/communishift_delete_projects.yml @@ -0,0 +1,48 @@ +--- +# Communishift project deletion (inventory + per-project eligibility). +# Run after workloads are shut down via communishift_disable_project.yml. +# Uses communishift_projects from inventory (e.g. inventory/group_vars/all) and each +# entry's do_not_delete to skip exempt namespaces. +# +# Omitting communishift_delete_projects_dry_run applies changes. Preview removals only: +# ansible-playbook .../communishift_delete_projects.yml --tags communishift_delete_projects \ +# -e communishift_delete_projects_dry_run=true +# +# Explicit live delete: +# ansible-playbook .../communishift_delete_projects.yml --tags communishift_delete_projects \ +# -e communishift_delete_projects_dry_run=false +# +# Run with an inventory that merges group_vars/all so communishift_projects is defined. +# +- hosts: localhost + user: root + gather_facts: false + + vars_files: + - /srv/web/infra/ansible/vars/global.yml + - "/srv/private/ansible/vars.yml" + - /srv/web/infra/ansible/vars/{{ ansible_distribution }}.yml + + pre_tasks: + - name: Require communishift_projects from inventory + ansible.builtin.assert: + that: + - communishift_projects is defined + - communishift_projects is mapping + fail_msg: > + communishift_projects is missing. Run with an inventory that loads + inventory/group_vars/all (e.g. ansible-playbook -i path/to/inventory ...). + tags: + - always + + tasks: + - name: Communishift delete eligible projects (namespace, EFS, group) + ansible.builtin.include_role: + name: communishift + tasks_from: cleanup-administration-delete-projects + apply: + tags: + - communishift_delete_projects + loop: "{{ lookup('dict', communishift_projects) }}" + tags: + - communishift_delete_projects diff --git a/roles/communishift/library/communishift_storage_efs.py b/roles/communishift/library/communishift_storage_efs.py index 949099aa94..1805ba373e 100644 --- a/roles/communishift/library/communishift_storage_efs.py +++ b/roles/communishift/library/communishift_storage_efs.py @@ -23,6 +23,13 @@ options: description: The name of the Communishift Project. required: true type: str + state: + description: Create access point or remove access points for this project from the filesystem. + type: str + default: present + choices: + - present + - absent aws_access_key_id: description: The AWS API access_key_id. required: true @@ -36,8 +43,11 @@ options: required: true type: str aws_efs_filesystem_id: - description: The AWS EFS FileSystemId of the resource that was previously created. - required: true + description: >- + The AWS EFS FileSystemId. Required when I(state=present). + Optional when I(state=absent); if omitted the filesystem is found by CreationToken matching I(project_name), + consistent with community.aws.efs I(name). + required: false type: str author: @@ -57,88 +67,143 @@ EXAMPLES = r""" {{create_efs_filesystem_response['efs']['file_system_id']}} register: create_efs_accesspoint_response ignore_errors: true + +- name: Delete access points for a Communishift project + communishift_storage_efs: + project_name: "{{ item.value.name }}" + state: absent + aws_access_key_id: "{{ communishift_efs_access_key }}" + aws_secret_access_key: "{{ communishift_efs_secret_key }}" + aws_region: "{{ communishift_region }}" """ RETURN = r""" - -# These are examples of possible return values, and in general should use other names for return values. accesspoint_id: - description: The AccessPointId returned by the AWS EFS API creation request. - type: str - returned: If the EFS Filesystem exists and the AccessPoint been successfully created or already exists. - sample: 'fsap-0938462b9b5f77388' -full_response: - description: The response returned by the AWS EFS boto3 client.create_access_point() or client.describe_access_points(). + description: The AccessPointId returned by create (present) when a single matching resource is summarized. type: str returned: If the EFS Filesystem exists and the AccessPoint has been successfully created or already exists. - sample: '{'ResponseMetadata': {'RequestId': '9c3d3e41-4332-4fe3-8388-f04ccf0400a2', 'HTTPStatusCode': 200, 'HTTPHeaders': {'x-amzn-requ -estid': '9c3d3e41-4332-4fe3-8388-f04ccf0400a2', 'content-type': 'application/json', 'content-length': '503', 'date': 'Tue, 16 -Aug 2022 10:17:43 GMT'}, 'RetryAttempts': 0}, 'ClientToken': 'communishift_storage_efs', 'Tags': [{'Key': 'communishift', 'Val -ue': 'projectname'}], 'AccessPointId': 'fsap-0938462b9b5f77388', 'AccessPointArn': 'arn:aws:elasticfilesystem:us-east-1:XXXXXXXXXXXX: -access-point/fsap-0938462b9b5f77388', 'FileSystemId': 'fs-0343e73f7765a503b', 'PosixUser': {'Uid': 50000, 'Gid': 50000} -, 'RootDirectory': {'Path': '/', 'CreationInfo': {'OwnerUid': 50000, 'OwnerGid': 50000, 'Permissions': '775'}}, 'OwnerId': 'XXXX', -'LifeCycleState': 'creating'}' +deleted_access_point_ids: + description: Access point IDs deleted in absent mode. + type: list + elements: str + returned: absent +full_response: + description: Creation response or describe payload (present) or summarized absent action. + type: raw msg: description: The output message that the module generates. type: str returned: always - sample: 'AWS EFS AccessPoint already exists.' """ from ansible.module_utils.basic import AnsibleModule -def run_module(): - # define available arguments/parameters a user can pass to the module - module_args = dict( - project_name=dict(type="str", required=True), - aws_access_key_id=dict(type="str", required=True), - aws_secret_access_key=dict(type="str", required=True), - aws_region=dict(type="str", required=True), - aws_efs_filesystem_id=dict(type="str", required=True), - ) - - # seed the result dict in the object - # we primarily care about changed and state - # changed is if this module effectively modified the target - # state will include any data that you want your module to pass back - # for consumption, for example, in a subsequent task - result = dict(changed=False, accesspoint_id="", full_response="", msg="") - - # the AnsibleModule object will be our abstraction working with Ansible - # this includes instantiation, a couple of common attr would be the - # args/params passed to the execution, as well as if the module - # supports check mode - module = AnsibleModule(argument_spec=module_args, supports_check_mode=True) - - # if the user is working with this module in only check mode we do not - # want to make any changes to the environment, just return the current - # state with no modifications - if module.check_mode: - module.exit_json(**result) - - efs_client = boto3.client( +def build_efs_client(params): + return boto3.client( "efs", - aws_access_key_id=module.params["aws_access_key_id"], - aws_secret_access_key=module.params["aws_secret_access_key"], - region_name=module.params["aws_region"], + aws_access_key_id=params["aws_access_key_id"], + aws_secret_access_key=params["aws_secret_access_key"], + region_name=params["aws_region"], ) + +def find_filesystem_id_by_creation_token(efs_client, creation_token): + paginator = efs_client.get_paginator("describe_file_systems") + for page in paginator.paginate(): + for fs in page.get("FileSystems", []): + if fs.get("CreationToken") == creation_token: + return fs["FileSystemId"] + return None + + +def matching_access_points(efs_client, filesystem_id, project_name): + expected_path = "/{0}".format(project_name) + matching = [] + paginator = efs_client.get_paginator("describe_access_points") + for page in paginator.paginate(FileSystemId=filesystem_id): + for ap in page.get("AccessPoints", []): + tags = {t["Key"]: t["Value"] for t in ap.get("Tags", [])} + if tags.get("communishift") != project_name: + continue + root_path = ap.get("RootDirectory", {}).get("Path", "") + if root_path != expected_path: + continue + matching.append(ap["AccessPointId"]) + return matching + + +def run_absent(module, efs_client, params, check_mode): + project_name = params["project_name"] + fs_id = params.get("aws_efs_filesystem_id") or "" + if not fs_id: + fs_id = find_filesystem_id_by_creation_token(efs_client, project_name) + if fs_id is None: + module.exit_json( + changed=False, + deleted_access_point_ids=[], + accesspoint_id="", + full_response=None, + msg="No EFS file system found with CreationToken matching project_name.", + ) + access_point_ids = matching_access_points(efs_client, fs_id, project_name) + if not access_point_ids: + module.exit_json( + changed=False, + deleted_access_point_ids=[], + accesspoint_id="", + full_response={"file_system_id": fs_id}, + msg="No matching Communishift-tagged access points to delete.", + ) + if check_mode: + module.exit_json( + changed=True, + deleted_access_point_ids=[], + access_points_that_would_be_deleted=access_point_ids, + accesspoint_id="", + full_response={"file_system_id": fs_id}, + msg="Check mode: would delete {0} access point(s)".format(len(access_point_ids)), + ) + deleted = [] + for ap_id in access_point_ids: + efs_client.delete_access_point(AccessPointId=ap_id) + deleted.append(ap_id) + module.exit_json( + changed=True, + deleted_access_point_ids=deleted, + accesspoint_id=deleted[-1] if deleted else "", + full_response={ + "file_system_id": fs_id, + "deleted_access_point_ids": deleted, + }, + msg="Deleted {0} Communishift EFS access point(s).".format(len(deleted)), + ) + + +def run_present(module, efs_client, params, check_mode): + fs_id = params["aws_efs_filesystem_id"] + if check_mode: + module.exit_json( + changed=False, + accesspoint_id="", + full_response=None, + msg="Check mode: would create access point if missing.", + ) try: response = efs_client.create_access_point( - ClientToken=module.params["project_name"], + ClientToken=params["project_name"], Tags=[ - {"Key": "communishift", "Value": module.params["project_name"]}, - {"Key": "Name", "Value": module.params["project_name"]}, + {"Key": "communishift", "Value": params["project_name"]}, + {"Key": "Name", "Value": params["project_name"]}, ], - FileSystemId=module.params["aws_efs_filesystem_id"], + FileSystemId=fs_id, PosixUser={ "Uid": 1001, "Gid": 1001, - "SecondaryGids": [ 0 ], + "SecondaryGids": [0], }, RootDirectory={ - "Path": f"/{ module.params['project_name'] }", + "Path": "/{0}".format(params["project_name"]), "CreationInfo": { "OwnerUid": 0, "OwnerGid": 0, @@ -147,19 +212,49 @@ def run_module(): }, ) - result["accesspoint_id"] = response["AccessPointId"] - result["full_response"] = response - result["changed"] = True - result["msg"] = "AWS EFS AccessPoint created successfully." - module.exit_json(**result) - except efs_client.exceptions.AccessPointAlreadyExists: - response = efs_client.describe_access_points( - FileSystemId=module.params["aws_efs_filesystem_id"] + module.exit_json( + changed=True, + accesspoint_id=response["AccessPointId"], + full_response=response, + deleted_access_point_ids=[], + msg="AWS EFS AccessPoint created successfully.", ) - result["accesspoint_id"] = response["AccessPoints"][0]["AccessPointId"] - result["full_response"] = response - result["msg"] = "AWS EFS AccessPoint already exists." - module.fail_json(**result) + except efs_client.exceptions.AccessPointAlreadyExists: + ap_ids = matching_access_points(efs_client, fs_id, params["project_name"]) + chosen = ap_ids[0] if ap_ids else "" + module.exit_json( + changed=False, + accesspoint_id=chosen, + full_response=ap_ids, + deleted_access_point_ids=[], + msg="AWS EFS AccessPoint already exists.", + ) + + +def run_module(): + module_args = dict( + project_name=dict(type="str", required=True), + state=dict(type="str", default="present", choices=["present", "absent"]), + aws_access_key_id=dict(type="str", required=True), + aws_secret_access_key=dict(type="str", required=True), + aws_region=dict(type="str", required=True), + aws_efs_filesystem_id=dict(type="str", required=False), + ) + + module = AnsibleModule(argument_spec=module_args, supports_check_mode=True) + + params = module.params + state = params["state"] + + if state == "present" and not params.get("aws_efs_filesystem_id"): + module.fail_json(msg="aws_efs_filesystem_id is required when state=present.") + + efs_client = build_efs_client(params) + + if state == "absent": + run_absent(module, efs_client, params, module.check_mode) + else: + run_present(module, efs_client, params, module.check_mode) def main(): diff --git a/roles/communishift/tasks/cleanup-administration-delete-projects.yml b/roles/communishift/tasks/cleanup-administration-delete-projects.yml new file mode 100644 index 0000000000..53cf25aa97 --- /dev/null +++ b/roles/communishift/tasks/cleanup-administration-delete-projects.yml @@ -0,0 +1,60 @@ +--- +- name: Set Communishift deletion context for this project + ansible.builtin.set_fact: + communishift_namespace: "{{ item.value.name }}" + communishift_protected: "{{ item.value.do_not_delete | default(false) | bool }}" + +- name: Skip delete for protected Communishift project + ansible.builtin.debug: + msg: "Skipping deletion for {{ communishift_namespace }} (do_not_delete is true)" + when: communishift_protected | bool + +- name: Dry-run - eligible project deletion preview + ansible.builtin.debug: + msg: >- + Would delete Namespace {{ communishift_namespace }}, Communishift EFS access points and filesystem {{ communishift_namespace }}, + and Group {{ communishift_namespace }}-admins (set -e communishift_delete_projects_dry_run=false to apply). + when: + - not communishift_protected | bool + - communishift_delete_projects_dry_run | default(false) | bool + +- name: Delete Communishift namespace cluster resources then AWS/OpenShift teardown + when: + - not communishift_protected | bool + - not (communishift_delete_projects_dry_run | default(false) | bool) + block: + - name: Remove Communishift Kubernetes namespace + kubernetes.core.k8s: + api_key: "{{ communishift_ocp_api_token }}" + host: "{{ communishift_ocp_api_host }}" + api_version: v1 + kind: Namespace + name: "{{ communishift_namespace }}" + state: absent + wait: true + + - name: Delete Communishift-tagged EFS access points for project + communishift_storage_efs: + project_name: "{{ communishift_namespace }}" + state: absent + aws_access_key_id: "{{ communishift_efs_access_key }}" + aws_secret_access_key: "{{ communishift_efs_secret_key }}" + aws_region: "{{ communishift_region }}" + + - name: Remove Communishift EFS filesystem (matches community.aws.efs creation token) + community.aws.efs: + aws_access_key: "{{ communishift_efs_access_key }}" + aws_secret_key: "{{ communishift_efs_secret_key }}" + region: "{{ communishift_region }}" + state: absent + name: "{{ communishift_namespace }}" + wait: true + + - name: Remove OpenShift group for project admins + community.okd.k8s: + api_key: "{{ communishift_ocp_api_token }}" + host: "{{ communishift_ocp_api_host }}" + name: "{{ communishift_namespace }}-admins" + api_version: user.openshift.io/v1 + kind: Group + state: absent