From 441ff581525c906a152fa2527c2c1bdf4bf4ad6d Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Wed, 30 Jul 2025 11:07:18 -0400 Subject: [PATCH] Add pgAdmin installation role Signed-off-by: Webster Mudge --- roles/pgadmin/README.md | 109 ++++++ roles/pgadmin/defaults/main.yml | 40 +++ roles/pgadmin/handlers/main.yml | 21 ++ roles/pgadmin/meta/argument_specs.yml | 83 +++++ roles/pgadmin/molecule/default/converge.yml | 28 ++ roles/pgadmin/molecule/default/create.yml | 336 ++++++++++++++++++ roles/pgadmin/molecule/default/destroy.yml | 157 ++++++++ roles/pgadmin/molecule/default/molecule.yml | 55 +++ roles/pgadmin/molecule/default/prepare.yml | 32 ++ .../pgadmin/molecule/default/requirements.yml | 20 ++ roles/pgadmin/molecule/default/verify.yml | 36 ++ roles/pgadmin/tasks/main.yml | 49 +++ .../templates/pgadmin-db-servers.json.j2 | 8 + .../templates/pgadmin-docker-compose.yml.j2 | 21 ++ roles/pgadmin/templates/pgadmin-service.j2 | 15 + roles/pgadmin/vars/main.yml | 16 + 16 files changed, 1026 insertions(+) create mode 100644 roles/pgadmin/README.md create mode 100644 roles/pgadmin/defaults/main.yml create mode 100644 roles/pgadmin/handlers/main.yml create mode 100644 roles/pgadmin/meta/argument_specs.yml create mode 100644 roles/pgadmin/molecule/default/converge.yml create mode 100644 roles/pgadmin/molecule/default/create.yml create mode 100644 roles/pgadmin/molecule/default/destroy.yml create mode 100644 roles/pgadmin/molecule/default/molecule.yml create mode 100644 roles/pgadmin/molecule/default/prepare.yml create mode 100644 roles/pgadmin/molecule/default/requirements.yml create mode 100644 roles/pgadmin/molecule/default/verify.yml create mode 100644 roles/pgadmin/tasks/main.yml create mode 100644 roles/pgadmin/templates/pgadmin-db-servers.json.j2 create mode 100644 roles/pgadmin/templates/pgadmin-docker-compose.yml.j2 create mode 100644 roles/pgadmin/templates/pgadmin-service.j2 create mode 100644 roles/pgadmin/vars/main.yml diff --git a/roles/pgadmin/README.md b/roles/pgadmin/README.md new file mode 100644 index 00000000..76cb6875 --- /dev/null +++ b/roles/pgadmin/README.md @@ -0,0 +1,109 @@ +# pgadmin + +Install pgAdmin + +This role installs and configures pgAdmin 4, running it as a Docker container. It sets up automatic access to a list of specified PostgreSQL databases, enabling quick management through the pgAdmin web UI. A systemd service file is created to manage the pgAdmin container's lifecycle (start/stop). + +The role will: +- Pull the official pgAdmin 4 Docker image. +- Create necessary directories for pgAdmin configuration and data persistence. +- Generate and configure the `.pgpass` file within the container for seamless database authentication. +- Create a configuration file to preload specified database connections into pgAdmin upon its first launch. +- Define and configure the pgAdmin web UI access details (email and password). +- Create a systemd service unit file to manage the pgAdmin Docker container. +- Start and enable the pgAdmin service to run on system boot. + +# Requirements + +- **Docker**: The `docker` service must be installed and running on the target host. Consider using the `docker_install` role as a prerequisite. +- **Systemd**: The target host must use `systemd` for service management. +- **Network Access**: The target host where pgAdmin is installed must have network access to the PostgreSQL database server(s). +- **Database Credentials**: The provided `database_admin_user` and `database_admin_password` must have sufficient privileges to access the specified databases. + +# Dependencies + +- `docker_install` (Recommended for Docker installation) + +# Parameters + +| Variable | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `database_admin_user` | `str` | `True` | | Username for the database superuser account that pgAdmin will use to connect to databases. | +| `database_admin_password` | `str` | `True` | | Password for the database superuser account. | +| `database_host` | `str` | `True` | | The hostname or IP address of the primary PostgreSQL database server that pgAdmin will connect to. | +| `database_port` | `int` | `False` | `5432` | The port for connecting to the primary database server. | +| `pgadmin_db_servers` | `list` of `dict` | `False` | `[{Name: "CE Postgres", Group: "Servers", Port: "{{ database_port }}", Username: "{{ database_admin_user }}", PassFile: /pgpass, Host: "{{ database_host }}", SSLMode: prefer, MaintenanceDB: postgres}]` | A list of dictionaries, where each dictionary defines a database connection to be pre-loaded into pgAdmin at its first launch. Uses Jinja2 templating to derive values from `database_host`, `database_port`, and `database_admin_user` by default. | +| `pgadmin_pgpass` | `list` of `str` | `False` | `["{{ database_host }}:{{ database_port }}:*:{{ database_admin_user }}:{{ database_admin_password }}"]` | Contents for the `.pgpass` file within the pgAdmin container. Each element is a line in the format `hostname:port:database:username:password`. Uses Jinja2 templating by default to include the primary database's credentials. | +| `pgadmin_port` | `int` | `False` | `5050` | The port on the host where the pgAdmin web UI service will be listening. | +| `pgadmin_default_email` | `str` | `False` | `pgadmin@cloudera-labs.com` | Email account for the default user to access the pgAdmin web UI. This user is created on first launch of the container. | +| `pgadmin_default_password` | `str` | `False` | `pgadmin` | Password for the default user to access the pgAdmin web UI. **It is highly recommended to change this default password for production environments.** | +| `pgadmin_docker_exe` | `str` | `False` | `/usr/bin/docker` | The full path to the Docker executable on the target host. | + +# Example Playbook + +```yaml +- hosts: pgadmin_host + tasks: + - name: Ensure Docker is installed (if not already) + ansible.builtin.import_role: + name: cloudera.exe.docker # Prerequisite role + # You might pass variables to docker_install here if needed. + + - name: Install and configure pgAdmin for a single database + ansible.builtin.import_role: + name: cloudera.exe.pgadmin + vars: + database_admin_user: "postgres_superuser" + database_admin_password: "my_secure_db_password" + database_host: "my-db-server.example.com" + database_port: 5432 # Explicitly define if not default + + - name: Install pgAdmin with custom web UI port and multiple database connections + ansible.builtin.import_role: + name: cloudera.exe.pgadmin + vars: + database_admin_user: "dbuser" + database_admin_password: "another_secure_password" + database_host: "main-db.example.com" + pgadmin_port: 8080 # Custom port for web UI + pgadmin_default_email: "admin@mycompany.com" + pgadmin_default_password: "new_strong_password" + pgadmin_db_servers: + - Name: "Primary DB" + Group: "Production" + Port: 5432 + Username: "{{ database_admin_user }}" + PassFile: /pgpass + Host: "{{ database_host }}" + SSLMode: prefer + MaintenanceDB: postgres + - Name: "Analytics DB" + Group: "Data Warehousing" + Port: 5432 + Username: "analytics_user" + PassFile: /pgpass + Host: "analytics-db.example.com" + SSLMode: require + MaintenanceDB: analytics_db + pgadmin_pgpass: + - "{{ database_host }}:{{ database_port }}:*:{{ database_admin_user }}:{{ database_admin_password }}" + - "analytics-db.example.com:5432:*:analytics_user:analytics_password_secret" # Add credentials for analytics DB +``` + +## License + +``` +Copyright 2024 Cloudera, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +``` diff --git a/roles/pgadmin/defaults/main.yml b/roles/pgadmin/defaults/main.yml new file mode 100644 index 00000000..d66a5086 --- /dev/null +++ b/roles/pgadmin/defaults/main.yml @@ -0,0 +1,40 @@ +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +--- + +database_admin_user: "{{ undef(hint='Please defined the adminstrator username for the database server.') }}" +database_admin_password: "{{ undef(hint='Please defined the adminstrator password for the database server.') }}" +database_host: "{{ undef(hint='Please defined the database server hostname or IP address.') }}" +database_port: 5432 + +# List of database connections +pgadmin_db_servers: + - Name: "CE Postgres" + Group: "Servers" + Port: "{{ database_port }}" + Username: "{{ database_admin_user }}" + PassFile: /pgpass + Host: "{{ database_host }}" + SSLMode: prefer + MaintenanceDB: postgres + +pgadmin_pgpass: + - "{{ database_host }}:{{ database_port }}:*:{{ database_admin_user }}:{{ database_admin_password }}" + +pgadmin_port: 5050 +pgadmin_default_email: "pgadmin@cloudera-labs.com" +pgadmin_default_password: "pgadmin" + +pgadmin_docker_exe: /usr/bin/docker diff --git a/roles/pgadmin/handlers/main.yml b/roles/pgadmin/handlers/main.yml new file mode 100644 index 00000000..b4bc8b18 --- /dev/null +++ b/roles/pgadmin/handlers/main.yml @@ -0,0 +1,21 @@ +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +--- +- name: Start pgAdmin + ansible.builtin.systemd: + name: pgadmin + enabled: true + daemon-reload: true + state: restarted diff --git a/roles/pgadmin/meta/argument_specs.yml b/roles/pgadmin/meta/argument_specs.yml new file mode 100644 index 00000000..61484ad9 --- /dev/null +++ b/roles/pgadmin/meta/argument_specs.yml @@ -0,0 +1,83 @@ +--- +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +argument_specs: + main: + short_description: "Install pgAdmin" + description: + - Install pgAdmin and configure access to a list of specified databases. + - pgAdmin is run as a container with systemd service file is used to control container stop and start. + author: + - "Jim Enright " + version_added: "5.0.0" + options: + database_admin_user: + description: + - Username for database superuser account + type: str + required: true + database_admin_password: + description: + - Password for database superuser account + type: str + required: true + database_host: + description: The hostname or IP address of the database server. + type: str + required: true + database_port: + description: The port for connecting to the database server. + type: int + required: false + default: 5432 + pgadmin_db_servers: + description: List of database connections to pre-load into pgAdmin at first launch + type: list + elements: dict + default: + - Name: "CE Postgres" + Group: "Servers" + Port: "{{ database_port }}" + Username: "{{ database_admin_user }}" + PassFile: /pgpass + Host: "{{ database_host }}" + SSLMode: prefer + MaintenanceDB: postgres + pgadmin_pgpass: + description: Contents of pgpass file with credentials for databases accessed from pgAdmin + type: list + elements: str + default: + - "{{ database_host }}:{{ database_port }}:*:{{ database_admin_user }}:{{ database_admin_password }}" + pgadmin_port: + description: Port where pgAdmin web ui service will be listening. + type: int + required: false + default: 5050 + pgadmin_default_email: + description: Email account for default user to access the pgAdmin web ui. + type: str + required: false + default: "pgadmin@cloudera-labs.com" + pgadmin_default_password: + description: Password for default user to access the pgAdmin web ui. + type: str + required: false + default: "pgadmin" + pgadmin_docker_exe: + description: Directory of docker executable. + type: str + required: false + default: "/usr/bin/docker" diff --git a/roles/pgadmin/molecule/default/converge.yml b/roles/pgadmin/molecule/default/converge.yml new file mode 100644 index 00000000..ad449fc4 --- /dev/null +++ b/roles/pgadmin/molecule/default/converge.yml @@ -0,0 +1,28 @@ +--- +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +- name: Converge + hosts: all + gather_facts: true + become: true + tasks: + - name: Run pgamin role + ansible.builtin.import_role: + name: pgadmin + vars: + database_host: "{{ ansible_default_ipv4.address }}" + # Below are defined in molecule.yml + # database_admin_user: + # database_admin_password: diff --git a/roles/pgadmin/molecule/default/create.yml b/roles/pgadmin/molecule/default/create.yml new file mode 100644 index 00000000..41583e5e --- /dev/null +++ b/roles/pgadmin/molecule/default/create.yml @@ -0,0 +1,336 @@ +--- +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + # Run config handling + default_run_id: "{{ lookup('password', '/dev/null chars=ascii_lowercase length=5') }}" + default_run_config: + run_id: "{{ default_run_id }}" + + run_config_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/run-config.yml" + run_config_from_file: "{{ (lookup('file', run_config_path, errors='ignore') or '{}') | from_yaml }}" + run_config: "{{ default_run_config | combine(run_config_from_file) }}" + + # Platform settings handling + default_assign_public_ip: true + default_aws_profile: "{{ lookup('env', 'AWS_PROFILE') }}" + default_boot_wait_seconds: 120 + default_instance_type: t3a.medium + default_key_inject_method: cloud-init # valid values: [cloud-init, ec2] + default_key_name: "molecule-{{ run_config.run_id }}" + default_private_key_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/id_rsa" + default_public_key_path: "{{ default_private_key_path }}.pub" + default_ssh_user: ansible + default_ssh_port: 22 + default_user_data: "" + + default_security_group_name: "molecule-{{ run_config.run_id }}" + default_security_group_description: Ephemeral security group for Molecule instances + default_security_group_rules: + - proto: tcp + from_port: "{{ default_ssh_port }}" + to_port: "{{ default_ssh_port }}" + cidr_ip: "0.0.0.0/0" + - proto: icmp + from_port: 8 + to_port: -1 + cidr_ip: "0.0.0.0/0" + default_security_group_rules_egress: + - proto: -1 + from_port: 0 + to_port: 0 + cidr_ip: "0.0.0.0/0" + + platform_defaults: + assign_public_ip: "{{ default_assign_public_ip }}" + aws_profile: "{{ default_aws_profile }}" + boot_wait_seconds: "{{ default_boot_wait_seconds }}" + instance_type: "{{ default_instance_type }}" + key_inject_method: "{{ default_key_inject_method }}" + key_name: "{{ default_key_name }}" + private_key_path: "{{ default_private_key_path }}" + public_key_path: "{{ default_public_key_path }}" + security_group_name: "{{ default_security_group_name }}" + security_group_description: "{{ default_security_group_description }}" + security_group_rules: "{{ default_security_group_rules }}" + security_group_rules_egress: "{{ default_security_group_rules_egress }}" + ssh_user: "{{ default_ssh_user }}" + ssh_port: "{{ default_ssh_port }}" + cloud_config: {} + image: "" + image_name: "" + image_owner: [self] + name: "" + region: "" + security_groups: [] + tags: {} + volumes: [] + vpc_id: "" + vpc_subnet_id: "" + + # Merging defaults into a list of dicts is, it turns out, not straightforward + platforms: >- + {{ [platform_defaults | dict2items] + | product(molecule_yml.platforms | map('dict2items') | list) + | map('flatten', levels=1) + | list + | map('items2dict') + | list }} + pre_tasks: + - name: Validate platform configurations + ansible.builtin.assert: + that: + - platforms | length > 0 + - platform.name is string and platform.name | length > 0 + - platform.assign_public_ip is boolean + - platform.aws_profile is string + - platform.boot_wait_seconds is integer and platform.boot_wait_seconds >= 0 + - platform.cloud_config is mapping + - platform.image is string + - platform.image_name is string + - platform.image_owner is sequence or (platform.image_owner is string and platform.image_owner | length > 0) + - platform.instance_type is string and platform.instance_type | length > 0 + - platform.key_inject_method is in ["cloud-init", "ec2"] + - platform.key_name is string and platform.key_name | length > 0 + - platform.private_key_path is string and platform.private_key_path | length > 0 + - platform.public_key_path is string and platform.public_key_path | length > 0 + - platform.region is string + - platform.security_group_name is string and platform.security_group_name | length > 0 + - platform.security_group_description is string and platform.security_group_description | length > 0 + - platform.security_group_rules is sequence + - platform.security_group_rules_egress is sequence + - platform.security_groups is sequence + - platform.ssh_user is string and platform.ssh_user | length > 0 + - platform.ssh_port is integer and platform.ssh_port in range(1, 65536) + - platform.tags is mapping + - platform.volumes is sequence + - platform.vpc_id is string + - platform.vpc_subnet_id is string and platform.vpc_subnet_id | length > 0 + quiet: true + loop: "{{ platforms }}" + loop_control: + loop_var: platform + label: "{{ platform.name }}" + tasks: + - name: Write run config to file + ansible.builtin.copy: + dest: "{{ run_config_path }}" + content: "{{ run_config | to_yaml }}" + mode: "0600" + + - name: Generate local key pairs + community.crypto.openssh_keypair: + path: "{{ item.private_key_path }}" + type: rsa + size: 2048 + regenerate: never + loop: "{{ platforms }}" + loop_control: + label: "{{ item.name }}" + register: local_keypairs + + - name: Look up EC2 AMI(s) by owner and name (if image not set) + amazon.aws.ec2_ami_info: + owners: "{{ item.image_owner }}" + filters: "{{ item.image_filters | default({}) | combine(image_name_map) }}" + vars: + image_name_map: "{% if item.image_name is defined and item.image_name | length > 0 %}{{ {'name': item.image_name} }}{% else %}{}{% endif %}" + loop: "{{ platforms }}" + loop_control: + label: "{{ item.name }}" + when: not item.image + register: ami_info + + - name: Look up subnets to determine VPCs (if needed) + amazon.aws.ec2_vpc_subnet_info: + subnet_ids: "{{ item.vpc_subnet_id }}" + loop: "{{ platforms }}" + loop_control: + label: "{{ item.name }}" + when: not item.vpc_id + register: subnet_info + + - name: Validate discovered information + ansible.builtin.assert: + that: + - platform.image or (ami_info.results[index].images | length > 0) + - platform.vpc_id or (subnet_info.results[index].subnets | length > 0) + quiet: true + loop: "{{ platforms }}" + loop_control: + loop_var: platform + index_var: index + label: "{{ platform.name }}" + + - name: Create ephemeral EC2 keys (if needed) + amazon.aws.ec2_key: + profile: "{{ item.aws_profile | default(omit) }}" + region: "{{ item.region | default(omit) }}" + name: "{{ item.key_name }}" + key_material: "{{ local_keypair.public_key }}" + vars: + local_keypair: "{{ local_keypairs.results[index] }}" + loop: "{{ platforms }}" + loop_control: + index_var: index + label: "{{ item.name }}" + when: item.key_inject_method == "ec2" + register: ec2_keys + + - name: Create ephemeral security groups (if needed) + amazon.aws.ec2_security_group: + profile: "{{ item.aws_profile | default(omit) }}" + iam_instance_profile: "{{ item.iam_instance_profile | default(omit) }}" + region: "{{ item.region | default(omit) }}" + vpc_id: "{{ item.vpc_id or vpc_subnet.vpc_id }}" + name: "{{ item.security_group_name }}" + description: "{{ item.security_group_description }}" + rules: "{{ item.security_group_rules }}" + rules_egress: "{{ item.security_group_rules_egress }}" + vars: + vpc_subnet: "{{ subnet_info.results[index].subnets[0] }}" + loop: "{{ platforms }}" + loop_control: + index_var: index + label: "{{ item.name }}" + when: item.security_groups | length == 0 + + - name: Create ephemeral EC2 instance(s) + amazon.aws.ec2_instance: + profile: "{{ item.aws_profile | default(omit) }}" + region: "{{ item.region | default(omit) }}" + filters: "{{ platform_filters }}" + instance_type: "{{ item.instance_type }}" + image_id: "{{ platform_image_id }}" + vpc_subnet_id: "{{ item.vpc_subnet_id }}" + security_groups: "{{ platform_security_groups }}" + network: + assign_public_ip: "{{ item.assign_public_ip }}" + volumes: "{{ item.volumes }}" + key_name: "{{ (item.key_inject_method == 'ec2') | ternary(item.key_name, omit) }}" + tags: "{{ platform_tags }}" + user_data: "{{ platform_user_data }}" + state: "running" + wait: true + vars: + platform_security_groups: "{{ item.security_groups or [item.security_group_name] }}" + platform_generated_image_id: "{{ (ami_info.results[index].images | sort(attribute='creation_date', reverse=True))[0].image_id }}" + platform_image_id: "{{ item.image or platform_generated_image_id }}" + + platform_generated_cloud_config: + users: + - name: "{{ item.ssh_user }}" + ssh_authorized_keys: + - "{{ local_keypairs.results[index].public_key }}" + sudo: "ALL=(ALL) NOPASSWD:ALL" + platform_cloud_config: >- + {{ (item.key_inject_method == 'cloud-init') + | ternary((item.cloud_config | combine(platform_generated_cloud_config)), item.cloud_config) }} + platform_user_data: |- + #cloud-config + {{ platform_cloud_config | to_yaml }} + + platform_generated_tags: + instance: "{{ item.name }}" + "molecule-run-id": "{{ run_config.run_id }}" + platform_tags: "{{ (item.tags or {}) | combine(platform_generated_tags) }}" + platform_filter_keys: "{{ platform_generated_tags.keys() | map('regex_replace', '^(.+)$', 'tag:\\1') }}" + platform_filters: "{{ dict(platform_filter_keys | zip(platform_generated_tags.values())) }}" + loop: "{{ platforms }}" + loop_control: + index_var: index + label: "{{ item.name }}" + register: ec2_instances_async + async: 7200 + poll: 0 + + - name: Instance boot block + when: ec2_instances_async is changed + block: + - name: Wait for instance creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ ec2_instances_async.results }}" + loop_control: + index_var: index + label: "{{ platforms[index].name }}" + register: ec2_instances + until: ec2_instances is finished + retries: 300 + + - name: Collect instance configs + ansible.builtin.set_fact: + instance_config: + instance: "{{ item.name }}" + address: "{{ item.assign_public_ip | ternary(instance.public_ip_address, instance.private_ip_address) }}" + user: "{{ item.ssh_user }}" + port: "{{ item.ssh_port }}" + identity_file: "{{ item.private_key_path }}" + instance_ids: + - "{{ instance.instance_id }}" + vars: + instance: "{{ ec2_instances.results[index].instances[0] }}" + loop: "{{ platforms }}" + loop_control: + index_var: index + label: "{{ item.name }}" + register: instance_configs + + - name: Write Molecule instance configs + ansible.builtin.copy: + dest: "{{ molecule_instance_config }}" + content: >- + {{ instance_configs.results + | map(attribute='ansible_facts.instance_config') + | list + | to_json + | from_json + | to_yaml }} + mode: "0600" + + - name: Start SSH pollers + ansible.builtin.wait_for: + host: "{{ item.address }}" + port: "{{ item.port }}" + search_regex: SSH + delay: 10 + timeout: 320 + loop: "{{ instance_configs.results | map(attribute='ansible_facts.instance_config') | list }}" + loop_control: + label: "{{ item.instance }}" + register: ssh_wait_async + async: 300 + poll: 0 + + - name: Wait for SSH + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ ssh_wait_async.results }}" + loop_control: + index_var: index + label: "{{ platforms[index].name }}" + register: ssh_wait + until: ssh_wait is finished + retries: 300 + delay: 1 + + - name: Wait for boot process to finish + ansible.builtin.pause: + seconds: "{{ platforms | map(attribute='boot_wait_seconds') | max }}" diff --git a/roles/pgadmin/molecule/default/destroy.yml b/roles/pgadmin/molecule/default/destroy.yml new file mode 100644 index 00000000..a090fe0b --- /dev/null +++ b/roles/pgadmin/molecule/default/destroy.yml @@ -0,0 +1,157 @@ +--- +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + # Run config handling + default_run_id: "{{ lookup('password', '/dev/null chars=ascii_lowercase length=5') }}" + default_run_config: + run_id: "{{ default_run_id }}" + + run_config_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/run-config.yml" + run_config_from_file: "{{ (lookup('file', run_config_path, errors='ignore') or '{}') | from_yaml }}" + run_config: "{{ default_run_config | combine(run_config_from_file) }}" + + # Platform settings handling + default_aws_profile: "{{ lookup('env', 'AWS_PROFILE') }}" + default_key_inject_method: cloud-init # valid values: [cloud-init, ec2] + default_key_name: "molecule-{{ run_config.run_id }}" + default_security_group_name: "molecule-{{ run_config.run_id }}" + + platform_defaults: + aws_profile: "{{ default_aws_profile }}" + key_inject_method: "{{ default_key_inject_method }}" + key_name: "{{ default_key_name }}" + region: "" + security_group_name: "{{ default_security_group_name }}" + security_groups: [] + vpc_id: "" + vpc_subnet_id: "" + + # Merging defaults into a list of dicts is, it turns out, not straightforward + platforms: >- + {{ [platform_defaults | dict2items] + | product(molecule_yml.platforms | map('dict2items') | list) + | map('flatten', levels=1) + | list + | map('items2dict') + | list }} + + # Stored instance config + instance_config: "{{ (lookup('file', molecule_instance_config, errors='ignore') or '{}') | from_yaml }}" + pre_tasks: + - name: Validate platform configurations + ansible.builtin.assert: + that: + - platforms | length > 0 + - platform.name is string and platform.name | length > 0 + - platform.aws_profile is string + - platform.key_inject_method is in ["cloud-init", "ec2"] + - platform.key_name is string and platform.key_name | length > 0 + - platform.region is string + - platform.security_group_name is string and platform.security_group_name | length > 0 + - platform.security_groups is sequence + - platform.vpc_id is string + - platform.vpc_subnet_id is string and platform.vpc_subnet_id | length > 0 + quiet: true + loop: "{{ platforms }}" + loop_control: + loop_var: platform + label: "{{ platform.name }}" + tasks: + - name: Look up subnets to determine VPCs (if needed) + amazon.aws.ec2_vpc_subnet_info: + profile: "{{ item.aws_profile | default(omit) }}" + region: "{{ item.region | default(omit) }}" + subnet_ids: "{{ item.vpc_subnet_id }}" + loop: "{{ platforms }}" + loop_control: + label: "{{ item.name }}" + when: not item.vpc_id + register: subnet_info + + - name: Validate discovered information + ansible.builtin.assert: + that: platform.vpc_id or (subnet_info.results[index].subnets | length > 0) + quiet: true + loop: "{{ platforms }}" + loop_control: + loop_var: platform + index_var: index + label: "{{ platform.name }}" + + - name: Destroy resources + when: instance_config | length != 0 + block: + - name: Destroy ephemeral EC2 instances + amazon.aws.ec2_instance: + profile: "{{ item.aws_profile | default(omit) }}" + region: "{{ item.region | default(omit) }}" + instance_ids: "{{ instance_config | map(attribute='instance_ids') | flatten }}" + vpc_subnet_id: "{{ item.vpc_subnet_id }}" + state: absent + loop: "{{ platforms }}" + loop_control: + label: "{{ item.name }}" + register: ec2_instances_async + async: 7200 + poll: 0 + + - name: Wait for instance destruction to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ ec2_instances_async.results }}" + loop_control: + index_var: index + label: "{{ platforms[index].name }}" + register: ec2_instances + until: ec2_instances is finished + retries: 300 + + - name: Destroy ephemeral security groups (if needed) + amazon.aws.ec2_security_group: + profile: "{{ item.aws_profile | default(omit) }}" + region: "{{ item.region | default(omit) }}" + vpc_id: "{{ item.vpc_id or vpc_subnet.vpc_id }}" + name: "{{ item.security_group_name }}" + state: absent + vars: + vpc_subnet: "{{ subnet_info.results[index].subnets[0] }}" + loop: "{{ platforms }}" + loop_control: + index_var: index + label: "{{ item.name }}" + when: item.security_groups | length == 0 + + - name: Destroy ephemeral keys (if needed) + amazon.aws.ec2_key: + profile: "{{ item.aws_profile | default(omit) }}" + region: "{{ item.region | default(omit) }}" + name: "{{ item.key_name }}" + state: absent + loop: "{{ platforms }}" + loop_control: + index_var: index + label: "{{ item.name }}" + when: item.key_inject_method == "ec2" + + - name: Write Molecule instance configs + ansible.builtin.copy: + dest: "{{ molecule_instance_config }}" + content: "{{ {} | to_yaml }}" diff --git a/roles/pgadmin/molecule/default/molecule.yml b/roles/pgadmin/molecule/default/molecule.yml new file mode 100644 index 00000000..426bed64 --- /dev/null +++ b/roles/pgadmin/molecule/default/molecule.yml @@ -0,0 +1,55 @@ +--- +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Requires a preexisting AWS VPC and subnet, the latter referenced by the +# environment variable, ROLE_SUBNET_ID, typically set in the Molecule ENV file +# which is /.env.yml + +# Requires AWS credentials, including region + +# Requires the following Python libraries on localhost +# - netattr +# - boto3 + +driver: + name: ec2 +platforms: + # RHEL 9.4 + - name: rhel9-4.molecule.internal + image_owner: "309956199498" + image_name: RHEL-9.4.0_HVM-* + instance_type: t3.medium + boot_wait_seconds: 15 + vpc_subnet_id: ${TEST_VPC_SUBNET_ID} + tags: + Name: molecule-pgadmin-rhel9-4 + Project: Molecule testing for pgadmin +# Ubuntu 20.04 +# - name: ubuntu20.04.molecule.internal +# image_owner: "099720109477" +# image_name: ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-* +# instance_type: t3.medium +# boot_wait_seconds: 15 +# vpc_subnet_id: ${TEST_VPC_SUBNET_ID} +# tags: +# Name: molecule-pgadmin-ubuntu20-04 +# Project: Molecule testing for pgadmin +provisioner: + name: ansible + inventory: + group_vars: + all: + database_admin_user: molecule + database_admin_password: molecule diff --git a/roles/pgadmin/molecule/default/prepare.yml b/roles/pgadmin/molecule/default/prepare.yml new file mode 100644 index 00000000..bf5f7079 --- /dev/null +++ b/roles/pgadmin/molecule/default/prepare.yml @@ -0,0 +1,32 @@ +--- +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +- name: Prepare + hosts: all + gather_facts: true + become: true + tasks: + - name: Setup Postgres + ansible.builtin.import_role: + name: postgresql_server + vars: + create_database_admin_user: true + # Below variables are set in molecule.yml + # database_admin_user: + # database_admin_password: + + - name: Install Docker + ansible.builtin.import_role: + name: docker diff --git a/roles/pgadmin/molecule/default/requirements.yml b/roles/pgadmin/molecule/default/requirements.yml new file mode 100644 index 00000000..73de1d73 --- /dev/null +++ b/roles/pgadmin/molecule/default/requirements.yml @@ -0,0 +1,20 @@ +--- +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +collections: + - community.docker + - community.crypto + - amazon.aws + - ansible.utils diff --git a/roles/pgadmin/molecule/default/verify.yml b/roles/pgadmin/molecule/default/verify.yml new file mode 100644 index 00000000..abdd41ff --- /dev/null +++ b/roles/pgadmin/molecule/default/verify.yml @@ -0,0 +1,36 @@ +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +--- +- name: Verify + hosts: all + gather_facts: true + tasks: + # Check 1 - confirm pgadmin service is running + - name: Get all services + ansible.builtin.service_facts: + + - name: Check pgAdmin + ansible.builtin.assert: + that: + - "'pgadmin.service' in ansible_facts.services" + - ansible_facts.services['pgadmin.service'].state == 'running' + - ansible_facts.services['pgadmin.service'].status == 'enabled' + + # Check 2 - confirm pgadmin port is listening + - name: Confirm pgAdmin web ui port is listening + ansible.builtin.wait_for: + port: 5050 + delay: 30 + timeout: 300 diff --git a/roles/pgadmin/tasks/main.yml b/roles/pgadmin/tasks/main.yml new file mode 100644 index 00000000..1601e353 --- /dev/null +++ b/roles/pgadmin/tasks/main.yml @@ -0,0 +1,49 @@ +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +--- +- name: Make directory for the service + ansible.builtin.file: + path: "{{ service_root_dir }}" + state: directory + mode: "0755" + +- name: Copy database servers definition file into place + ansible.builtin.template: + src: pgadmin-db-servers.json.j2 + dest: "{{ service_root_dir }}/servers.json" + mode: "0755" + +- name: Create the pgpass file + ansible.builtin.copy: + dest: "{{ service_root_dir }}/pgpass" + content: | + {% for pgpass_entry in pgadmin_pgpass %} + {{ pgpass_entry }} + {% endfor %} + mode: "0644" + no_log: true + +- name: Copy docker-compose.yml file into place + ansible.builtin.template: + src: pgadmin-docker-compose.yml.j2 + dest: "{{ service_root_dir }}/docker-compose.yml" + mode: "0644" + +- name: Configure systemd service + ansible.builtin.template: + src: "pgadmin-service.j2" + dest: /etc/systemd/system/pgadmin.service + mode: "0755" + notify: Start pgAdmin diff --git a/roles/pgadmin/templates/pgadmin-db-servers.json.j2 b/roles/pgadmin/templates/pgadmin-db-servers.json.j2 new file mode 100644 index 00000000..b93f2481 --- /dev/null +++ b/roles/pgadmin/templates/pgadmin-db-servers.json.j2 @@ -0,0 +1,8 @@ +{ +"Servers": { +{% for server in pgadmin_db_servers %} + "{{ loop.index }}": + {{ server|tojson(indent=4)}} +{% endfor %} + } +} diff --git a/roles/pgadmin/templates/pgadmin-docker-compose.yml.j2 b/roles/pgadmin/templates/pgadmin-docker-compose.yml.j2 new file mode 100644 index 00000000..39f07881 --- /dev/null +++ b/roles/pgadmin/templates/pgadmin-docker-compose.yml.j2 @@ -0,0 +1,21 @@ +services: + pgadmin: + image: dpage/pgadmin4:latest + container_name: pgadmin4_container + restart: always + ports: + - "{{ pgadmin_port }}:80" + entrypoint: /bin/sh -c "chmod 600 /pgpass; /entrypoint.sh;" + user: root + environment: + PGADMIN_DEFAULT_EMAIL: {{ pgadmin_default_email }} + PGADMIN_DEFAULT_PASSWORD: {{ pgadmin_default_password }} + PGADMIN_DISABLE_POSTFIX: True + PGPASSFILE: /pgpass + volumes: + - {{ service_root_dir }}/servers.json:/pgadmin4/servers.json + - {{ service_root_dir }}/pgpass:/pgpass:0400 + - pgadmin-data:/var/lib/pgadmin + +volumes: + pgadmin-data: diff --git a/roles/pgadmin/templates/pgadmin-service.j2 b/roles/pgadmin/templates/pgadmin-service.j2 new file mode 100644 index 00000000..5320aa39 --- /dev/null +++ b/roles/pgadmin/templates/pgadmin-service.j2 @@ -0,0 +1,15 @@ +[Unit] +Description=pgAdmin Service +After=docker.service +Requires=docker.service + +[Service] +TimeoutSec=infinity +Restart=always +RestartSec=3 + +ExecStart={{ pgadmin_docker_exe }} compose -f {{ service_root_dir }}/docker-compose.yml --project-name pgadmin up +ExecStop={{ pgadmin_docker_exe }} compose -f {{ service_root_dir }}/docker-compose.yml --project-name pgadmin stop + +[Install] +WantedBy=multi-user.target diff --git a/roles/pgadmin/vars/main.yml b/roles/pgadmin/vars/main.yml new file mode 100644 index 00000000..5dcb8b17 --- /dev/null +++ b/roles/pgadmin/vars/main.yml @@ -0,0 +1,16 @@ +--- +# Copyright 2024 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +service_root_dir: /usr/local/pgadmin