diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index b612178..4fbcbe7 100755 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -9,6 +9,12 @@ echo "pfSensible HAProxy DevContainer Setup" echo "=========================================" echo "" +# Install system dependencies +echo "📦 Installing system dependencies..." +sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck +echo "✓ System dependencies installed" +echo "" + # Install Python requirements echo "📦 Installing Python requirements..." pip install --quiet --upgrade pip @@ -19,7 +25,12 @@ echo "" # Install Ansible collections echo "📦 Installing Ansible collections..." ansible-galaxy collection install community.internal_test_tools -ansible-galaxy collection install git+https://github.com/pfsensible/core.git +# Install pfsensible.core into the workspace collection root so ansible-test can find it +ansible-galaxy collection install -p /workspaces/ansible_collections git+https://github.com/pfsensible/core.git +# Symlink community namespace so ansible-test can find it +if [ ! -e /workspaces/ansible_collections/community ]; then + ln -s /home/vscode/.ansible/collections/ansible_collections/community /workspaces/ansible_collections/community +fi echo "✓ Ansible collections installed" echo "" @@ -33,6 +44,12 @@ else fi echo "" +# Install pre-commit hooks +echo "🔧 Installing pre-commit hooks..." +pre-commit install +echo "✓ Pre-commit hooks installed" +echo "" + # Display environment information echo "=========================================" echo "✓ DevContainer setup complete!" diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt index 35b8f8a..de9e3e7 100644 --- a/.devcontainer/requirements.txt +++ b/.devcontainer/requirements.txt @@ -21,3 +21,6 @@ pycodestyle # Changelog management tool antsibull-changelog + +# Pre-commit framework for git hooks +pre-commit>=3.6.0 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e714784..b66998f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,69 +2,187 @@ name: CI # Controls when the workflow will run on: - # Triggers the workflow on push or pull request events + # Triggers the workflow on push to main and feature branches push: + branches: + - main + - 'feature/**' + # Triggers on pull request events pull_request: + types: [opened, synchronize, reopened] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" + # Fast PR validation job - matches pre-commit hooks + pr-checks: + name: PR Checks (pycodestyle, sanity, units) + runs-on: ubuntu-latest + # Only run on pull requests + if: github.event_name == 'pull_request' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install dependencies + run: | + pip install pycodestyle ansible-core==2.16.* dnspython parameterized pyyaml pytest + ansible-galaxy collection install community.internal_test_tools + ansible-galaxy collection install git+https://github.com/pfsensible/core.git + + - name: Setup collection directory structure + run: | + mkdir -p ~/.ansible/collections/ansible_collections/pfsensible + cp -al $PWD ~/.ansible/collections/ansible_collections/pfsensible/haproxy + + - name: Run pycodestyle + id: pycodestyle + working-directory: ~/.ansible/collections/ansible_collections/pfsensible/haproxy + run: | + echo "## Pycodestyle Results" >> $GITHUB_STEP_SUMMARY + if pycodestyle --config=setup.cfg plugins/ tests/ 2>&1 | tee /tmp/pycodestyle.txt; then + echo "✅ All Python files pass style checks" >> $GITHUB_STEP_SUMMARY + echo "status=success" >> $GITHUB_OUTPUT + else + echo "❌ Style check failures found:" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat /tmp/pycodestyle.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "status=failure" >> $GITHUB_OUTPUT + exit 1 + fi + + - name: Run ansible-test sanity + id: sanity + working-directory: ~/.ansible/collections/ansible_collections/pfsensible/haproxy + run: | + echo "## Ansible Sanity Test Results" >> $GITHUB_STEP_SUMMARY + if ansible-test sanity --requirements --python 3.10 2>&1 | tee /tmp/sanity.txt; then + echo "✅ All sanity tests passed" >> $GITHUB_STEP_SUMMARY + echo "status=success" >> $GITHUB_OUTPUT + else + echo "❌ Sanity test failures:" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -50 /tmp/sanity.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "status=failure" >> $GITHUB_OUTPUT + exit 1 + fi + + - name: Run ansible-test units + id: units + working-directory: ~/.ansible/collections/ansible_collections/pfsensible/haproxy + run: | + echo "## Ansible Unit Test Results" >> $GITHUB_STEP_SUMMARY + if ansible-test units --requirements --python 3.10 2>&1 | tee /tmp/units.txt; then + # Extract test count from pytest output + TEST_COUNT=$(grep -oP '\d+(?= passed)' /tmp/units.txt | tail -1) + echo "✅ All ${TEST_COUNT:-0} unit tests passed" >> $GITHUB_STEP_SUMMARY + echo "status=success" >> $GITHUB_OUTPUT + else + echo "❌ Unit test failures:" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -50 /tmp/units.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "status=failure" >> $GITHUB_OUTPUT + exit 1 + fi + + - name: Summary + if: always() + run: | + echo "## 📊 PR Checks Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Pycodestyle | ${{ steps.pycodestyle.outputs.status == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Ansible Sanity | ${{ steps.sanity.outputs.status == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Ansible Units | ${{ steps.units.outputs.status == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + + # Comprehensive testing across Ansible versions build: - # The type of runner that the job will run on + name: Build (Python ${{ matrix.python-version }}, Ansible ${{ matrix.ansible-version }}) runs-on: ubuntu-latest + # Run on pushes and workflow_dispatch, but not on PRs (pr-checks handles those) + if: github.event_name != 'pull_request' strategy: fail-fast: false matrix: python-version: ['3.10'] ansible-version: ['2.14', '2.15', '2.16'] - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - name: Checkout project - uses: actions/checkout@v3 + - name: Checkout code + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: 'pip' - - name: Cache pip modules - uses: actions/cache@v3 - env: - cache-name: cache-pip - with: - path: | - ~/.cache - key: ${{ runner.os }}-build-${{ env.cache-name }}-python-${{ matrix.python-version }} - - - name: Cache ansible setup - uses: actions/cache@v3 - env: - cache-name: cache-ansible - with: - path: | - ~/work/ansible-pfsense/ansible-pfsense/ansible - key: build-${{ env.cache-name }}-ansible-${{ matrix.ansible-version }} - - # Runs a set of commands using the runners shell - - name: Install ansible and deps + - name: Install dependencies run: | - pip install ansible-core==${{ matrix.ansible-version }}.* dnspython parameterized pyyaml + pip install ansible-core==${{ matrix.ansible-version }}.* dnspython parameterized pyyaml pytest ansible-galaxy collection install community.internal_test_tools - # Not currently shipping tests in releases ansible-galaxy collection install git+https://github.com/pfsensible/core.git - - name: Run ansible tests + - name: Setup collection directory structure run: | - pwd - dir=$(pwd) mkdir -p ~/.ansible/collections/ansible_collections/pfsensible - cd ~/.ansible/collections/ansible_collections/pfsensible - cp -al $dir haproxy - cd haproxy - ansible-test sanity --requirements --python ${{ matrix.python-version }} - ansible-test units --requirements --python ${{ matrix.python-version }} + cp -al $PWD ~/.ansible/collections/ansible_collections/pfsensible/haproxy + + - name: Run ansible-test sanity + id: sanity + working-directory: ~/.ansible/collections/ansible_collections/pfsensible/haproxy + run: | + echo "## Ansible Sanity Test Results (Ansible ${{ matrix.ansible-version }})" >> $GITHUB_STEP_SUMMARY + if ansible-test sanity --requirements --python ${{ matrix.python-version }} 2>&1 | tee /tmp/sanity.txt; then + echo "✅ All sanity tests passed" >> $GITHUB_STEP_SUMMARY + echo "status=success" >> $GITHUB_OUTPUT + else + echo "❌ Sanity test failures:" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -50 /tmp/sanity.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "status=failure" >> $GITHUB_OUTPUT + exit 1 + fi + + - name: Run ansible-test units + id: units + working-directory: ~/.ansible/collections/ansible_collections/pfsensible/haproxy + run: | + echo "## Ansible Unit Test Results (Ansible ${{ matrix.ansible-version }})" >> $GITHUB_STEP_SUMMARY + if ansible-test units --requirements --python ${{ matrix.python-version }} 2>&1 | tee /tmp/units.txt; then + # Extract test count from pytest output + TEST_COUNT=$(grep -oP '\d+(?= passed)' /tmp/units.txt | tail -1) + echo "✅ All ${TEST_COUNT:-0} unit tests passed" >> $GITHUB_STEP_SUMMARY + echo "status=success" >> $GITHUB_OUTPUT + else + echo "❌ Unit test failures:" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -50 /tmp/units.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "status=failure" >> $GITHUB_OUTPUT + exit 1 + fi + + - name: Summary + if: always() + run: | + echo "## 📊 Build Summary (Python ${{ matrix.python-version }}, Ansible ${{ matrix.ansible-version }})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Ansible Sanity | ${{ steps.sanity.outputs.status == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Ansible Units | ${{ steps.units.outputs.status == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index b8435d8..7651c95 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,9 @@ venv.bak/ # VS Code user-specific settings .vscode/settings.json + +#macOS +.DS_Store + +# Pre-commit cache +.pre-commit-cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5ff5ec0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,62 @@ +# Pre-commit hooks for pfsensible.haproxy Ansible collection +# See https://pre-commit.com for more information + +default_language_version: + python: python3.10 + +repos: + # Standard pre-commit hooks for file hygiene + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + exclude: '^tests/output/' + - id: end-of-file-fixer + exclude: '^tests/output/' + - id: check-yaml + args: ['--unsafe'] # Allow custom YAML tags used by Ansible + - id: check-added-large-files + args: ['--maxkb=500'] + - id: mixed-line-ending + args: ['--fix=lf'] + - id: check-merge-conflict + - id: debug-statements + + # Local hooks for Ansible collection testing + - repo: local + hooks: + # Fast: pycodestyle linting (runs on changed Python files only) + - id: pycodestyle + name: Python Code Style (pycodestyle) + entry: pycodestyle + language: system + types: [python] + args: ['--config=setup.cfg'] + + # Medium: ansible-test sanity checks + - id: ansible-test-sanity + name: Ansible Test Sanity + entry: bash + language: system + args: + - -c + - | + echo "Running ansible-test sanity checks..." + ansible-test sanity --requirements --python 3.10 + pass_filenames: false + files: '\.(py|yml|yaml)$' + verbose: true + + # Slow: ansible-test unit tests + - id: ansible-test-units + name: Ansible Test Units + entry: bash + language: system + args: + - -c + - | + echo "Running ansible-test unit tests..." + ansible-test units --requirements --python 3.10 + pass_filenames: false + files: '\.(py|yml|yaml)$' + verbose: true diff --git a/README.md b/README.md index a22a5c3..0320c38 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,62 @@ The following modules are currently available: The modules assume that you have already installed the haproxy pfSense package. +## Development + +### Pre-commit Hooks + +This project uses [pre-commit](https://pre-commit.com/) to run automated tests before each commit: + +- **pycodestyle**: Python code style checking +- **ansible-test sanity**: Ansible module validation +- **ansible-test units**: Unit tests + +Hooks are automatically installed when using the devcontainer. + +#### Skipping Hooks + +For quick commits when needed: + +```bash +# Skip unit tests only (faster commits) +SKIP=ansible-test-units git commit -m "docs: update README" + +# Skip all hooks (use sparingly) +git commit --no-verify -m "wip: experimental changes" +``` + +#### Running Hooks Manually + +```bash +# Run all hooks on all files +pre-commit run --all-files + +# Run specific hook +pre-commit run ansible-test-sanity --all-files +``` + +### GitHub Actions CI + +Pull requests automatically run the same checks as pre-commit hooks: + +- **pycodestyle**: Python code style validation +- **ansible-test sanity**: Ansible module validation +- **ansible-test units**: Unit test suite + +#### Status Checks + +The `PR Checks` job must pass before merging. View detailed results in the Actions tab. + +To configure as a required check: +1. Go to repository Settings → Branches → Branch protection rules +2. Select the main branch +3. Enable "Require status checks to pass before merging" +4. Select "PR Checks (pycodestyle, sanity, units)" + +#### Full Test Matrix + +The `build` job runs comprehensive tests across multiple Ansible versions (2.14, 2.15, 2.16) on pushes to main. + ## [Change Log](https://github.com/pfsensible/haproxy/blob/master/CHANGELOG.rst) ## Operation diff --git a/galaxy.yml b/galaxy.yml index 87b0210..f8f468e 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -12,6 +12,7 @@ readme: README.md authors: - Frederic Bor - Orion Poplawski +- Chris Morton ### OPTIONAL but strongly recommended diff --git a/plugins/module_utils/haproxy_frontend.py b/plugins/module_utils/haproxy_frontend.py new file mode 100644 index 0000000..091f1f3 --- /dev/null +++ b/plugins/module_utils/haproxy_frontend.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Chris Morton, cosmo@cosmo.2y.net +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type +import re +from ansible_collections.pfsensible.core.plugins.module_utils.module_base import PFSenseModuleBase + +HAPROXY_FRONTEND_ARGUMENT_SPEC = dict( + state=dict(default='present', choices=['present', 'absent']), + name=dict(required=True, type='str'), + status=dict(required=False, type='str'), + desc=dict(required=False, type='str'), + type=dict(default='http', choices=['http', 'https']), + httpclose=dict(default='http-keep-alive', choices=['http-keep-alive']), + backend_serverpool=dict(required=False, type='str'), + ssloffloadcert=dict(required=False, type='str'), + ssloffloadcert_type_search=dict(default='descr', type='str'), + ssloffloadacl_an=dict(required=False, type='str'), + max_connections=dict(default=100, type='int'), + addhttp_https_redirect=dict(required=False, type='bool') +) + + +class PFSenseHaproxyFrontendModule(PFSenseModuleBase): + """ module managing pfsense haproxy frontends """ + + @staticmethod + def get_argument_spec(): + """ return argument spec """ + return HAPROXY_FRONTEND_ARGUMENT_SPEC + + ############################## + # init + # + def __init__(self, module, pfsense=None): + super(PFSenseHaproxyFrontendModule, self).__init__(module, pfsense) + self.name = "pfsense_haproxy_frontend" + self.obj = dict() + + pkgs_elt = self.pfsense.get_element('installedpackages') + self.haproxy = pkgs_elt.find('haproxy') if pkgs_elt is not None else None + self.root_elt = self.haproxy.find('ha_backends') if self.haproxy is not None else None + if self.root_elt is None: + self.module.fail_json(msg='Unable to find frontends (ha_backends) XML configuration entry. Are you sure haproxy is installed ?') + + self.servers = None + + ############################## + # params processing + # + def _params_to_obj(self): + """ return a frontend dict from module params """ + params = self.params + + obj = dict() + obj['name'] = self.params['name'] + if self.params['state'] == 'present': + self._get_ansible_param(obj, 'desc') + self._get_ansible_param(obj, 'type') + self._get_ansible_param(obj, 'status') + self._get_ansible_param(obj, 'httpclose') + self._get_ansible_param(obj, 'backend_serverpool') + self._get_ansible_param(obj, 'max_connections') + + if 'ssloffloadcert' in params and params['ssloffloadcert'] is not None and params['ssloffloadcert'] != '': + search_field_type = 'type' + if 'ssloffloadcert_type_search' in params and params['ssloffloadcert_type_search'] is not None and params['ssloffloadcert_type_search'] != '': + search_field_type = params['ssloffloadcert_type_search'] + + cert_elt = self.pfsense.find_cert_elt(params['ssloffloadcert'], search_field=search_field_type) + if cert_elt is None: + self.module.fail_json(msg='%s is not a valid certificate ' % (params['ssloffloadcert'])) + obj['ssloffloadcert'] = cert_elt.find('refid').text + + self._get_ansible_param(obj, 'ssloffloadacl_an') + + # check for redirect + if ('addhttp_https_redirect' in params and + params['addhttp_https_redirect'] is not None and + params['addhttp_https_redirect'] != '' and + params['addhttp_https_redirect']): + # add redirect rules + aval = dict() + val = dict() + val['action'] = 'http-request_redirect' + val['http-request_redirectrule'] = 'scheme https' + aval['item'] = val + obj['a_actionitems'] = aval + + return obj + + def _validate_params(self): + """ do some extra checks on input parameters """ + # check name + if re.search(r'[^a-zA-Z0-9\.\-_]', self.params['name']) is not None: + self.module.fail_json(msg="The field 'name' contains invalid characters.") + + ############################## + # XML processing + # + def _create_target(self): + """ create the XML target_elt """ + server_elt = self.pfsense.new_element('item') + return server_elt + + def _find_target(self): + """ find the XML target_elt """ + for item_elt in self.root_elt: + if item_elt.tag != 'item': + continue + name_elt = item_elt.find('name') + if name_elt is not None and name_elt.text == self.obj['name']: + return item_elt + return None + + def _get_next_id(self): + """ get next free haproxy id """ + max_id = 99 + id_elts = self.haproxy.findall('.//id') + for id_elt in id_elts: + if id_elt.text is None: + continue + ha_id = int(id_elt.text) + if ha_id > max_id: + max_id = ha_id + return str(max_id + 1) + + ############################## + # run + # + def _update(self): + """ make the target pfsense reload haproxy """ + return self.pfsense.phpshell('''require_once("haproxy/haproxy.inc"); +$result = haproxy_check_and_run($savemsg, true); if ($result) unlink_if_exists($d_haproxyconfdirty_path);''') + + ############################## + # Logging + # + def _log_fields(self, before=None): + """ generate pseudo-CLI command fields parameters to create an obj """ + values = '' + if before is None: + values += self.format_cli_field(self.params, 'desc') + values += self.format_cli_field(self.params, 'type') + values += self.format_cli_field(self.params, 'httpclose') + values += self.format_cli_field(self.params, 'backend_serverpool') + values += self.format_cli_field(self.params, 'ssloffloadcert') + values += self.format_cli_field(self.params, 'ssloffloadacl_an') + values += self.format_cli_field(self.params, 'max_connections') + else: + values += self.format_updated_cli_field(self.obj, before, 'desc', add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, 'type', add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, 'httpclose', add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, 'backend_serverpool', add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, 'ssloffloadcert', add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, 'ssloffloadacl_an', add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, 'max_connections', add_comma=(values)) + return values + + def _get_obj_name(self): + """ return obj's name """ + return "'{0}'".format(self.obj['name']) diff --git a/plugins/module_utils/haproxy_frontend_server.py b/plugins/module_utils/haproxy_frontend_server.py new file mode 100644 index 0000000..8a78137 --- /dev/null +++ b/plugins/module_utils/haproxy_frontend_server.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Chris Morton, cosmo@cosmo.2y.net +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type +from ansible_collections.pfsensible.core.plugins.module_utils.module_base import PFSenseModuleBase + +HAPROXY_FRONTEND_SERVER_ARGUMENT_SPEC = dict( + state=dict(default='present', choices=['present', 'absent']), + frontend=dict(required=True, type='str'), + extaddr=dict(required=False, type='str'), + extaddr_port=dict(required=False, type='int'), + extaddr_ssl=dict(required=False, type='str'), +) + + +class PFSenseHaproxyFrontendServerModule(PFSenseModuleBase): + """ module managing pfsense haproxy frontends """ + + @staticmethod + def get_argument_spec(): + """ return argument spec """ + return HAPROXY_FRONTEND_SERVER_ARGUMENT_SPEC + + ############################## + # init + # + def __init__(self, module, pfsense=None): + super(PFSenseHaproxyFrontendServerModule, self).__init__(module, pfsense) + self.name = "pfsense_haproxy_frontend_server" + self.root_elt = None + self.obj = dict() + + pkgs_elt = self.pfsense.get_element('installedpackages') + self.haproxy = pkgs_elt.find('haproxy') if pkgs_elt is not None else None + self.frontends = self.haproxy.find('ha_backends') if self.haproxy is not None else None + if self.frontends is None: + self.module.fail_json(msg='Unable to find frontends (ha_backends) XML configuration entry. Are you sure haproxy is installed ?') + + self.frontend = None + + ############################## + # params processing + # + def _params_to_obj(self): + """ return a frontend dict from module params """ + obj = dict() + self._get_ansible_param(obj, 'extaddr') + self._get_ansible_param(obj, 'extaddr_port') + self._get_ansible_param(obj, 'extaddr_ssl') + obj['name'] = "'{0}_{1}'".format(self.params['extaddr'], self.params['extaddr_port']) + + return obj + + def _validate_params(self): + """ do some extra checks on input parameters """ + + # get the frontend + self.frontend = self._find_frontend(self.params['frontend']) + if self.frontend is None: + self.module.fail_json(msg="The frontend named '{0}' does not exist".format(self.params['frontend'])) + + # setup the a_extaddr if we don't have it + self.root_elt = self.frontend.find('a_extaddr') + if self.root_elt is None: + self.root_elt = self.pfsense.new_element('a_extaddr') + self.frontend.append(self.root_elt) + + ############################## + # XML processing + # + def _create_target(self): + """ create the XML target_elt """ + server_elt = self.pfsense.new_element('item') + return server_elt + + def _find_frontend(self, name): + """ return the target frontend_elt if found """ + for item_elt in self.frontends: + if item_elt.tag != 'item': + continue + name_elt = item_elt.find('name') + if name_elt is not None and name_elt.text == name: + return item_elt + return None + + def _find_target(self): + """ find the XML target_elt """ + for item_elt in self.root_elt: + if item_elt.tag != 'item': + continue + name_elt = item_elt.find('name') + if name_elt is not None and name_elt.text == self.obj['name']: + return item_elt + return None + + def _get_next_id(self): + """ get next free haproxy id """ + max_id = 99 + id_elts = self.haproxy.findall('.//id') + for id_elt in id_elts: + if id_elt.text is None: + continue + ha_id = int(id_elt.text) + if ha_id > max_id: + max_id = ha_id + return str(max_id + 1) + + ############################## + # run + # + def _update(self): + """ make the target pfsense reload haproxy """ + return self.pfsense.phpshell('''require_once("haproxy/haproxy.inc"); +$result = haproxy_check_and_run($savemsg, true); if ($result) unlink_if_exists($d_haproxyconfdirty_path);''') + + ############################## + # Logging + # + def _log_fields(self, before=None): + """ generate pseudo-CLI command fields parameters to create an obj """ + values = '' + if before is None: + values += self.format_cli_field(self.params, 'extaddr') + values += self.format_cli_field(self.params, 'extaddr_port') + values += self.format_cli_field(self.params, 'extaddr_ssl') + else: + values += self.format_updated_cli_field(self.obj, before, 'extaddr', add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, 'extaddr_port', add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, 'extaddr_ssl', add_comma=(values)) + return values + + def _get_obj_name(self): + """ return obj's name """ + return "'{0}_{1}'".format(self.obj['extaddr'], self.obj['extaddr_port']) diff --git a/plugins/modules/pfsense_haproxy_frontend.py b/plugins/modules/pfsense_haproxy_frontend.py new file mode 100644 index 0000000..d2645e1 --- /dev/null +++ b/plugins/modules/pfsense_haproxy_frontend.py @@ -0,0 +1,123 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Chris Morton +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = """ +--- +module: pfsense_haproxy_frontend +version_added: 0.2.0 +author: Chris Morton (@cosmosified) +short_description: Manage pfSense HAProxy frontends +description: + - Manage pfSense HAProxy frontends +notes: +options: + name: + description: The frontend name. + required: true + type: str + status: + description: Frontend status (enabled/disabled). + required: false + type: str + desc: + description: Frontend description. + required: false + type: str + type: + description: Frontend type. + required: false + type: str + choices: ['http', 'https'] + default: 'http' + httpclose: + description: HTTP close mode. + required: false + type: str + choices: ['http-keep-alive'] + default: 'http-keep-alive' + backend_serverpool: + description: Backend server pool to use. + required: false + type: str + ssloffloadcert: + description: SSL certificate for offloading. + required: false + type: str + ssloffloadcert_type_search: + description: Field type to search for SSL certificate. + required: false + type: str + default: 'descr' + ssloffloadacl_an: + description: SSL ACL alternative names. + required: false + type: str + max_connections: + description: Maximum number of connections. + required: false + type: int + default: 100 + addhttp_https_redirect: + description: Add HTTP to HTTPS redirect rule. + required: false + type: bool + state: + description: State in which to leave the frontend + choices: [ "present", "absent" ] + default: present + type: str +""" + +EXAMPLES = """ +- name: Add frontend + pfsensible.haproxy.pfsense_haproxy_frontend: + name: web-frontend + desc: "Web frontend" + status: active + type: https + backend_serverpool: web-backend + state: present + +- name: Remove frontend + pfsensible.haproxy.pfsense_haproxy_frontend: + name: web-frontend + state: absent +""" + +RETURN = """ +commands: + description: the set of commands that would be pushed to the remote device (if pfSense had a CLI) + returned: always + type: list + sample: ["create haproxy_frontend 'web-frontend', desc='Web frontend', type='https'", "delete haproxy_frontend 'web-frontend'"] +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.pfsensible.haproxy.plugins.module_utils.haproxy_frontend import ( + PFSenseHaproxyFrontendModule, + HAPROXY_FRONTEND_ARGUMENT_SPEC +) + + +def main(): + module = AnsibleModule( + argument_spec=HAPROXY_FRONTEND_ARGUMENT_SPEC, + supports_check_mode=True) + + pfmodule = PFSenseHaproxyFrontendModule(module) + pfmodule.run(module.params) + pfmodule.commit_changes() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/pfsense_haproxy_frontend_server.py b/plugins/modules/pfsense_haproxy_frontend_server.py new file mode 100644 index 0000000..6441188 --- /dev/null +++ b/plugins/modules/pfsense_haproxy_frontend_server.py @@ -0,0 +1,93 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Chris Morton +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = """ +--- +module: pfsense_haproxy_frontend_server +version_added: 0.2.0 +author: Chris Morton (@cosmosified) +short_description: Manage pfSense HAProxy frontend servers +description: + - Manage pfSense HAProxy frontend bind addresses/ports +notes: +options: + frontend: + description: The frontend name. + required: true + type: str + extaddr: + description: External address to bind to. + required: false + type: str + extaddr_port: + description: External port to bind to. + required: false + type: int + extaddr_ssl: + description: SSL configuration for external address. + required: false + type: str + state: + description: State in which to leave the frontend server + choices: [ "present", "absent" ] + default: present + type: str +""" + +EXAMPLES = """ +- name: Add frontend server binding + pfsensible.haproxy.pfsense_haproxy_frontend_server: + frontend: web-frontend + extaddr: 0.0.0.0 + extaddr_port: 443 + extaddr_ssl: "yes" + state: present + +- name: Remove frontend server binding + pfsensible.haproxy.pfsense_haproxy_frontend_server: + frontend: web-frontend + extaddr: 0.0.0.0 + extaddr_port: 443 + state: absent +""" + +RETURN = """ +commands: + description: the set of commands that would be pushed to the remote device (if pfSense had a CLI) + returned: always + type: list + sample: [ + "create haproxy_frontend_server '0.0.0.0_443' on 'web-frontend', extaddr='0.0.0.0', port=443", + "delete haproxy_frontend_server '0.0.0.0_443' on 'web-frontend'" + ] +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.pfsensible.haproxy.plugins.module_utils.haproxy_frontend_server import ( + PFSenseHaproxyFrontendServerModule, + HAPROXY_FRONTEND_SERVER_ARGUMENT_SPEC +) + + +def main(): + module = AnsibleModule( + argument_spec=HAPROXY_FRONTEND_SERVER_ARGUMENT_SPEC, + supports_check_mode=True) + + pfmodule = PFSenseHaproxyFrontendServerModule(module) + pfmodule.run(module.params) + pfmodule.commit_changes() + + +if __name__ == '__main__': + main() diff --git a/setup.cfg b/setup.cfg index 6b34680..2558b5f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,3 @@ [pycodestyle] -ignore = E402,W503,W504,E741 +ignore = E402,W503,W504,E741 max-line-length = 160 diff --git a/tests/requirements.yml b/tests/requirements.yml new file mode 100644 index 0000000..4a4b9cf --- /dev/null +++ b/tests/requirements.yml @@ -0,0 +1,3 @@ +--- +collections: + - name: community.internal_test_tools diff --git a/tests/sanity/ignore-2.16.txt b/tests/sanity/ignore-2.16.txt new file mode 100644 index 0000000..3b3cc01 --- /dev/null +++ b/tests/sanity/ignore-2.16.txt @@ -0,0 +1 @@ +.devcontainer/postCreate.sh shebang # devcontainer setup script (not a Python module)