diff --git a/.github/workflows/baremetal.yaml b/.github/workflows/baremetal.yaml new file mode 100644 index 00000000..2e4fdc8e --- /dev/null +++ b/.github/workflows/baremetal.yaml @@ -0,0 +1,111 @@ +--- +name: Baremetal integration tests + +"on": + workflow_call: + +concurrency: + group: baremetal + cancel-in-progress: false + +jobs: + server1: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Configure integration tests + run: | + envsubst < integration_config.yml.template > ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml + env: + SC_TOKEN: "${{ secrets.SC_TOKEN }}" + - name: Integration tests (server1) + run: ansible-test integration --requirements --python 3.13 --color yes + ssh_key + baremetal_locations_info + dedicated_server_reinstall_quick + dedicated_server_reinstall_1 + dedicated_server_power + + working-directory: ansible_collections/serverscom/sc_api + - name: Cleanup secrets + if: always() + run: | + dd if=/dev/zero bs=4k count=4 of=ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml + sync + sync + + server2: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Configure integration tests + run: | + envsubst < integration_config.yml.template > ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml + env: + SC_TOKEN: "${{ secrets.SC_TOKEN }}" + - name: Integration tests (server2) + run: ansible-test integration --requirements --python 3.13 --color yes + baremetal_servers_info + dedicated_server_reinstall_2 + dedicated_server_rescue + dedicated_server_reinstall_raid0 + + working-directory: ansible_collections/serverscom/sc_api + - name: Cleanup secrets + if: always() + run: | + dd if=/dev/zero bs=4k count=4 of=ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml + sync + sync + + server3: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Configure integration tests + run: | + envsubst < integration_config.yml.template > ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml + env: + SC_TOKEN: "${{ secrets.SC_TOKEN }}" + - name: Integration tests (server3) + run: ansible-test integration --requirements --python 3.13 --color yes + baremetal_os_list + dedicated_server_info + dedicated_server_reinstall_3 + dedicated_server_ipxe + dedicated_server_reinstall_noraid + + working-directory: ansible_collections/serverscom/sc_api + - name: Cleanup secrets + if: always() + run: | + dd if=/dev/zero bs=4k count=4 of=ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml + sync + sync diff --git a/.github/workflows/inventory.yaml b/.github/workflows/inventory.yaml new file mode 100644 index 00000000..eff6ebc8 --- /dev/null +++ b/.github/workflows/inventory.yaml @@ -0,0 +1,40 @@ +--- +name: Inventory integration tests + +"on": + workflow_call: + +concurrency: + group: inventory + cancel-in-progress: false + +jobs: + inventory: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Configure integration tests + run: | + envsubst < integration_config.yml.template > ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml + env: + SC_TOKEN: "${{ secrets.SC_TOKEN }}" + - name: Integration tests + run: ansible-test integration --requirements --python 3.13 --color yes + sc_inventory + + working-directory: ansible_collections/serverscom/sc_api + - name: Cleanup secrets + if: always() + run: | + dd if=/dev/zero bs=4k count=4 of=ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml + sync + sync diff --git a/.github/workflows/l2.yaml b/.github/workflows/l2.yaml new file mode 100644 index 00000000..cc763f98 --- /dev/null +++ b/.github/workflows/l2.yaml @@ -0,0 +1,42 @@ +--- +name: L2 integration tests + +"on": + workflow_call: + +concurrency: + group: l2 + cancel-in-progress: false + +jobs: + l2: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Configure integration tests + run: | + envsubst < integration_config.yml.template > ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml + env: + SC_TOKEN: "${{ secrets.SC_TOKEN }}" + - name: Integration tests + run: ansible-test integration --requirements --python 3.13 --color yes + l2_segment + l2_segment_aliases + l2_segments_info + + working-directory: ansible_collections/serverscom/sc_api + - name: Cleanup secrets + if: always() + run: | + dd if=/dev/zero bs=4k count=4 of=ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml + sync + sync diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index ae074bf8..2691f601 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -39,7 +39,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python_version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python_version }} - name: Install ansible ${{ matrix.ansible_core }} @@ -47,13 +47,13 @@ jobs: python -m pip install --upgrade pip pip install 'ansible-core~=${{ matrix.ansible_core }}.0' requests pytest - name: Sanity tests - run: ansible-test sanity --requirements --python ${{ matrix.python_version }} + run: ansible-test sanity --requirements --python ${{ matrix.python_version }} --color yes working-directory: ansible_collections/serverscom/sc_api - name: Unit tests - run: ansible-test units --requirements --python ${{ matrix.python_version }} + run: ansible-test units --requirements --python ${{ matrix.python_version }} --color yes working-directory: ansible_collections/serverscom/sc_api - name: Token-free integration tests - run: ansible-test integration --requirements --python ${{ matrix.python_version }} sc_no_token_tests + run: ansible-test integration --requirements --python ${{ matrix.python_version }} sc_no_token_tests --color yes working-directory: ansible_collections/serverscom/sc_api - name: Try-build by ansible-galaxy run: ansible-galaxy collection build @@ -73,7 +73,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install dependencies @@ -86,7 +86,7 @@ jobs: env: SC_TOKEN: "${{ secrets.SC_TOKEN }}" - name: Integration tests - run: ansible-test integration --requirements --python 3.13 + run: ansible-test integration --requirements --python 3.13 --color yes ssh_key cloud_computing_flavors_info cloud_computing_regions_info @@ -107,84 +107,22 @@ jobs: sync sync - integration_tests_bm: - runs-on: ubuntu-24.04 + baremetal: needs: - unit_and_sanity - lint - concurrency: - group: BM - cancel-in-progress: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Configure integration tests - run: | - envsubst < integration_config.yml.template > ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml - env: - SC_TOKEN: "${{ secrets.SC_TOKEN }}" - - name: Integration tests - run: ansible-test integration --requirements --python 3.13 - ssh_key - baremetal_locations_info - baremetal_servers_info - baremetal_os_list - dedicated_server_info - dedicated_server_reinstall_quick - dedicated_server_reinstall_long - dedicated_server_power - - working-directory: ansible_collections/serverscom/sc_api - - name: Cleanup secrets - if: always() - run: | - dd if=/dev/zero bs=4k count=4 of=ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml - sync - sync + uses: ./.github/workflows/baremetal.yaml + secrets: inherit - integration_tests_l2: - runs-on: ubuntu-24.04 - needs: integration_tests_bm - concurrency: - group: BM - cancel-in-progress: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Configure integration tests - run: | - envsubst < integration_config.yml.template > ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml - env: - SC_TOKEN: "${{ secrets.SC_TOKEN }}" - - name: Integration tests - run: ansible-test integration --requirements --python 3.13 - l2_segment - l2_segment_aliases - l2_segments_info + inventory: + needs: baremetal + uses: ./.github/workflows/inventory.yaml + secrets: inherit - working-directory: ansible_collections/serverscom/sc_api - - name: Cleanup secrets - if: always() - run: | - dd if=/dev/zero bs=4k count=4 of=ansible_collections/serverscom/sc_api/tests/integration/integration_config.yml - sync - sync + l2: + needs: baremetal + uses: ./.github/workflows/l2.yaml + secrets: inherit integration_tests_lb_instances: runs-on: ubuntu-24.04 @@ -198,7 +136,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install dependencies @@ -211,7 +149,7 @@ jobs: env: SC_TOKEN: "${{ secrets.SC_TOKEN }}" - name: Integration tests - run: ansible-test integration --requirements --python 3.13 + run: ansible-test integration --requirements --python 3.13 --color yes load_balancer_instance_info load_balancer_instance_l4 load_balancer_instance_l7 @@ -237,7 +175,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install dependencies @@ -250,7 +188,7 @@ jobs: env: SC_TOKEN: "${{ secrets.SC_TOKEN }}" - name: Integration tests - run: ansible-test integration --requirements --python 3.13 + run: ansible-test integration --requirements --python 3.13 --color yes rbs_flavors_info rbs_volume rbs_volume_info @@ -276,7 +214,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install dependencies @@ -289,7 +227,7 @@ jobs: env: SC_TOKEN: "${{ secrets.SC_TOKEN }}" - name: Integration tests - run: ansible-test integration --requirements --python 3.13 + run: ansible-test integration --requirements --python 3.13 --color yes sbm_flavor_models_info sbm_server_lifecycle sbm_servers_info @@ -306,9 +244,10 @@ jobs: build: runs-on: ubuntu-24.04 needs: - - integration_tests_bm + - baremetal - integration_tests_cloud - - integration_tests_l2 + - inventory + - l2 - integration_tests_lb_instances - integration_tests_rbs_volumes - integration_tests_sbm @@ -318,7 +257,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install dependencies diff --git a/.gitignore b/.gitignore index 4c7e148e..0a73f64a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ ansible_collections/serverscom/sc_api/tests/integration/inventory ansible_collections/serverscom/sc_api/*.tar.gz output* .secrets/ +.claude/ diff --git a/AGENTS.md b/AGENTS.md index 7430ded4..0777d95e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,5 +37,8 @@ To run quick tests (sanity, unit, non-secret integration tests), run `just quick There are other slow and expensive integration tests, do not run them, you don't have access to secrets. Don't try to get access to them. +Integration tests should use variables to reference existing objects (defined in @integration_config.yml.template) + Libraries: ansible-provided and requests. CI: Github Actions .github/workflows/tests.yaml + diff --git a/ansible_collections/serverscom/sc_api/README.md b/ansible_collections/serverscom/sc_api/README.md index 104d2451..b90deab3 100644 --- a/ansible_collections/serverscom/sc_api/README.md +++ b/ansible_collections/serverscom/sc_api/README.md @@ -70,6 +70,7 @@ List of modules * `dedicated_server_info` - Information about one dedicated server * `dedicated_server_reinstall` - Reinstallation of dedicated servers * `dedicated_server_power` - Power management for dedicated baremetal servers +* `dedicated_server_ipxe` - Managing the iPXE feature for dedicated baremetal servers **Cloud Computing** diff --git a/ansible_collections/serverscom/sc_api/galaxy.yml b/ansible_collections/serverscom/sc_api/galaxy.yml index 895e7c39..e2bab1c9 100644 --- a/ansible_collections/serverscom/sc_api/galaxy.yml +++ b/ansible_collections/serverscom/sc_api/galaxy.yml @@ -1,12 +1,13 @@ --- namespace: serverscom name: sc_api -version: 1.0.1 +version: 1.1.1 readme: README.md authors: - George Shuklin - Volodymyr Rudniev - Aleksandr Chudinov + - Dmitrii Rebryshkin description: Collection of modules to work with Servers.com API license: - GPL-3.0-or-later diff --git a/ansible_collections/serverscom/sc_api/long_integration.sh b/ansible_collections/serverscom/sc_api/long_integration.sh deleted file mode 100755 index 91387be2..00000000 --- a/ansible_collections/serverscom/sc_api/long_integration.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -ansible-test integration --python=3.8 sc_dedicated_server_reinstall_long diff --git a/ansible_collections/serverscom/sc_api/meta/runtime.yml b/ansible_collections/serverscom/sc_api/meta/runtime.yml index 757388ea..e5d8a425 100644 --- a/ansible_collections/serverscom/sc_api/meta/runtime.yml +++ b/ansible_collections/serverscom/sc_api/meta/runtime.yml @@ -16,7 +16,9 @@ action_groups: - cloud_computing_openstack_credentials - cloud_computing_regions_info - dedicated_server_info + - dedicated_server_ipxe - dedicated_server_power + - dedicated_server_rescue - dedicated_server_reinstall - l2_segment - l2_segment_aliases diff --git a/ansible_collections/serverscom/sc_api/plugins/inventory/__init__.py b/ansible_collections/serverscom/sc_api/plugins/inventory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ansible_collections/serverscom/sc_api/plugins/inventory/sc_inventory.py b/ansible_collections/serverscom/sc_api/plugins/inventory/sc_inventory.py new file mode 100644 index 00000000..8cbaeee0 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/plugins/inventory/sc_inventory.py @@ -0,0 +1,695 @@ +# Copyright (c) 2026 Servers.com +# 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 + +DOCUMENTATION = """ +name: sc_inventory +version_added: "1.2.0" +author: "George Shuklin (@amarao)" +short_description: Servers.com dynamic inventory +description: + - Builds Ansible inventory from the Servers.com API. + - Fetches dedicated bare-metal servers, Scalable Bare-Metal (SBM) servers, + Kubernetes bare-metal nodes, and cloud computing instances. + - Supports filtering by kind, region, name regexp, labels, status, and + arbitrary exclusion rules. + - Hosts can be assigned to a static group or dynamically grouped by + a server attribute. +options: + plugin: + description: Token that identifies the file as a config for this plugin. + required: true + choices: ['serverscom.sc_api.sc_inventory'] + token: + description: + - API bearer token for the Servers.com API. + - If unset, the SERVERSCOM_API_TOKEN or SC_TOKEN environment variable is used. + type: str + required: false + env: + - name: SERVERSCOM_API_TOKEN + - name: SC_TOKEN + endpoint: + description: + - Base URL of the Servers.com API. + - If unset, the SERVERSCOM_API_URL environment variable is used, + falling back to the public endpoint. + type: str + required: false + default: https://api.servers.com/v1 + env: + - name: SERVERSCOM_API_URL + resources: + description: + - List of resource blocks describing what to fetch and how to expose it. + - If omitted or empty, every resource kind is fetched with no filters + (via the bulk /hosts endpoint plus /cloud_computing/instances). + - When at least one block is present, every block must specify C(kind). + type: list + elements: dict + required: false + default: [] + suboptions: + kind: + description: + - Resource kind to fetch — required for every entry in C(resources). + - One of C(baremetal), C(sbm), C(k8s_nodes), C(cloud). + - To fetch every kind, omit C(resources) entirely (or set it to an + empty list) instead of relying on per-block fallback. + type: str + regions: + description: + - List of location_code (baremetal) or region_code (cloud) values. + - Empty list means all regions. + type: list + elements: str + default: [] + name_regex: + description: Regexp matched against the server title (baremetal) or name (cloud). + type: str + labels: + description: Every key=value pair must be present on the server (AND match). + type: dict + default: {} + status_filter: + description: Status whitelist; empty means all statuses. + type: list + elements: str + default: [] + exclude: + description: + - List of exclusion rules. A host is dropped if any rule matches. + - A single rule matches when all its conditions hold. + type: list + elements: dict + default: [] + suboptions: + regions: + description: Empty means any region. + type: list + elements: str + default: [] + labels: + description: Empty means no label test for this rule. + type: dict + default: {} + ansible_host: + description: Which IP field to expose as ansible_host. + type: str + choices: [public_ipv4, private_ipv4, public_ipv6, oob_ipv4, local_ipv4] + default: public_ipv4 + assign_inventory_group: + description: + - Name of a static Ansible group to add every matched host into. + - Mutually exclusive with group_by. + type: str + group_by: + description: + - Name of a top-level server attribute whose value becomes the group name. + - Mutually exclusive with assign_inventory_group. + type: str + extra_vars: + description: Constant variables set on every matched host (override raw fields). + type: dict + default: {} +""" + +EXAMPLES = """ +# 1. Everything — minimal config (no resources at all) +plugin: serverscom.sc_api.sc_inventory + +--- +# 2. All cloud instances, grouped by region +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: cloud + group_by: region_code + +--- +# 3. All baremetal servers, grouped by location +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + group_by: location_code + +--- +# 4. Baremetal in specific regions only +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: [AMS1, AMS7] + assign_inventory_group: amsterdam_servers + +--- +# 5. Cloud filtered by label, using private IP +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: cloud + labels: + environment: staging + ansible_host: private_ipv4 + status_filter: [ACTIVE] + assign_inventory_group: staging + extra_vars: + ansible_user: ubuntu + ansible_python_interpreter: /usr/bin/python3 + +--- +# 6. Exclusion — all AMS1 servers except those labeled production +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: [AMS1] + exclude: + - labels: + environment: production + assign_inventory_group: ams1_nonprod + +--- +# 7. Multi-rule exclusion — exclude AMS2 OR decommissioned +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + exclude: + - regions: [AMS2] + - labels: { decommissioned: "true" } + assign_inventory_group: active_fleet + +--- +# 8. Environment variable substitution +# Usage: DEPLOY_REGION=AMS1 SC_ENV=production ansible-inventory -i dynamic.sc_api.yml --list +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: + - ${DEPLOY_REGION} + labels: + environment: ${SC_ENV} + group_by: location_code + +--- +# 9. K8s nodes on private IPs +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: k8s_nodes + ansible_host: private_ipv4 + assign_inventory_group: kubernetes_nodes + extra_vars: + ansible_user: root + +--- +# 10. Multiple kinds with different per-kind configs +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: [AMS1, FRA1] + labels: { managed: "true" } + status_filter: [active] + ansible_host: public_ipv4 + assign_inventory_group: baremetal_fleet + extra_vars: + ansible_user: root + - kind: cloud + regions: [AMS1, FRA1] + labels: { managed: "true" } + status_filter: [ACTIVE] + ansible_host: public_ipv4 + group_by: region_code + extra_vars: + ansible_user: ubuntu + - kind: k8s_nodes + ansible_host: private_ipv4 + assign_inventory_group: k8s_nodes + extra_vars: + ansible_user: root + +--- +# 11. Name regexp — only hosts matching a pattern, across two kinds +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: cloud + name_regex: "^web-" + status_filter: [ACTIVE] + assign_inventory_group: web_tier + - kind: baremetal + name_regex: "^web-" + status_filter: [active] + assign_inventory_group: web_tier +""" + +import os +import re + +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.plugins.inventory import BaseInventoryPlugin +from ansible.utils.display import Display + +from ansible_collections.serverscom.sc_api.plugins.module_utils.api import ( + DEFAULT_API_ENDPOINT, + SCBaseError, + ScApi, +) + +display = Display() + +_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") +_GROUP_NAME_SANITIZE_RE = re.compile(r"[^A-Za-z0-9_]") + +_HOST_TYPE_TO_KIND = { + "dedicated_server": "baremetal", + "sbm_server": "sbm", + "kubernetes_baremetal_node": "k8s_nodes", +} + +_KIND_TO_HOST_TYPE = { + "baremetal": "dedicated_server", + "sbm": "sbm_server", + "k8s_nodes": "kubernetes_baremetal_node", +} + +_VALID_KINDS = {"baremetal", "sbm", "k8s_nodes", "cloud"} + +_VALID_ANSIBLE_HOST = { + "public_ipv4", + "private_ipv4", + "public_ipv6", + "oob_ipv4", + "local_ipv4", +} + +# Strict key validation: any key not listed below is rejected at parse time. +_KNOWN_TOP_KEYS = { + "plugin", + "token", + "endpoint", + "resources", +} + +_KNOWN_BLOCK_KEYS = { + "kind", + "regions", + "name_regex", + "labels", + "status_filter", + "exclude", + "ansible_host", + "assign_inventory_group", + "group_by", + "extra_vars", +} + +_KNOWN_EXCLUDE_RULE_KEYS = {"regions", "labels"} + + +class InventoryModule(BaseInventoryPlugin): + + NAME = "serverscom.sc_api.sc_inventory" + + # ------------------------------------------------------------------ # + # Entry points + # ------------------------------------------------------------------ # + + def verify_file(self, path): + if not super(InventoryModule, self).verify_file(path): + return False + if not path.endswith( + ( + ".sc_api.yml", + ".sc_api.yaml", + ".sc_inventory.yml", + ".sc_inventory.yaml", + ) + ): + return False + # Must also contain `plugin: serverscom.sc_api.sc_inventory` so we + # don't claim foreign files that just happen to share the suffix. + try: + with open(path, "r") as f: + content = f.read() + except (OSError, IOError): + return False + return "plugin: %s" % self.NAME in content + + def parse(self, inventory, loader, path, cache=True): + super(InventoryModule, self).parse(inventory, loader, path, cache) + self._reject_unknown_keys(loader, path) + self._read_config_data(path) + + token, endpoint = self._resolve_token_endpoint() + api = self._build_api(token, endpoint) + + resources = self.get_option("resources") + + try: + if not resources: + # No `resources:` (or empty list) → fetch every kind, no filters. + self._apply_resource(api, {}) + return + + for raw_block in resources: + block = self._substitute_env_vars(raw_block or {}) + if not block.get("kind"): + raise AnsibleParserError( + "Every entry in `resources:` must specify `kind` " + "(one of baremetal/sbm/k8s_nodes/cloud). " + "To fetch all kinds, omit `resources:` entirely " + "or set it to an empty list." + ) + self._apply_resource(api, block) + except SCBaseError as e: + raise AnsibleError(e.msg) + + # ------------------------------------------------------------------ # + # Strict key validation + # ------------------------------------------------------------------ # + + def _reject_unknown_keys(self, loader, path): + """Raise AnsibleParserError if the config file contains any key + not listed in the plugin's schema. Ansible's own validator is + lenient and silently drops unknowns, which lets typos slip past; + this restores strict behavior.""" + raw = loader.load_from_file(path, cache="none", unsafe=True) + if not isinstance(raw, dict): + raise AnsibleParserError( + "Inventory config %r must be a YAML mapping." % path + ) + + extras = set(raw.keys()) - _KNOWN_TOP_KEYS + if extras: + raise AnsibleParserError( + "Unknown top-level keys in %s: %s. Known keys: %s" + % (path, sorted(extras), sorted(_KNOWN_TOP_KEYS)) + ) + + for i, block in enumerate(raw.get("resources") or []): + if not isinstance(block, dict): + continue + block_extras = set(block.keys()) - _KNOWN_BLOCK_KEYS + if block_extras: + raise AnsibleParserError( + "Unknown keys in resources[%d] of %s: %s. Known keys: %s" + % (i, path, sorted(block_extras), sorted(_KNOWN_BLOCK_KEYS)) + ) + for j, rule in enumerate(block.get("exclude") or []): + if not isinstance(rule, dict): + continue + rule_extras = set(rule.keys()) - _KNOWN_EXCLUDE_RULE_KEYS + if rule_extras: + raise AnsibleParserError( + "Unknown keys in resources[%d].exclude[%d] of %s: %s" + % (i, j, path, sorted(rule_extras)) + ) + + # ------------------------------------------------------------------ # + # Token / endpoint / api + # ------------------------------------------------------------------ # + + def _resolve_token_endpoint(self): + token = self.get_option("token") + if not token: + token = os.environ.get("SERVERSCOM_API_TOKEN") or os.environ.get( + "SC_TOKEN" + ) + if token: + token = self._substitute_env_vars(token) + if not token: + raise AnsibleError( + "No API token found. Set SERVERSCOM_API_TOKEN or SC_TOKEN " + "environment variable, or pass 'token:' in the inventory config." + ) + + endpoint = self.get_option("endpoint") + if not endpoint: + endpoint = os.environ.get("SERVERSCOM_API_URL", DEFAULT_API_ENDPOINT) + endpoint = self._substitute_env_vars(endpoint) + return token, endpoint + + def _build_api(self, token, endpoint): + return ScApi(token, endpoint) + + # ------------------------------------------------------------------ # + # ${VAR} substitution + # ------------------------------------------------------------------ # + + def _substitute_env_vars(self, value): + if isinstance(value, str): + def replace(match): + name = match.group(1) + resolved = os.environ.get(name) + if resolved is None: + raise AnsibleParserError( + "Environment variable %r referenced in inventory " + "config is not set." % name + ) + return resolved + + return _ENV_VAR_RE.sub(replace, value) + if isinstance(value, list): + return [self._substitute_env_vars(item) for item in value] + if isinstance(value, dict): + return {k: self._substitute_env_vars(v) for k, v in value.items()} + return value + + # ------------------------------------------------------------------ # + # Listing / kind dispatch + # ------------------------------------------------------------------ # + + def _list_for_kind(self, api, kind): + if kind is None: + for host in api.list_hosts(): + mapped = _HOST_TYPE_TO_KIND.get(host.get("type")) + if mapped is None: + display.warning( + "Skipping host id=%r with unknown type=%r" + % (host.get("id"), host.get("type")) + ) + continue + yield host, mapped + for instance in api.list_instances(): + yield instance, "cloud" + return + + if kind not in _VALID_KINDS: + raise AnsibleParserError( + "Unknown resource kind %r; valid: %s" + % (kind, sorted(_VALID_KINDS)) + ) + + if kind == "cloud": + for instance in api.list_instances(): + yield instance, "cloud" + return + + host_type = _KIND_TO_HOST_TYPE[kind] + for host in api.list_hosts(type=host_type): + yield host, kind + + # ------------------------------------------------------------------ # + # Per-host accessors + # ------------------------------------------------------------------ # + + def _hostname(self, server, kind): + field = "name" if kind == "cloud" else "title" + name = server.get(field) + if not name: + server_id = server.get("id") + display.warning( + "Server id=%r has empty %s; falling back to id as hostname." + % (server_id, field) + ) + return str(server_id) if server_id is not None else None + return name + + def _region(self, server, kind): + if kind == "cloud": + return server.get("region_code") + return server.get("location_code") + + def _ip(self, server, kind, ip_type): + if ip_type == "public_ipv4": + return server.get("public_ipv4_address") + if ip_type == "private_ipv4": + return server.get("private_ipv4_address") + if ip_type == "public_ipv6": + if kind == "cloud": + return server.get("public_ipv6_address") + return None + if ip_type == "oob_ipv4": + if kind == "baremetal": + return server.get("oob_ipv4_address") + return None + if ip_type == "local_ipv4": + if kind == "cloud": + return server.get("local_ipv4_address") + return None + return None + + # ------------------------------------------------------------------ # + # Filtering + # ------------------------------------------------------------------ # + + def _matches_labels(self, host_labels, required): + if not required: + return True + if not isinstance(host_labels, dict): + return False + for k, v in required.items(): + if host_labels.get(k) != v: + return False + return True + + def _is_excluded(self, server, kind, exclude_rules): + if not exclude_rules: + return False + region = self._region(server, kind) + host_labels = server.get("labels") or {} + for rule in exclude_rules: + rule = rule or {} + rule_regions = rule.get("regions") or [] + rule_labels = rule.get("labels") or {} + region_ok = (not rule_regions) or (region in rule_regions) + labels_ok = self._matches_labels(host_labels, rule_labels) + if region_ok and labels_ok: + return True + return False + + # ------------------------------------------------------------------ # + # Grouping + # ------------------------------------------------------------------ # + + def _sanitize_group(self, name): + return _GROUP_NAME_SANITIZE_RE.sub("_", str(name)) + + def _add_to_groups(self, hostname, server, assign_inventory_group, group_by): + if assign_inventory_group and group_by: + raise AnsibleParserError( + "assign_inventory_group and group_by are mutually exclusive " + "within a single resource block." + ) + if assign_inventory_group: + group = self._sanitize_group(assign_inventory_group) + self.inventory.add_group(group) + self.inventory.add_child(group, hostname) + elif group_by: + value = server.get(group_by) + if value in (None, ""): + display.warning( + "Host %r: group_by attribute %r is missing/empty; " + "not added to any extra group." + % (hostname, group_by) + ) + return + group = self._sanitize_group(value) + self.inventory.add_group(group) + self.inventory.add_child(group, hostname) + + # ------------------------------------------------------------------ # + # Host variables + # ------------------------------------------------------------------ # + + def _set_host_vars( + self, hostname, server, kind, ansible_host_type, extra_vars + ): + ip = self._ip(server, kind, ansible_host_type) + if ip: + self.inventory.set_variable(hostname, "ansible_host", ip) + else: + display.warning( + "Host %r: configured ansible_host=%s is not available; " + "host added without ansible_host." + % (hostname, ansible_host_type) + ) + + self.inventory.set_variable( + hostname, "public_ip", server.get("public_ipv4_address") + ) + self.inventory.set_variable( + hostname, "private_ip", server.get("private_ipv4_address") + ) + self.inventory.set_variable( + hostname, + "public_ipv6", + server.get("public_ipv6_address") if kind == "cloud" else None, + ) + self.inventory.set_variable( + hostname, + "oob_ip", + server.get("oob_ipv4_address") if kind == "baremetal" else None, + ) + self.inventory.set_variable( + hostname, + "local_ip", + server.get("local_ipv4_address") if kind == "cloud" else None, + ) + # v1: placeholder; the /hosts list endpoint returns only + # `additional_ip_addresses_count`, not the actual addresses. + self.inventory.set_variable(hostname, "additional_ip_addresses", []) + self.inventory.set_variable(hostname, "sc_kind", kind) + + for key, value in server.items(): + self.inventory.set_variable(hostname, key, value) + + for key, value in (extra_vars or {}).items(): + self.inventory.set_variable(hostname, key, value) + + # ------------------------------------------------------------------ # + # Main per-resource flow + # ------------------------------------------------------------------ # + + def _apply_resource(self, api, config): + kind = config.get("kind") + regions = config.get("regions") or [] + name_regex = config.get("name_regex") + labels = config.get("labels") or {} + status_filter = config.get("status_filter") or [] + exclude_rules = config.get("exclude") or [] + ansible_host_type = config.get("ansible_host") or "public_ipv4" + if ansible_host_type not in _VALID_ANSIBLE_HOST: + raise AnsibleParserError( + "Invalid ansible_host=%r; valid: %s" + % (ansible_host_type, sorted(_VALID_ANSIBLE_HOST)) + ) + assign_inventory_group = config.get("assign_inventory_group") + group_by = config.get("group_by") + if assign_inventory_group and group_by: + raise AnsibleParserError( + "assign_inventory_group and group_by are mutually exclusive " + "within a single resource block." + ) + extra_vars = config.get("extra_vars") or {} + + compiled_regex = re.compile(name_regex) if name_regex else None + + for server, server_kind in self._list_for_kind(api, kind): + region = self._region(server, server_kind) + if regions and region not in regions: + continue + + hostname = self._hostname(server, server_kind) + if not hostname: + continue + if compiled_regex and not compiled_regex.search(hostname): + continue + + if labels and not self._matches_labels( + server.get("labels") or {}, labels + ): + continue + + if status_filter and server.get("status") not in status_filter: + continue + + if self._is_excluded(server, server_kind, exclude_rules): + continue + + self.inventory.add_host(hostname) + self._set_host_vars( + hostname, server, server_kind, ansible_host_type, extra_vars + ) + self._add_to_groups( + hostname, server, assign_inventory_group, group_by + ) diff --git a/ansible_collections/serverscom/sc_api/plugins/module_utils/api.py b/ansible_collections/serverscom/sc_api/plugins/module_utils/api.py index 5e51ef2d..37919322 100644 --- a/ansible_collections/serverscom/sc_api/plugins/module_utils/api.py +++ b/ansible_collections/serverscom/sc_api/plugins/module_utils/api.py @@ -78,6 +78,10 @@ class APIError409(APIError): pass +class APIError412(APIError): + pass + + class SCConnectionError(SCBaseError): def __init__(self, msg, api_url): self.msg = msg @@ -162,6 +166,13 @@ def send_request(self, good_codes): msg=f"409 Conflict. {response.content}", correlation_id=correlation_id, ) + if response.status_code == 412: + raise APIError412( + status_code=response.status_code, + api_url=prep_request.url, + msg=f"412 Precondition Failed. {response.content}", + correlation_id=correlation_id, + ) if response.status_code not in good_codes: raise APIError( status_code=response.status_code, @@ -1126,6 +1137,59 @@ def post_dedicated_server_power_off(self, server_id): good_codes=[202], ) + def get_dedicated_server_features(self, server_id, retry_rules=None): + return self.api_helper.make_get_request( + path=f"/hosts/dedicated_servers/{server_id}/features", + retry_rules=retry_rules, + ) + + def post_dedicated_server_feature_activate( + self, server_id, feature_name, body=None + ): + return self.api_helper.make_post_request( + path=f"/hosts/dedicated_servers/{server_id}/features/{feature_name}/activate", + body=body, + query_parameters=None, + good_codes=[202], + ) + + def post_dedicated_server_feature_deactivate(self, server_id, feature_name): + return self.api_helper.make_post_request( + path=f"/hosts/dedicated_servers/{server_id}/features/{feature_name}/deactivate", + body=None, + query_parameters=None, + good_codes=[202], + ) + + def put_dedicated_server(self, server_id, body): + return self.api_helper.make_put_request( + path=f"/hosts/dedicated_servers/{server_id}", + body=body, + query_parameters=None, + good_codes=[200], + ) + + def post_dedicated_server_rescue_activate( + self, server_id, auth_methods, ssh_key_fingerprints=None + ): + body = {"auth_methods": auth_methods} + if ssh_key_fingerprints: + body["ssh_key_fingerprints"] = ssh_key_fingerprints + return self.api_helper.make_post_request( + path=f"/hosts/dedicated_servers/{server_id}/features/host_rescue_mode/activate", + body=body, + query_parameters=None, + good_codes=[202], + ) + + def post_dedicated_server_rescue_deactivate(self, server_id): + return self.api_helper.make_post_request( + path=f"/hosts/dedicated_servers/{server_id}/features/host_rescue_mode/deactivate", + body=None, + query_parameters=None, + good_codes=[202], + ) + def list_server_models(self, location_id, search_pattern=None): if search_pattern: query = {"search_pattern": search_pattern} diff --git a/ansible_collections/serverscom/sc_api/plugins/module_utils/dedicated_server.py b/ansible_collections/serverscom/sc_api/plugins/module_utils/dedicated_server.py index 12deb041..cacfe320 100644 --- a/ansible_collections/serverscom/sc_api/plugins/module_utils/dedicated_server.py +++ b/ansible_collections/serverscom/sc_api/plugins/module_utils/dedicated_server.py @@ -4,6 +4,8 @@ from ansible_collections.serverscom.sc_api.plugins.module_utils.api import ( APIError404, + APIError409, + APIError412, ScApi, ) from ansible_collections.serverscom.sc_api.plugins.module_utils.modules import ( @@ -385,6 +387,215 @@ def run(self): raise ModuleError(f"Unknown state: {self.state}") +class ScDedicatedServerIpxe: + TRANSITIONAL_STATUSES = ("activation", "deactivation") + + def __init__( + self, + endpoint, + token, + server_id, + state, + ipxe_config, + wait, + update_interval, + checkmode, + ): + if wait and int(wait) < int(update_interval): + raise ModuleError( + f"Update interval ({update_interval}) is longer " + f"than wait time ({wait})" + ) + self.api = ScApi(token, endpoint) + self.server_id = server_id + self.state = state + if state in ("public", "private"): + self.feature_name = f"{state}_ipxe_boot" + opposite = "private" if state == "public" else "public" + self.opposite_feature_name = f"{opposite}_ipxe_boot" + self.ipxe_config = ipxe_config + self.wait = wait + self.update_interval = update_interval + self.checkmode = checkmode + + def _get_feature_by_name(self, feature_name, retry_rules=None): + features = self.api.get_dedicated_server_features( + self.server_id, retry_rules=retry_rules + ) + for feature in features: + if feature.get("name") == feature_name: + return feature + return None + + def _get_feature_status(self, retry_rules=None): + feature = self._get_feature_by_name(self.feature_name, retry_rules=retry_rules) + if feature is None: + raise ModuleError( + f"Unexpected error, unable to find feature '{self.feature_name}' not found " + f"for server {self.server_id}, please contact support" + ) + return feature + + def _get_opposite_feature_status(self): + return self._get_feature_by_name(self.opposite_feature_name) + + def wait_for_status(self, target_status, feature_name=None): + if feature_name is None: + feature_name = self.feature_name + start = time.time() + while True: + elapsed = time.time() - start + if elapsed > self.wait: + raise WaitError( + msg=f"Timeout waiting for {feature_name} " + f"to reach '{target_status}'", + timeout=elapsed, + ) + time.sleep(self.update_interval) + elapsed = time.time() - start + feature = self._get_feature_by_name( + feature_name, + retry_rules=_retry_rules_for_wait( + max_wait=max(0, self.wait - elapsed), + delay=self.update_interval, + ), + ) + if feature is None: + raise ModuleError( + f"Feature '{feature_name}' not found " + f"for server {self.server_id}" + ) + status = feature.get("status") + if status == target_status: + return feature + if status not in self.TRANSITIONAL_STATUSES: + raise ModuleError( + f"Unexpected status '{status}' for {feature_name}, " + f"expected '{target_status}'" + ) + + def _deactivate_feature(self, feature_name, status): + """Deactivate a feature handling all possible statuses. + + Returns (changed, feature) where changed indicates whether + a deactivation was initiated by us. + """ + if status in ("deactivated", "incompatible", "unavailable"): + return False, None + if status == "activated": + self.api.post_dedicated_server_feature_deactivate( + self.server_id, feature_name + ) + feature = None + if self.wait: + feature = self.wait_for_status("deactivated", feature_name=feature_name) + return True, feature + if status == "activation": + if self.wait: + self.wait_for_status("activated", feature_name=feature_name) + self.api.post_dedicated_server_feature_deactivate( + self.server_id, feature_name + ) + feature = None + if self.wait: + feature = self.wait_for_status("deactivated", feature_name=feature_name) + return True, feature + if status == "deactivation": + feature = None + if self.wait: + feature = self.wait_for_status("deactivated", feature_name=feature_name) + return False, feature + raise ModuleError(f"Unexpected status '{status}' for {feature_name}") + + def _deactivate_opposite(self, opposite): + self._deactivate_feature(self.opposite_feature_name, opposite.get("status")) + + def _opposite_needs_deactivation(self, opposite): + if opposite is None: + return False + return opposite.get("status") in ("activated", "activation", "deactivation") + + def _ensure_present(self): + feature = self._get_feature_status() + status = feature.get("status") + + if status == "activated": + if self.ipxe_config is not None: + server = self.api.get_dedicated_servers(self.server_id) + current_config = server.get("ipxe_config") or "" + if current_config == self.ipxe_config: + return {"changed": False, "feature": feature} + if self.checkmode: + return {"changed": True, "feature": feature} + self.api.put_dedicated_server( + self.server_id, {"ipxe_config": self.ipxe_config} + ) + return {"changed": True, "feature": feature} + return {"changed": False, "feature": feature} + + if status in ("deactivated", "incompatible", "unavailable"): + opposite = self._get_opposite_feature_status() + if self._opposite_needs_deactivation(opposite): + if self.checkmode: + return {"changed": True, "feature": feature} + self._deactivate_opposite(opposite) + if self.checkmode: + return {"changed": True, "feature": feature} + body = {} + if self.ipxe_config is not None: + body["ipxe_config"] = self.ipxe_config + self.api.post_dedicated_server_feature_activate( + self.server_id, self.feature_name, body=body or None + ) + if self.wait: + feature = self.wait_for_status("activated") + return {"changed": True, "feature": feature} + + if status == "activation": + if self.wait: + feature = self.wait_for_status("activated") + return {"changed": False, "feature": feature} + + raise ModuleError(f"Unexpected status '{status}' for {self.feature_name}") + + def _ensure_absent(self): + features = self.api.get_dedicated_server_features(self.server_id) + for feature_name in ("public_ipxe_boot", "private_ipxe_boot"): + feature = None + for f in features: + if f.get("name") == feature_name: + feature = f + break + if feature is None: + continue + + status = feature.get("status") + + if status in ("deactivated", "incompatible", "unavailable"): + continue + + if self.checkmode: + return {"changed": True, "feature": feature} + + changed, result_feature = self._deactivate_feature(feature_name, status) + return { + "changed": changed, + "feature": result_feature or feature, + } + + # Both features are inactive or absent + for f in features: + if f.get("name") in ("public_ipxe_boot", "private_ipxe_boot"): + return {"changed": False, "feature": f} + return {"changed": False, "feature": {}} + + def run(self): + if self.state in ("public", "private"): + return self._ensure_present() + else: + return self._ensure_absent() + + class ScDedicatedOSList: def __init__( self, @@ -468,3 +679,194 @@ def run(self): raise ModuleError("No operating systems found matching the criteria") return {"changed": False, "os_list": os_list} + + +class ScDedicatedServerRescue: + def __init__( + self, + endpoint, + token, + server_id, + state, + auth_methods, + ssh_key_fingerprints, + ssh_key_name, + wait, + update_interval, + checkmode, + ): + self.api = ScApi(token, endpoint) + self.server_id = server_id + self.state = state + self.auth_methods = auth_methods + self._validate_auth_methods(auth_methods, ssh_key_fingerprints, ssh_key_name) + self.ssh_key_fingerprints = self._resolve_ssh_keys( + ssh_key_fingerprints, ssh_key_name + ) + self.wait = wait + self.update_interval = update_interval + self.checkmode = checkmode + + @staticmethod + def _validate_auth_methods(auth_methods, ssh_key_fingerprints, ssh_key_name): + if not auth_methods: + return + valid_methods = {"password", "ssh_key"} + invalid = set(auth_methods) - valid_methods + if invalid: + raise ModuleError( + f"Invalid auth_methods: {', '.join(sorted(invalid))}. " + f"Valid values are: {', '.join(sorted(valid_methods))}" + ) + if "ssh_key" in auth_methods and not ssh_key_fingerprints and not ssh_key_name: + raise ModuleError( + "ssh_key_fingerprints or ssh_key_name is required " + "when 'ssh_key' is in auth_methods." + ) + + def _resolve_ssh_keys(self, ssh_key_fingerprints, ssh_key_name): + if ssh_key_fingerprints: + return ssh_key_fingerprints + if not ssh_key_name: + return None + return [ + self.api.toolbox.get_ssh_fingerprints_by_key_name( + ssh_key_name, must=True + ) + ] + + def _get_rescue_feature_status(self, retry_rules=None): + features = self.api.get_dedicated_server_features( + self.server_id, retry_rules=retry_rules + ) + for feature in features: + if feature["name"] == "host_rescue_mode": + return feature["status"] + raise ModuleError( + f"host_rescue_mode feature not found for server {self.server_id}. " + "The server may not support rescue mode." + ) + + def _retry_on_api_error(self, action): + start = time.time() + while True: + try: + return action() + except APIError409 as e: + if '"INCOMPATIBLE_FEATURE_STATE"' not in e.msg: + raise + if time.time() - start > self.wait: + raise + time.sleep(self.update_interval) + except APIError412: + if time.time() - start > self.wait: + raise + time.sleep(self.update_interval) + + def _wait_for_feature_status(self, target_status): + start = time.time() + while True: + elapsed = time.time() - start + status = self._get_rescue_feature_status( + retry_rules=_retry_rules_for_wait( + max_wait=max(0, self.wait - elapsed), + delay=self.update_interval, + ), + ) + if status == target_status: + return + if elapsed > self.wait: + raise WaitError( + msg=f"Timeout waiting for rescue feature " + f"status={target_status}, last={status}", + timeout=elapsed, + ) + time.sleep(self.update_interval) + + def activate_rescue(self): + feature_status = self._get_rescue_feature_status() + + if feature_status == "unavailable": + raise ModuleError( + f"Rescue mode is unavailable for server {self.server_id}." + ) + if feature_status == "incompatible": + raise ModuleError( + f"Rescue mode is incompatible for server {self.server_id}." + ) + + if feature_status == "activated": + server = self.api.get_dedicated_servers(self.server_id) + server["changed"] = False + return server + + if feature_status == "activation": + if self.wait: + self._wait_for_feature_status("activated") + server = self.api.get_dedicated_servers(self.server_id) + server["changed"] = False + return server + + if self.checkmode: + server = self.api.get_dedicated_servers(self.server_id) + server["changed"] = True + return server + + self._retry_on_api_error( + lambda: self.api.post_dedicated_server_rescue_activate( + self.server_id, + auth_methods=self.auth_methods, + ssh_key_fingerprints=self.ssh_key_fingerprints, + ) + ) + + if self.wait: + self._wait_for_feature_status("activated") + server = self.api.get_dedicated_servers(self.server_id) + server["changed"] = True + return server + + def deactivate_rescue(self): + feature_status = self._get_rescue_feature_status() + + if feature_status == "unavailable": + raise ModuleError( + f"Rescue mode is unavailable for server {self.server_id}." + ) + + if feature_status == "deactivated": + server = self.api.get_dedicated_servers(self.server_id) + server["changed"] = False + return server + + if feature_status == "deactivation": + if self.wait: + self._wait_for_feature_status("deactivated") + server = self.api.get_dedicated_servers(self.server_id) + server["changed"] = False + return server + + if self.checkmode: + server = self.api.get_dedicated_servers(self.server_id) + server["changed"] = True + return server + + self._retry_on_api_error( + lambda: self.api.post_dedicated_server_rescue_deactivate( + self.server_id + ) + ) + + if self.wait: + self._wait_for_feature_status("deactivated") + server = self.api.get_dedicated_servers(self.server_id) + server["changed"] = True + return server + + def run(self): + if self.state == "rescue": + return self.activate_rescue() + elif self.state == "normal": + return self.deactivate_rescue() + else: + raise ModuleError(f"Unknown state: {self.state}") diff --git a/ansible_collections/serverscom/sc_api/plugins/modules/dedicated_server_ipxe.py b/ansible_collections/serverscom/sc_api/plugins/modules/dedicated_server_ipxe.py new file mode 100644 index 00000000..6d94f7c5 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/plugins/modules/dedicated_server_ipxe.py @@ -0,0 +1,168 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# (c) 2026, Servers.com +# 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: dedicated_server_ipxe +version_added: "1.0.0" +author: "Servers.com Team (@serverscom)" +short_description: Manage iPXE boot feature on dedicated servers +description: > + Activate or deactivate the public or private iPXE boot feature on a + dedicated server. Optionally update the iPXE configuration script on an + already activated feature. + +extends_documentation_fragment: serverscom.sc_api.api_auth + +options: + server_id: + type: str + required: true + description: + - ID of the dedicated server. + - Use M(serverscom.sc_api.dedicated_server_info) to retrieve servers. + + state: + type: str + required: true + choices: ['absent', 'public', 'private'] + description: + - Desired state of the iPXE boot feature. + - C(public) activates the public_ipxe_boot feature (or updates iPXE + config if already active). Automatically deactivates private_ipxe_boot + if it is active. + - C(private) activates the private_ipxe_boot feature (or updates iPXE + config if already active). Automatically deactivates public_ipxe_boot + if it is active. + - C(absent) deactivates whichever iPXE boot feature is currently active. + + ipxe_config: + type: str + required: false + description: + - iPXE script content (max 64 KB). + - Required when I(state) is C(public) or C(private). + - When the feature is already activated the configuration is updated via + the server update endpoint. + + wait: + type: int + required: false + default: 600 + description: + - Maximum time in seconds to wait for the feature to reach the desired + status after activation or deactivation. + - Set to C(0) to return immediately without waiting. + + update_interval: + type: int + required: false + default: 10 + description: + - Polling interval in seconds when waiting for the feature status change. +""" + +RETURN = """ +feature: + description: Feature object returned by the API. + type: dict + returned: on success + contains: + name: + description: Feature name (e.g. public_ipxe_boot, private_ipxe_boot). + type: str + status: + description: > + Current feature status (activation, activated, deactivation, + deactivated, incompatible, unavailable). + type: str +""" + +EXAMPLES = """ +- name: Activate public iPXE boot + serverscom.sc_api.dedicated_server_ipxe: + token: "{{ api_token }}" + server_id: "0m592Zmn" + state: public + ipxe_config: | + #!ipxe + chain http://boot.example.com/menu.ipxe + +- name: Activate private iPXE boot + serverscom.sc_api.dedicated_server_ipxe: + token: "{{ api_token }}" + server_id: "0m592Zmn" + state: private + ipxe_config: | + #!ipxe + chain http://boot.example.com/menu.ipxe + +- name: Deactivate whichever iPXE boot is active + serverscom.sc_api.dedicated_server_ipxe: + token: "{{ api_token }}" + server_id: "0m592Zmn" + state: absent +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.serverscom.sc_api.plugins.module_utils.modules import ( + AUTH_ARGS, + SCBaseError, +) +from ansible_collections.serverscom.sc_api.plugins.module_utils.dedicated_server import ( + ScDedicatedServerIpxe, +) + + +def main(): + module = AnsibleModule( + argument_spec={ + **AUTH_ARGS, + "server_id": {"type": "str", "required": True}, + "state": { + "type": "str", + "choices": ["absent", "public", "private"], + "required": True, + }, + "ipxe_config": {"type": "str", "no_log": False}, + "wait": {"type": "int", "default": 600}, + "update_interval": {"type": "int", "default": 10}, + }, + required_if=[ + ["state", "public", ["ipxe_config"]], + ["state", "private", ["ipxe_config"]], + ], + supports_check_mode=True, + ) + + try: + ipxe = ScDedicatedServerIpxe( + endpoint=module.params["endpoint"], + token=module.params["token"], + server_id=module.params["server_id"], + state=module.params["state"], + ipxe_config=module.params["ipxe_config"], + wait=module.params["wait"], + update_interval=module.params["update_interval"], + checkmode=module.check_mode, + ) + module.exit_json(**ipxe.run()) + except SCBaseError as e: + module.exit_json(**e.fail()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/serverscom/sc_api/plugins/modules/dedicated_server_rescue.py b/ansible_collections/serverscom/sc_api/plugins/modules/dedicated_server_rescue.py new file mode 100644 index 00000000..5f887d13 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/plugins/modules/dedicated_server_rescue.py @@ -0,0 +1,294 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# (c) 2026, Servers.com +# 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: dedicated_server_rescue +version_added: "1.1.0" +author: "Aleksandr Chudinov (@chal)" +short_description: Manage rescue mode on a Bare Metal server +description: > + Activate or deactivate rescue mode on a Bare Metal (dedicated) server. + Rescue mode boots the server into a temporary environment for + troubleshooting and recovery. The server's disks are not mounted + automatically. + +extends_documentation_fragment: serverscom.sc_api.api_auth + +options: + server_id: + type: str + required: true + description: + - ID of the Bare Metal server. + - Use I(serverscom.sc_api.baremetal_servers_info) to retrieve servers. + + state: + type: str + required: true + choices: ['rescue', 'normal'] + description: + - Desired rescue mode state. + - "I(rescue): activate rescue mode." + - "I(normal): deactivate rescue mode and boot normally." + + auth_methods: + type: list + elements: str + description: + - Authentication methods for rescue mode login. + - Required when I(state)=C(rescue). + - "C(password): enable password authentication." + - "C(ssh_key): enable SSH key authentication + (requires I(ssh_key_fingerprints) or I(ssh_key_name))." + + ssh_key_fingerprints: + type: list + elements: str + description: + - Fingerprints of SSH keys for rescue mode access. + - Required when C(ssh_key) is in I(auth_methods). + - Keys must be registered via M(serverscom.sc_api.ssh_key). + - Mutually exclusive with I(ssh_key_name). + + ssh_key_name: + type: str + description: + - Name of a single SSH key to use for rescue mode access. + - The module resolves the name to a fingerprint via the SSH keys API. + - Mutually exclusive with I(ssh_key_fingerprints). + + wait: + type: int + default: 600 + description: + - Maximum time in seconds to wait for the server to reach the + desired operational status. + - Set to C(0) to disable waiting. + + update_interval: + type: int + default: 10 + description: + - Polling interval in seconds when waiting. +""" + +RETURN = """ +id: + description: Unique identifier of a server. + type: str + returned: on success + +title: + description: Displayed name of the server (defaults to hostname). + type: str + returned: on success + +type: + description: "Resource type (always 'dedicated_server')." + type: str + returned: on success + +rack_id: + description: Unique identifier of the rack, or null if provisioning. + type: str + returned: on success + +status: + description: Provisioning state of the server (init, pending, active). + type: str + returned: on success + +operational_status: + description: Detailed operational state (normal, provisioning, installation, entering_rescue_mode, rescue_mode, exiting_rescue_mode). + type: str + returned: on success + +power_status: + description: Power state indicator (unknown, powering_on, powered_on, powering_off, powered_off, power_cycling). + type: str + returned: on success + +configuration: + description: Chassis model, RAM, and disk details. + type: str + returned: on success + +location_id: + description: Numeric identifier of the server's location. + type: int + returned: on success + +location_code: + description: Technical code of the server's location. + type: str + returned: on success + +private_ipv4_address: + description: Private IPv4 address, or null if unassigned. + type: str + returned: on success + +public_ipv4_address: + description: Public IPv4 address, or null if unassigned. + type: str + returned: on success + +lease_start_at: + description: Date when leasing began, or null. + type: str + returned: on success + +scheduled_release_at: + description: Scheduled release date-time, or null. + type: str + returned: on success + +configuration_details: + description: Detailed configuration object. + type: dict + returned: on success + +labels: + description: Labels associated with the server resource. + type: dict + returned: on success + +created_at: + description: Timestamp when the server was created. + type: str + returned: on success + +updated_at: + description: Timestamp of the last update. + type: str + returned: on success + +oob_ipv4_address: + description: Out-of-band IPv4 address if OOB access is enabled, or null. + type: str + returned: on success +""" + +EXAMPLES = """ +- name: Activate rescue mode with password auth + serverscom.sc_api.dedicated_server_rescue: + token: "{{ api_token }}" + server_id: "0m592Zmn" + state: rescue + auth_methods: + - password + +- name: Activate rescue mode with SSH key by name + serverscom.sc_api.dedicated_server_rescue: + token: "{{ api_token }}" + server_id: "0m592Zmn" + state: rescue + auth_methods: + - ssh_key + ssh_key_name: my-key + +- name: Activate rescue mode with SSH key by fingerprint + serverscom.sc_api.dedicated_server_rescue: + token: "{{ api_token }}" + server_id: "0m592Zmn" + state: rescue + auth_methods: + - password + - ssh_key + ssh_key_fingerprints: + - "ab:cd:ef:12:34:56:78:90" + +- name: Deactivate rescue mode + serverscom.sc_api.dedicated_server_rescue: + token: "{{ api_token }}" + server_id: "0m592Zmn" + state: normal + +- name: Activate rescue without waiting + serverscom.sc_api.dedicated_server_rescue: + token: "{{ api_token }}" + server_id: "0m592Zmn" + state: rescue + auth_methods: + - password + wait: 0 +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.serverscom.sc_api.plugins.module_utils.modules import ( + AUTH_ARGS, + SCBaseError, +) +from ansible_collections.serverscom.sc_api.plugins.module_utils.dedicated_server import ( + ScDedicatedServerRescue, +) + +__metaclass__ = type + + +def main(): + module = AnsibleModule( + argument_spec={ + **AUTH_ARGS, + "server_id": {"type": "str", "required": True}, + "state": { + "type": "str", + "choices": ["rescue", "normal"], + "required": True, + }, + "auth_methods": { + "type": "list", + "elements": "str", + }, + "ssh_key_fingerprints": { + "type": "list", + "elements": "str", + "no_log": False + }, + "ssh_key_name": {"type": "str"}, + "wait": {"type": "int", "default": 600}, + "update_interval": {"type": "int", "default": 10}, + }, + supports_check_mode=True, + mutually_exclusive=[ + ["ssh_key_fingerprints", "ssh_key_name"], + ], + required_if=[ + ["state", "rescue", ["auth_methods"]], + ], + ) + + try: + rescue = ScDedicatedServerRescue( + endpoint=module.params["endpoint"], + token=module.params["token"], + server_id=module.params["server_id"], + state=module.params["state"], + auth_methods=module.params["auth_methods"], + ssh_key_fingerprints=module.params["ssh_key_fingerprints"], + ssh_key_name=module.params["ssh_key_name"], + wait=module.params["wait"], + update_interval=module.params["update_interval"], + checkmode=module.check_mode, + ) + module.exit_json(**rescue.run()) + except SCBaseError as e: + module.exit_json(**e.fail()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/serverscom/sc_api/tests/integration/README.md b/ansible_collections/serverscom/sc_api/tests/integration/README.md index f92041da..0827164c 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/README.md +++ b/ansible_collections/serverscom/sc_api/tests/integration/README.md @@ -1,11 +1,54 @@ How to run integration tests ============================ You need to create a custom `integration_config.yml` file in this directory. +You should never commit the file into the repository (and it's gitignored). -Variables to define: -* sc_endpoint -* sc_token +Use `integration_config.yml.template` in the repository root as a starting +point - copy it here and fill in real values for your environment. -You should never commit the file into the repository (and it's gitignored). +Required variables +------------------ +* `sc_endpoint` - API endpoint URL +* `sc_token` - API bearer token + +Pre-existing resource variables +------------------------------- +These reference resources that must already exist in the account before +running integration tests: + +* `existing_server1_id`, `existing_server2_id`, `existing_server3_id` - existing dedicated server IDs +* `non_existing_id` - an ID that does not correspond to any existing resource, but is valid. + +Test environment variables +-------------------------- +Each test group uses environment-specific IDs, codes, and names. +Update these to match your account's available resources. + +**RBS (Block Storage):** +`rbs_test_location_id`, `rbs_test_location_code`, `rbs_test_flavor_id`, +`rbs_test_flavor_name` + +**Load Balancer:** +`lb_test_location_id`, `lb_test_upstream_ip1`, `lb_test_upstream_ip2`, +`lb_test_network` + +**Cloud Computing:** +`cloud_test_region_id`, `cloud_test_flavor_id`, `cloud_test_flavor_name`, +`cloud_test_ssh_key_fingerprint`, `cloud_test_region_search_pattern`, +`cloud_test_region_search_match`, `cloud_test_region_search_nomatch` + +**Baremetal:** +`baremetal_test_location_search_pattern`, `baremetal_test_location_search_match`, +`baremetal_test_location_search_nomatch`, `baremetal_test_os_location_id`, +`baremetal_test_os_location_code`, `baremetal_test_os_server_model_id`, +`baremetal_test_os_server_model_name` + +**Dedicated server reinstall:** +`dedicated_test_reinstall_os_id`, `dedicated_test_reinstall_ssh_key_fingerprint`, +`dedicated_test_reinstall_ssh_key_name` + +**SBM (Scalable Baremetal):** +`sbm_test_location_code`, `sbm_test_flavor_name`, `sbm_test_os_regex`, +`sbm_test_reinstall_os_name` See the Justfile recipes (in the root directory of this project) for local development. diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/baremetal_locations_info/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/baremetal_locations_info/tasks/main.yaml index b2ef0cb9..f9555a8a 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/baremetal_locations_info/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/baremetal_locations_info/tasks/main.yaml @@ -23,7 +23,7 @@ - name: Test2, Search for location sc_baremetal_locations_info: - search_pattern: US + search_pattern: "{{ baremetal_test_location_search_pattern }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -34,8 +34,8 @@ that: - test2.locations|length >= 1 - test2.locations[0] - - "'AMS1' not in test2|string" - - "'DFW1' in test2|string" + - "baremetal_test_location_search_nomatch not in test2|string" + - "baremetal_test_location_search_match in test2|string" - name: Test3, Filter by features sc_baremetal_locations_info: diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/baremetal_os_list/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/baremetal_os_list/tasks/main.yaml index 652a3e31..f87d75f5 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/baremetal_os_list/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/baremetal_os_list/tasks/main.yaml @@ -43,8 +43,8 @@ - name: Test3, Get OS list with location_id and server_model_id sc_baremetal_os_list: - location_id: 34 - server_model_id: 11940 + location_id: "{{ baremetal_test_os_location_id }}" + server_model_id: "{{ baremetal_test_os_server_model_id }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -59,8 +59,8 @@ - name: Test4, Get OS list with location_code and server_model_name sc_baremetal_os_list: - location_code: "ams1" - server_model_name: "Dell R730xd / 2xIntel Xeon E5-2680 v3 / 32 GB RAM / 4x600 GB SAS" + location_code: "{{ baremetal_test_os_location_code }}" + server_model_name: "{{ baremetal_test_os_server_model_name }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance/tasks/main.yaml index 62d6b6a6..b2b0c4f2 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance/tasks/main.yaml @@ -368,7 +368,7 @@ state: present name: 8d2779e6-eb72-11ea-970f-733ca983e4ca region_id: "{{ regions.regions[0].id }}" - flavor_name: "SSD.30" + flavor_name: "{{ cloud_test_flavor_name }}" image_regexp: "Ubuntu.+" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' @@ -421,7 +421,7 @@ region_id: "{{ regions.regions[0].id }}" gpn: true ipv4: false - flavor_name: "SSD.30" + flavor_name: "{{ cloud_test_flavor_name }}" image_regexp: "Ubuntu.+" ssh_key_name: 95f41cea-00fc-11ed-bfa8-33691f518c37 user_data: | diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_info/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_info/tasks/main.yaml index fa8e2457..aa9098f6 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_info/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_info/tasks/main.yaml @@ -23,7 +23,7 @@ - name: Get Debian 12 image ID sc_cloud_computing_images_info: - region_id: 2 + region_id: "{{ cloud_test_region_id }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -36,11 +36,11 @@ - name: Create test instance sc_cloud_computing_instance: state: present - region_id: 2 # Luxembourg - flavor_id: "33227-1" # RND.2-1024-10 + region_id: "{{ cloud_test_region_id }}" + flavor_id: "{{ cloud_test_flavor_id }}" image_id: "{{ debian_12_image_id }}" name: "sc-cloud-computing-instance-info-test" - ssh_key_fingerprint: "9b:08:4d:a5:6d:45:26:72:2c:e0:9a:ee:bf:7d:03:a6" + ssh_key_fingerprint: "{{ cloud_test_ssh_key_fingerprint }}" labels: test_label: "test_value" backup_copies: 0 @@ -66,8 +66,8 @@ - test2 is not changed - test2.name == "sc-cloud-computing-instance-info-test" - test2.status == "ACTIVE" - - test2.region_id == 2 - - test2.flavor_id == "33227-1" + - test2.region_id == cloud_test_region_id + - test2.flavor_id == cloud_test_flavor_id - test2.image_id == debian_12_image_id - test2.labels.test_label == "test_value" - test2.backup_copies == 0 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_ptr/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_ptr/tasks/main.yaml index c34d6bf9..ee9a94f8 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_ptr/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_ptr/tasks/main.yaml @@ -18,7 +18,7 @@ sc_cloud_computing_instance: state: present region_id: "{{ regions.regions[0].id }}" - flavor_name: SSD.30 + flavor_name: "{{ cloud_test_flavor_name }}" image_regexp: "Ubuntu.+" name: 537eb44e-eced-11ea-8fef-3b4e87dd916a backup_copies: 0 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_reinstall/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_reinstall/tasks/main.yaml index a8a02480..85144ed9 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_reinstall/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_reinstall/tasks/main.yaml @@ -18,7 +18,7 @@ sc_cloud_computing_instance: state: present region_id: '{{ regions.regions[0].id }}' - flavor_name: SSD.30 + flavor_name: "{{ cloud_test_flavor_name }}" image_regexp: 'Ubuntu.+' name: '{{ instance_name }}' backup_copies: 0 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_state/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_state/tasks/main.yaml index 067c1f2c..e0d64649 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_state/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instance_state/tasks/main.yaml @@ -17,7 +17,7 @@ sc_cloud_computing_instance: state: present region_id: '{{ regions.regions[0].id }}' - flavor_name: SSD.30 + flavor_name: "{{ cloud_test_flavor_name }}" image_regexp: 'Ubuntu.+' name: 4839550e-edf5-11ea-8df4-4f25b3878e42 backup_copies: 0 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instances_info/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instances_info/tasks/main.yaml index 0ccd87e1..412375a9 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instances_info/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_instances_info/tasks/main.yaml @@ -21,7 +21,7 @@ - name: Test2, Get all instances in one region sc_cloud_computing_instances_info: - region_id: 2 + region_id: "{{ cloud_test_region_id }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -33,7 +33,7 @@ - test2 is not changed - test2.cloud_instances|length > 0 - test2.cloud_instances[0].id - - test2.cloud_instances[0].region_id == 2 + - test2.cloud_instances[0].region_id == cloud_test_region_id - test2.cloud_instances[0].region_code - test2.cloud_instances[0].flavor_id - test2.cloud_instances[0].flavor_name @@ -56,7 +56,7 @@ - name: Get Debian 12 image ID sc_cloud_computing_images_info: - region_id: 2 + region_id: "{{ cloud_test_region_id }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -69,11 +69,11 @@ - name: Create test instance with labels sc_cloud_computing_instance: state: present - region_id: 2 # Luxembourg - flavor_id: "33227-1" # RND.2-1024-10 + region_id: "{{ cloud_test_region_id }}" + flavor_id: "{{ cloud_test_flavor_id }}" image_id: "{{ debian_12_image_id }}" name: "sc-cloud-computing-instances-info-test" - ssh_key_fingerprint: "9b:08:4d:a5:6d:45:26:72:2c:e0:9a:ee:bf:7d:03:a6" + ssh_key_fingerprint: "{{ cloud_test_ssh_key_fingerprint }}" labels: test_label: "test_value" backup_copies: 0 @@ -99,7 +99,7 @@ - test4 is not changed - test4.cloud_instances|length > 0 - test4.cloud_instances[0].id == test_instance.id - - test4.cloud_instances[0].region_id == 2 + - test4.cloud_instances[0].region_id == cloud_test_region_id - test4.cloud_instances[0].name == "sc-cloud-computing-instances-info-test" - name: Destroy test instance diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_regions_info/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_regions_info/tasks/main.yaml index 6ad2ebbe..42e91d44 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_regions_info/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/cloud_computing_regions_info/tasks/main.yaml @@ -23,7 +23,7 @@ - name: Test2, Search for location sc_cloud_computing_regions_info: - search_pattern: WAS + search_pattern: "{{ cloud_test_region_search_pattern }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -34,5 +34,5 @@ assert: that: - test2.regions|length >= 1 - - "'AMS1' not in test2|string" - - "'WAS1' in test2|string" + - "cloud_test_region_search_nomatch not in test2|string" + - "cloud_test_region_search_match in test2|string" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_info/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_info/tasks/main.yaml index 1a9f3b6f..b7905398 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_info/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_info/tasks/main.yaml @@ -40,7 +40,7 @@ - name: Test3, Information for real server sc_dedicated_server_info: fail_on_absent: false - id: '{{ existing_server1_id }}' + id: '{{ existing_server3_id }}' environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -50,7 +50,7 @@ assert: that: - test3 is success - - test3.id == existing_server1_id + - test3.id == existing_server3_id - test3.configuration|length > 0 - test3.configuration_details|length > 0 - test3.found diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_ipxe/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_ipxe/tasks/main.yaml new file mode 100644 index 00000000..4a75e155 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_ipxe/tasks/main.yaml @@ -0,0 +1,313 @@ +--- +- name: Check if there are sc_token and sc_endpoint variables + no_log: true + fail: + msg: "You need to define sc_token and sc_endpoint variables in tests/integration/integration_config.yml" + when: not sc_endpoint or not sc_token + +- name: Wait until server is ready after reinstall + sc_dedicated_server_info: + name: '{{ existing_server3_id }}' + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: info + until: info.ready == 1 + delay: 30 + retries: 180 + +# --- Activate public iPXE boot --- + +- name: Activate public iPXE boot + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: public + ipxe_config: | + #!ipxe + chain http://boot.example.com/menu.ipxe + wait: 600 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: activate_public + +- name: Verify activate public iPXE boot + assert: + that: + - activate_public is changed + - activate_public.feature.name == "public_ipxe_boot" + - activate_public.feature.status == "activated" + +- name: Activate public iPXE boot (idempotency) + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: public + ipxe_config: | + #!ipxe + chain http://boot.example.com/menu.ipxe + wait: 0 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: activate_public_idem + +- name: Verify activate public iPXE boot idempotency + assert: + that: + - activate_public_idem is not changed + - activate_public_idem.feature.status == "activated" + +# --- Update iPXE config on activated feature --- + +- name: Update iPXE config on activated public feature + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: public + ipxe_config: | + #!ipxe + chain http://boot.example.com/updated-menu.ipxe + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: update_config + +- name: Verify iPXE config update + assert: + that: + - update_config is changed + - update_config.feature.name == "public_ipxe_boot" + - update_config.feature.status == "activated" + +- name: Update iPXE config on activated public feature (idempotency) + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: public + ipxe_config: | + #!ipxe + chain http://boot.example.com/updated-menu.ipxe + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: update_config_idem + +- name: Verify iPXE config update idempotency + assert: + that: + - update_config_idem is not changed + - update_config_idem.feature.status == "activated" + +# --- Check mode for deactivation --- + +- name: Deactivate iPXE boot (check mode) + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: absent + wait: 0 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + check_mode: true + register: deactivate_check + +- name: Verify check mode deactivation reports changed + assert: + that: + - deactivate_check is changed + +# --- Deactivate iPXE boot --- + +- name: Deactivate iPXE boot + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: absent + wait: 600 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: deactivate_public + +- name: Verify deactivate iPXE boot + assert: + that: + - deactivate_public is changed + - deactivate_public.feature.name == "public_ipxe_boot" + - deactivate_public.feature.status == "deactivated" + +- name: Deactivate iPXE boot (idempotency) + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: absent + wait: 0 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: deactivate_public_idem + +- name: Verify deactivate iPXE boot idempotency + assert: + that: + - deactivate_public_idem is not changed + +# --- Check mode for activation --- + +- name: Activate public iPXE boot (check mode) + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: public + ipxe_config: | + #!ipxe + chain http://boot.example.com/menu.ipxe + wait: 0 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + check_mode: true + register: activate_check + +- name: Verify check mode activation reports changed + assert: + that: + - activate_check is changed + +- name: Confirm feature is still deactivated after check mode + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: absent + wait: 0 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: after_check + +- name: Verify still deactivated after check mode + assert: + that: + - after_check is not changed + +# --- Switch mode: public -> private -> public --- + +- name: Activate public iPXE boot (setup for switch) + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: public + ipxe_config: | + #!ipxe + chain http://boot.example.com/menu.ipxe + wait: 600 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: setup_public + +- name: Verify public is activated for switch test + assert: + that: + - setup_public.feature.status == "activated" + +- name: Switch from public to private iPXE boot + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: private + ipxe_config: | + #!ipxe + chain http://boot.example.com/private-menu.ipxe + wait: 600 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: switch_to_private + +- name: Verify switch to private + assert: + that: + - switch_to_private is changed + - switch_to_private.feature.name == "private_ipxe_boot" + - switch_to_private.feature.status == "activated" + +- name: Switch to private iPXE boot (idempotency) + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: private + ipxe_config: | + #!ipxe + chain http://boot.example.com/private-menu.ipxe + wait: 0 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: switch_to_private_idem + +- name: Verify switch to private idempotency + assert: + that: + - switch_to_private_idem is not changed + - switch_to_private_idem.feature.status == "activated" + +- name: Switch from private back to public iPXE boot + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: public + ipxe_config: | + #!ipxe + chain http://boot.example.com/menu.ipxe + wait: 600 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: switch_to_public + +- name: Verify switch back to public + assert: + that: + - switch_to_public is changed + - switch_to_public.feature.name == "public_ipxe_boot" + - switch_to_public.feature.status == "activated" + +- name: Switch to private iPXE boot (check mode) + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: private + ipxe_config: | + #!ipxe + chain http://boot.example.com/private-menu.ipxe + wait: 0 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + check_mode: true + register: switch_check + +- name: Verify check mode switch reports changed + assert: + that: + - switch_check is changed + +- name: Confirm public is still activated after check mode switch + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: public + ipxe_config: | + #!ipxe + chain http://boot.example.com/menu.ipxe + wait: 0 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: after_switch_check + +- name: Verify public still activated after check mode switch + assert: + that: + - after_switch_check is not changed + - after_switch_check.feature.status == "activated" + +# --- Cleanup --- + +- name: Deactivate iPXE boot (cleanup) + serverscom.sc_api.dedicated_server_ipxe: + server_id: "{{ existing_server3_id }}" + state: absent + wait: 600 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_1/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_1/tasks/main.yaml new file mode 100644 index 00000000..591806a9 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_1/tasks/main.yaml @@ -0,0 +1,88 @@ +--- +# tests for real reinstallation. Takes about 40 minutes per task. +- name: Check if there are sc_token and sc_endpoint variables + no_log: true + fail: + msg: 'You need to define sc_token and sc_endpoint variables in tests/integration/integration_config.yml' + when: not sc_endpoint or not sc_token + + +- name: Test1, successful reinstall in no-wait mode + sc_dedicated_server_reinstall: + id: '{{ existing_server1_id }}' + drives_layout_template: raid1-simple + operating_system_id: "{{ dedicated_test_reinstall_os_id }}" + hostname: reinstall + wait: 0 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test1 + +- name: Check Test1 + assert: + that: + - test1 is changed + - test1.operational_status == "installation" + - test1.id == existing_server1_id + - test1.power_status == "powered_on" + +- name: Test1, wait until installation is done + sc_dedicated_server_info: + name: '{{ existing_server1_id }}' + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: info + until: info.ready == 1 + delay: 30 + retries: 180 + +- name: Test4, reinstall with raid0_template and ambiguous OS regex + sc_dedicated_server_reinstall: + id: '{{ existing_server1_id }}' + drives_layout_template: raid0-simple + operating_system_regex: "^debian (.*)x86_64$" + hostname: raid0test + ssh_key_name: "{{ dedicated_test_reinstall_ssh_key_name }}" + wait: 3600 + update_interval: 30 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test4 + failed_when: + - test4 is success + +- name: Check Test4 + assert: + that: + - test4 is not changed + - >- + test4.msg is match("(?s)^Multiple OS options match the + regex '\\^debian \\(.*\\)x86_64\\$': .*") + +- name: Test5, reinstall with raid0_template and non-existing OS regex + sc_dedicated_server_reinstall: + id: '{{ existing_server1_id }}' + drives_layout_template: raid0-simple + operating_system_regex: "^Debian 6(.*)x86_64$" + hostname: raid0test + ssh_key_name: "{{ dedicated_test_reinstall_ssh_key_name }}" + wait: 3600 + update_interval: 30 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test5 + failed_when: + - test5 is success + +- name: Check Test5 + assert: + that: + - test5 is not changed + - >- + test5.msg is match("(?s)^No OS options match the regex + '\\^Debian 6\\(.*\\)x86_64\\$' for server model 'Supermicro X11DPL-i / + 2 x Intel Xeon Silver 4114 / 32 GB RAM / 1 x 480 GB SSD' in location 'WAS1'.*") diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_2/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_2/tasks/main.yaml new file mode 100644 index 00000000..c5075817 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_2/tasks/main.yaml @@ -0,0 +1,27 @@ +--- +# Reinstall tests for server2: short-timeout wait test. +- name: Check if there are sc_token and sc_endpoint variables + no_log: true + fail: + msg: 'You need to define sc_token and sc_endpoint variables in tests/integration/integration_config.yml' + when: not sc_endpoint or not sc_token + +- name: Test1, reinstall with wait-mode with very short wait time + sc_dedicated_server_reinstall: + id: '{{ existing_server2_id }}' + drives_layout_template: raid1-simple + operating_system_id: "{{ dedicated_test_reinstall_os_id }}" + hostname: reinstall2 + wait: 10 + update_interval: 5 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test1 + failed_when: false + +- name: Check Test1 + assert: + that: + - test1.timeout > 10 + - "'Server is not ready' in test1.msg" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_3/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_3/tasks/main.yaml new file mode 100644 index 00000000..2513c01a --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_3/tasks/main.yaml @@ -0,0 +1,28 @@ +--- +# Reinstall tests for server3: full-wait reinstall with raid1 template. +- name: Check if there are sc_token and sc_endpoint variables + no_log: true + fail: + msg: 'You need to define sc_token and sc_endpoint variables in tests/integration/integration_config.yml' + when: not sc_endpoint or not sc_token + +- name: Test1, reinstall with wait-mode with raid1-template + sc_dedicated_server_reinstall: + id: '{{ existing_server3_id }}' + drives_layout_template: raid1-simple + hostname: raid1test + operating_system_id: "{{ dedicated_test_reinstall_os_id }}" + wait: 3600 + update_interval: 30 + ssh_keys: + - "{{ dedicated_test_reinstall_ssh_key_fingerprint }}" + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test1 + +- name: Check Test1 + assert: + that: + - test1.ready + - test1.title == "raid1test" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_long/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_long/tasks/main.yaml deleted file mode 100644 index aa5bb1bf..00000000 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_long/tasks/main.yaml +++ /dev/null @@ -1,186 +0,0 @@ ---- -# tests for real reinstallation. Takes about 40 minutes per task. -- name: Check if there are sc_token and sc_endpoint variables - no_log: true - fail: - msg: 'You need to define sc_token and sc_endpoint variables in tests/integration/integration_config.yml' - when: not sc_endpoint or not sc_token - - -- name: Test1, successful reinstall in no-wait mode - sc_dedicated_server_reinstall: - id: '{{ existing_server1_id }}' - drives_layout_template: raid1-simple - operating_system_id: 49 - hostname: reinstall - wait: 0 - environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' - register: test1 - -- name: Check Test1 - assert: - that: - - test1 is changed - - test1.operational_status == "installation" - - test1.id == existing_server1_id - - test1.power_status == "powered_on" - -- name: Test1, wait until installation is done - sc_dedicated_server_info: - name: '{{ existing_server1_id }}' - environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' - register: info - until: info.ready == 1 - delay: 30 - retries: 180 - -- name: Test2, reinstall with wait-mode with very short wait time - sc_dedicated_server_reinstall: - id: '{{ existing_server2_id }}' - drives_layout_template: raid1-simple - operating_system_id: 49 - hostname: reinstall2 - wait: 10 - update_interval: 5 - environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' - register: test2 - failed_when: false - -- name: Check Test2 - assert: - that: - - test2.timeout > 10 - - "'Server is not ready' in test2.msg" - - -- name: Test3, reinstall with wait-mode with raid1-template - sc_dedicated_server_reinstall: - id: '{{ existing_server3_id }}' - drives_layout_template: raid1-simple - hostname: raid1test - operating_system_id: 49 - wait: 3600 - update_interval: 30 - ssh_keys: - - f7:90:27:e6:97:5e:6d:ad:31:51:65:26:8d:82:ac:f9 - environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' - register: test3 - -- name: Check Test3 - assert: - that: - - test3.ready - - test3.title == "raid1test" - -- name: wait until installation is done 2 - sc_dedicated_server_info: - name: '{{ existing_server1_id }}' - environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' - register: info - until: info.ready == 1 - delay: 30 - retries: 180 - -- name: Test4, reinstall with raid0_template and ambiguous OS regex - sc_dedicated_server_reinstall: - id: '{{ existing_server1_id }}' - drives_layout_template: raid0-simple - operating_system_regex: "^debian (.*)x86_64$" - hostname: raid0test - ssh_key_name: amarao - wait: 3600 - update_interval: 30 - environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' - register: test4 - failed_when: - - test4 is success - -- name: Check Test4 - assert: - that: - - test4 is not changed - - >- - test4.msg is match("(?s)^Multiple OS options match the - regex '\\^debian \\(.*\\)x86_64\\$': .*") - -- name: Test5, reinstall with raid0_template and non-existing OS regex - sc_dedicated_server_reinstall: - id: '{{ existing_server1_id }}' - drives_layout_template: raid0-simple - operating_system_regex: "^Debian 6(.*)x86_64$" - hostname: raid0test - ssh_key_name: amarao - wait: 3600 - update_interval: 30 - environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' - register: test5 - failed_when: - - test5 is success - -- name: Check Test5 - assert: - that: - - test5 is not changed - - >- - test5.msg is match("(?s)^No OS options match the regex - '\\^Debian 6\\(.*\\)x86_64\\$' for server model 'Supermicro X11DPL-i / - 2 x Intel Xeon Silver 4114 / 32 GB RAM / 1 x 480 GB SSD' in location 'WAS1'.*") - - -- name: Test6, reinstall with raid0_template and OS name - sc_dedicated_server_reinstall: - id: '{{ existing_server1_id }}' - drives_layout_template: raid0-simple - operating_system_regex: "^Debian 12 (.*)x86_64$" - hostname: raid0test - ssh_key_name: amarao - wait: 3600 - update_interval: 30 - environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' - register: test6 - -- name: Check Test6 - assert: - that: - - test6 is changed - - test6.id == existing_server1_id - - test6.power_status == "powered_on" - - test6.title == "raid0test" - -- name: Test7, reinstall with no-raid_template and OS name - sc_dedicated_server_reinstall: - id: '{{ existing_server1_id }}' - drives_layout_template: no-raid - operating_system_regex: "^Debian 12 (.*)x86_64$" - hostname: noraidtest - ssh_key_name: amarao - wait: 3600 - update_interval: 30 - environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' - register: test7 - -- name: Check Test7 - assert: - that: - - test7 is changed - - test7.id == existing_server1_id - - test7.power_status == "powered_on" - - test7.title == "noraidtest" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_noraid/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_noraid/tasks/main.yaml new file mode 100644 index 00000000..4d503938 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_noraid/tasks/main.yaml @@ -0,0 +1,40 @@ +--- +# Reinstall test for server3: no-raid template with OS regex. +- name: Check if there are sc_token and sc_endpoint variables + no_log: true + fail: + msg: 'You need to define sc_token and sc_endpoint variables in tests/integration/integration_config.yml' + when: not sc_endpoint or not sc_token + +- name: Wait until server is ready + sc_dedicated_server_info: + name: '{{ existing_server3_id }}' + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: info + until: info.ready == 1 + delay: 30 + retries: 180 + +- name: Test1, reinstall with no-raid_template and OS regex + sc_dedicated_server_reinstall: + id: '{{ existing_server3_id }}' + drives_layout_template: no-raid + operating_system_regex: "^Debian 12 (.*)x86_64$" + hostname: noraidtest + ssh_key_name: "{{ dedicated_test_reinstall_ssh_key_name }}" + wait: 3600 + update_interval: 30 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test1 + +- name: Check Test1 + assert: + that: + - test1 is changed + - test1.id == existing_server3_id + - test1.power_status == "powered_on" + - test1.title == "noraidtest" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_quick/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_quick/tasks/main.yaml index 3cc64f49..e28fb115 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_quick/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_quick/tasks/main.yaml @@ -22,7 +22,7 @@ that: - test1 is not changed - test1.status_code == 404 - - "'v1/hosts/dedicated_servers/gd0EL519' in test1.api_url" + - "'v1/hosts/dedicated_servers/' ~ non_existing_id in test1.api_url" # should make a get to server before doing reinstall - name: Test2, Error on absent ssh key diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_raid0/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_raid0/tasks/main.yaml new file mode 100644 index 00000000..459c3690 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_reinstall_raid0/tasks/main.yaml @@ -0,0 +1,40 @@ +--- +# Reinstall test for server2: raid0 template with OS regex. +- name: Check if there are sc_token and sc_endpoint variables + no_log: true + fail: + msg: 'You need to define sc_token and sc_endpoint variables in tests/integration/integration_config.yml' + when: not sc_endpoint or not sc_token + +- name: Wait until server is ready + sc_dedicated_server_info: + name: '{{ existing_server2_id }}' + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: info + until: info.ready == 1 + delay: 30 + retries: 180 + +- name: Test1, reinstall with raid0_template and OS regex + sc_dedicated_server_reinstall: + id: '{{ existing_server2_id }}' + drives_layout_template: raid0-simple + operating_system_regex: "^Debian 12 (.*)x86_64$" + hostname: raid0test + ssh_key_name: "{{ dedicated_test_reinstall_ssh_key_name }}" + wait: 3600 + update_interval: 30 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test1 + +- name: Check Test1 + assert: + that: + - test1 is changed + - test1.id == existing_server2_id + - test1.power_status == "powered_on" + - test1.title == "raid0test" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_rescue/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_rescue/tasks/main.yaml new file mode 100644 index 00000000..78f006ec --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/dedicated_server_rescue/tasks/main.yaml @@ -0,0 +1,104 @@ +--- +- name: Check if there are sc_token and sc_endpoint variables + no_log: true + fail: + msg: "You need to define sc_token and sc_endpoint variables in tests/integration/integration_config.yml" + when: not sc_endpoint or not sc_token + +- name: Wait until server is ready after reinstall + sc_dedicated_server_info: + name: '{{ existing_server2_id }}' + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: info + until: info.ready == 1 + delay: 30 + retries: 180 + +- name: Dedicated server rescue test series + block: + - name: Ensure server starts in normal mode + serverscom.sc_api.dedicated_server_rescue: + state: normal + server_id: "{{ existing_server2_id }}" + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + + - name: Activate rescue mode with password + serverscom.sc_api.dedicated_server_rescue: + state: rescue + server_id: "{{ existing_server2_id }}" + auth_methods: + - password + wait: 600 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test1 + + - name: Check Test1 + assert: + that: + - test1 is changed + - test1.operational_status == "rescue_mode" + + - name: Activate rescue mode again (idempotency) + serverscom.sc_api.dedicated_server_rescue: + state: rescue + server_id: "{{ existing_server2_id }}" + auth_methods: + - password + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test2 + + - name: Check Test2 + assert: + that: + - test2 is not changed + - test2.operational_status == "rescue_mode" + + - name: Deactivate rescue mode without waiting + serverscom.sc_api.dedicated_server_rescue: + state: normal + server_id: "{{ existing_server2_id }}" + wait: 0 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test3 + + - name: Check Test3 + assert: + that: + - test3 is changed + + - name: Deactivate rescue mode again with wait (waits for in-progress deactivation) + serverscom.sc_api.dedicated_server_rescue: + state: normal + server_id: "{{ existing_server2_id }}" + wait: 600 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + register: test4 + + - name: Check Test4 + assert: + that: + - test4 is not changed + - test4.operational_status == "normal" + + always: + - name: Ensure server is back to normal mode + serverscom.sc_api.dedicated_server_rescue: + state: normal + server_id: "{{ existing_server2_id }}" + wait: 600 + environment: + SERVERSCOM_API_TOKEN: '{{ sc_token }}' + SERVERSCOM_API_URL: '{{ sc_endpoint }}' + ignore_errors: true diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_info/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_info/tasks/main.yaml index e807451e..06d5a776 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_info/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_info/tasks/main.yaml @@ -49,7 +49,7 @@ serverscom.sc_api.sc_load_balancer_instance_l7: state: present name: "test-sc-load-balancer-instance-info-module" - location_id: 32 + location_id: "{{ lb_test_location_id }}" store_logs: false geoip: false vhost_zones: @@ -67,7 +67,7 @@ redirect: false real_ip_header: name: "real_ip" - networks: ["192.168.1.0/24"] + networks: ["{{ lb_test_network }}"] upstream_zones: - id: "upstream-zone1" hc_interval: 5 @@ -82,7 +82,7 @@ hc_passes: 3 grpc: false upstreams: - - ip: 10.33.180.116 + - ip: "{{ lb_test_upstream_ip1 }}" port: 3000 weight: 1 max_conns: 63000 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_l4/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_l4/tasks/main.yaml index 8083be03..e63dac21 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_l4/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_l4/tasks/main.yaml @@ -21,7 +21,7 @@ hc_interval: 5 hc_jitter: 5 upstreams: - - ip: 10.33.180.116 + - ip: "{{ lb_test_upstream_ip1 }}" port: 3000 weight: 1 max_conns: 63000 @@ -34,7 +34,7 @@ serverscom.sc_api.sc_load_balancer_instance_l4: state: present name: "test-lb-l4-instance-module" - location_id: 32 + location_id: "{{ lb_test_location_id }}" vhost_zones: "{{ vhost_zones }}" upstream_zones: "{{ upstream_zones }}" labels: "{{ labels }}" @@ -51,7 +51,7 @@ # - (test2.cluster_id is none) or (test2.cluster_id | string | length == 0) # - (test2.external_addresses | length) > 0 # - (test2.id | string | length) > 0 - # - test2.location_id == 32 + # - test2.location_id == lb_test_location_id # - test2.name == "test-lb-l4-instance-module" # - test2.status == "active" # - test2.type == "l4" @@ -71,7 +71,7 @@ # token: "{{ sc_token }}" # state: present # name: "test-lb-l4-instance-module" - # location_id: 32 + # location_id: "{{ lb_test_location_id }}" # vhost_zones: "{{ vhost_zones }}" # upstream_zones: "{{ upstream_zones }}" # labels: "{{ labels }}" @@ -93,13 +93,13 @@ hc_interval: 5 hc_jitter: 5 upstreams: - - ip: 10.33.180.116 + - ip: "{{ lb_test_upstream_ip1 }}" port: 3000 weight: 1 max_conns: 63000 max_fails: 0 fail_timeout: 30 - - ip: 10.33.180.196 + - ip: "{{ lb_test_upstream_ip2 }}" port: 3000 weight: 1 max_conns: 63000 @@ -127,7 +127,7 @@ hc_interval: 5 hc_jitter: 5 upstreams: - - ip: 10.33.180.196 + - ip: "{{ lb_test_upstream_ip2 }}" port: 3000 weight: 1 max_conns: 63000 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_l7/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_l7/tasks/main.yaml index 64977bd0..386e9840 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_l7/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instance_l7/tasks/main.yaml @@ -11,7 +11,7 @@ serverscom.sc_api.sc_load_balancer_instance_l7: state: present name: "test-lb-l7-instance-module" - location_id: 32 + location_id: "{{ lb_test_location_id }}" store_logs: false geoip: false vhost_zones: @@ -29,7 +29,7 @@ redirect: false real_ip_header: name: "real_ip" - networks: ["192.168.1.0/24"] + networks: ["{{ lb_test_network }}"] upstream_zones: - id: "upstream-zone1" hc_interval: 5 @@ -44,7 +44,7 @@ hc_passes: 3 grpc: false upstreams: - - ip: 10.33.180.116 + - ip: "{{ lb_test_upstream_ip1 }}" port: 3000 weight: 1 max_conns: 63000 @@ -72,7 +72,7 @@ - test2.geoip_enabled == false - (test2.id | string | length) > 0 - test2.labels.environment == "staging" - - test2.location_id == 32 + - test2.location_id == lb_test_location_id - test2.name == "test-lb-l7-instance-module" - test2.redirect_http == false - (test2.proxy_protocol_enabled | default(false)) == false @@ -98,7 +98,7 @@ - test2.vhost_zones[0].ports == [80] - (test2.vhost_zones[0].proxy_request_headers | length) == 0 - test2.vhost_zones[0].real_ip_header.name == "real_ip" - - test2.vhost_zones[0].real_ip_header.networks == ["192.168.1.0/24"] + - test2.vhost_zones[0].real_ip_header.networks == [lb_test_network] - test2.vhost_zones[0].ssl == false # - name: Idempotency check (nothing changed) @@ -106,7 +106,7 @@ # token: "{{ sc_token }}" # state: present # name: "test-lb-l7-instance-module" - # location_id: 32 + # location_id: "{{ lb_test_location_id }}" # store_logs: false # geoip: false # vhost_zones: @@ -124,7 +124,7 @@ # redirect: false # real_ip_header: # name: "real_ip" - # networks: ["192.168.1.0/24"] + # networks: ["{{ lb_test_network }}"] # upstream_zones: # - id: "upstream-zone1" # hc_interval: 5 @@ -139,7 +139,7 @@ # hc_passes: 3 # grpc: false # upstreams: - # - ip: 10.33.180.116 + # - ip: "{{ lb_test_upstream_ip1 }}" # port: 3000 # weight: 1 # max_conns: 63000 @@ -173,17 +173,17 @@ redirect: false real_ip_header: name: "real_ip" - networks: ["192.168.1.0/24"] + networks: ["{{ lb_test_network }}"] upstream_zones: - id: "upstream-zone1" upstreams: - - ip: 10.33.180.116 + - ip: "{{ lb_test_upstream_ip1 }}" port: 3000 weight: 1 max_conns: 63000 max_fails: 0 fail_timeout: 30 - - ip: 10.33.180.196 + - ip: "{{ lb_test_upstream_ip2 }}" port: 3000 weight: 1 max_conns: 63000 @@ -219,11 +219,11 @@ redirect: false real_ip_header: name: "real_ip" - networks: ["192.168.1.0/24"] + networks: ["{{ lb_test_network }}"] upstream_zones: - id: "upstream-zone1" upstreams: - - ip: 10.33.180.196 + - ip: "{{ lb_test_upstream_ip2 }}" port: 3000 weight: 1 max_conns: 63000 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instances_list/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instances_list/tasks/main.yaml index 0456d6b1..3b87ca2c 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instances_list/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/load_balancer_instances_list/tasks/main.yaml @@ -11,7 +11,7 @@ serverscom.sc_api.sc_load_balancer_instance_l4: state: present name: "test-lb-l4-instances-list-module2" - location_id: 32 + location_id: "{{ lb_test_location_id }}" vhost_zones: - id: "vhost-zone1" ports: [80, 443] @@ -26,7 +26,7 @@ hc_interval: 5 hc_jitter: 5 upstreams: - - ip: 10.33.180.116 + - ip: "{{ lb_test_upstream_ip1 }}" port: 3000 weight: 1 max_conns: 63000 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_flavors_info/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_flavors_info/tasks/main.yaml index 5a0ce91e..de34d2cc 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_flavors_info/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_flavors_info/tasks/main.yaml @@ -22,7 +22,7 @@ - name: Test2, Get flavors serverscom.sc_api.sc_rbs_flavors_info: - location_id: 46 + location_id: "{{ rbs_test_location_id }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume/tasks/main.yaml index 67a7169d..ad45f692 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume/tasks/main.yaml @@ -11,11 +11,11 @@ serverscom.sc_api.sc_rbs_volume: state: present name: "test-rbs-volume-module" - location_id: 46 + location_id: "{{ rbs_test_location_id }}" labels: environment: staging size: 50 - flavor_id: 18590 # Performance + flavor_id: "{{ rbs_test_flavor_id }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -31,11 +31,11 @@ - test2.rbs_volume.size == 50 - test2.rbs_volume.status == "active" - test2.rbs_volume.labels.environment == "staging" - - test2.rbs_volume.location_id == 46 - - test2.rbs_volume.location_code == "AMS7" - - test2.rbs_volume.flavor_id == 18590 + - test2.rbs_volume.location_id == rbs_test_location_id + - test2.rbs_volume.location_code == rbs_test_location_code + - test2.rbs_volume.flavor_id == rbs_test_flavor_id - test2.rbs_volume.ip_address - - test2.rbs_volume.flavor_name == "Performance" + - test2.rbs_volume.flavor_name == rbs_test_flavor_name - test2.rbs_volume.iops - test2.rbs_volume.target_iqn - test2.rbs_volume.created_at @@ -45,11 +45,11 @@ serverscom.sc_api.sc_rbs_volume: state: present name: "test-rbs-volume-module" - location_id: 46 + location_id: "{{ rbs_test_location_id }}" labels: environment: staging size: 50 - flavor_id: 18590 # Performance + flavor_id: "{{ rbs_test_flavor_id }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -113,11 +113,11 @@ serverscom.sc_api.sc_rbs_volume: state: present name: "test-rbs-volume-module" - location_code: "AMS7" + location_code: "{{ rbs_test_location_code }}" labels: environment: staging size: 50 - flavor_name: Performance + flavor_name: "{{ rbs_test_flavor_name }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -157,11 +157,11 @@ serverscom.sc_api.sc_rbs_volume: state: present name: "test-rbs-volume-module" - location_id: 46 + location_id: "{{ rbs_test_location_id }}" labels: environment: staging size: 50 - flavor_id: 18590 # Performance + flavor_id: "{{ rbs_test_flavor_id }}" wait: 0 environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' @@ -171,11 +171,11 @@ serverscom.sc_api.sc_rbs_volume: state: present name: "test-rbs-volume-module" - location_id: 46 + location_id: "{{ rbs_test_location_id }}" labels: environment: staging size: 50 - flavor_id: 18590 # Performance + flavor_id: "{{ rbs_test_flavor_id }}" wait: 600 environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume_credentials_reset/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume_credentials_reset/tasks/main.yaml index 8f79e3f2..b7ed7895 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume_credentials_reset/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume_credentials_reset/tasks/main.yaml @@ -10,8 +10,8 @@ - name: Create test volume serverscom.sc_api.sc_rbs_volume: name: rbs_ansible_modules_test_volume - location_id: 46 - flavor_id: 18590 # Performance + location_id: "{{ rbs_test_location_id }}" + flavor_id: "{{ rbs_test_flavor_id }}" size: 50 state: present environment: @@ -54,7 +54,7 @@ always: - name: Get test volumes serverscom.sc_api.sc_rbs_volume_info: - location_id: 46 + location_id: "{{ rbs_test_location_id }}" search_pattern: "rbs_ansible_modules_test_volume" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume_info/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume_info/tasks/main.yaml index 071d0128..8da6399a 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume_info/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/rbs_volume_info/tasks/main.yaml @@ -10,8 +10,8 @@ - name: Create test volume serverscom.sc_api.sc_rbs_volume: name: rbs_ansible_modules_test_volume - location_id: 46 - flavor_id: 18590 # Performance + location_id: "{{ rbs_test_location_id }}" + flavor_id: "{{ rbs_test_flavor_id }}" size: 50 state: present environment: @@ -20,7 +20,7 @@ - name: Test1, Get volume list for a specific location id serverscom.sc_api.sc_rbs_volume_info: - location_id: 46 + location_id: "{{ rbs_test_location_id }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -62,7 +62,7 @@ - name: Test3, Get volume list for a specific location code serverscom.sc_api.sc_rbs_volume_info: - location_code: "AMS7" + location_code: "{{ rbs_test_location_code }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' @@ -89,7 +89,7 @@ always: - name: Get test volumes serverscom.sc_api.sc_rbs_volume_info: - location_id: 46 + location_id: "{{ rbs_test_location_id }}" search_pattern: "rbs_ansible_modules_test_volume" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_flavor_models_info/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_flavor_models_info/tasks/main.yaml index c4a52827..ad81f923 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_flavor_models_info/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_flavor_models_info/tasks/main.yaml @@ -102,7 +102,7 @@ - name: Test7, Verify location_id and location_code are mutually exclusive serverscom.sc_api.sc_sbm_flavor_models_info: location_id: 1 - location_code: AMS7 + location_code: "{{ sbm_test_location_code }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_os_list/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_os_list/tasks/main.yaml index af680e20..39db17b3 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_os_list/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_os_list/tasks/main.yaml @@ -111,7 +111,7 @@ - name: Test6, Verify location_id and location_code are mutually exclusive serverscom.sc_api.sc_sbm_os_list: location_id: 1 - location_code: AMS7 + location_code: "{{ sbm_test_location_code }}" flavor_id: "1" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' @@ -128,7 +128,7 @@ serverscom.sc_api.sc_sbm_os_list: location_id: 1 flavor_id: "1" - flavor_name: "DL-01" + flavor_name: "{{ sbm_test_flavor_name }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/main.yaml index 2d5565e8..1031fa27 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/main.yaml @@ -42,7 +42,7 @@ serverscom.sc_api.sc_sbm_server: state: present location_code: ZZZZZZ_INVALID - flavor_name: DL-01 + flavor_name: "{{ sbm_test_flavor_name }}" hostname: ci-sbm-negative-test operating_system_name: 'Debian 13 64-bit' environment: @@ -60,7 +60,7 @@ - name: Test create with nonexistent flavor_name serverscom.sc_api.sc_sbm_server: state: present - location_code: AMS7 + location_code: "{{ sbm_test_location_code }}" flavor_name: NONEXISTENT_FLAVOR_XYZ hostname: ci-sbm-negative-test operating_system_name: 'Debian 13 64-bit' @@ -79,8 +79,8 @@ - name: Test create with nonexistent os_name serverscom.sc_api.sc_sbm_server: state: present - location_code: AMS7 - flavor_name: DL-01 + location_code: "{{ sbm_test_location_code }}" + flavor_name: "{{ sbm_test_flavor_name }}" hostname: ci-sbm-negative-test operating_system_name: 'NonexistentOS 99.99 64-bit' environment: @@ -99,6 +99,7 @@ - block: - include_tasks: setup.yaml - include_tasks: test_info.yaml + - include_tasks: test_inventory_discovery.yaml - include_tasks: test_labels.yaml - include_tasks: test_networks.yaml - include_tasks: test_ptr.yaml diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/setup.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/setup.yaml index a10e7262..da6e888a 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/setup.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/setup.yaml @@ -5,57 +5,57 @@ - name: Get locations serverscom.sc_api.sc_baremetal_locations_info: - search_pattern: AMS7 + search_pattern: "{{ sbm_test_location_code }}" environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" register: locations_result -- name: Assert AMS7 location is available +- name: Assert location is available assert: that: - - locations_result.locations | selectattr('code', 'eq', 'AMS7') | list | length > 0 - fail_msg: "AMS7 location not found in {{ locations_result.locations | map(attribute='code') | list }}" + - locations_result.locations | selectattr('code', 'eq', sbm_test_location_code) | list | length > 0 + fail_msg: "{{ sbm_test_location_code }} location not found in {{ locations_result.locations | map(attribute='code') | list }}" -- name: Set AMS7 location facts +- name: Set location facts set_fact: - test_location_id: "{{ (locations_result.locations | selectattr('code', 'eq', 'AMS7') | first).id }}" - test_location_code: AMS7 + test_location_id: "{{ (locations_result.locations | selectattr('code', 'eq', sbm_test_location_code) | first).id }}" + test_location_code: "{{ sbm_test_location_code }}" -- name: Get SBM flavor models for AMS7 +- name: Get SBM flavor models serverscom.sc_api.sc_sbm_flavor_models_info: location_id: "{{ test_location_id }}" - search_pattern: DL-01 + search_pattern: "{{ sbm_test_flavor_name }}" environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" register: flavors_result -- name: Assert DL-01 flavor model is available +- name: Assert flavor model is available assert: that: - - flavors_result.sbm_flavor_models | selectattr('name', 'match', '.*DL-01.*') | list | length > 0 - fail_msg: "No DL-01 flavor model available in AMS7. Available: {{ flavors_result.sbm_flavor_models | map(attribute='name') | list }}" + - flavors_result.sbm_flavor_models | selectattr('name', 'match', '.*' ~ sbm_test_flavor_name ~ '.*') | list | length > 0 + fail_msg: "No {{ sbm_test_flavor_name }} flavor model available in {{ sbm_test_location_code }}. Available: {{ flavors_result.sbm_flavor_models | map(attribute='name') | list }}" -- name: Set DL-01 flavor model facts +- name: Set flavor model facts set_fact: - test_flavor_model_id: "{{ (flavors_result.sbm_flavor_models | selectattr('name', 'match', '.*DL-01.*') | first).id }}" - test_flavor_model_name: "{{ (flavors_result.sbm_flavor_models | selectattr('name', 'match', '.*DL-01.*') | first).name }}" + test_flavor_model_id: "{{ (flavors_result.sbm_flavor_models | selectattr('name', 'match', '.*' ~ sbm_test_flavor_name ~ '.*') | first).id }}" + test_flavor_model_name: "{{ (flavors_result.sbm_flavor_models | selectattr('name', 'match', '.*' ~ sbm_test_flavor_name ~ '.*') | first).name }}" -- name: Get available OS for DL-01 in AMS7 +- name: Get available OS for flavor in location serverscom.sc_api.sc_sbm_os_list: flavor_id: "{{ test_flavor_model_id }}" location_id: "{{ test_location_id }}" environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" register: os_result - name: Assert OS list is not empty assert: that: - os_result.os_list | length > 0 - fail_msg: "No OS available for flavor {{ test_flavor_model_name }} in AMS7" + fail_msg: "No OS available for flavor {{ test_flavor_model_name }} in {{ sbm_test_location_code }}" - name: Set OS facts set_fact: @@ -68,12 +68,12 @@ location_code: "{{ test_location_code }}" flavor_name: "{{ test_flavor_model_name }}" hostname: "{{ test_hostname }}" - operating_system_regex: 'Debian 13' + operating_system_regex: "{{ sbm_test_os_regex }}" wait: 86400 update_interval: 60 environment: - SERVERSCOM_API_TOKEN: '{{ sc_token }}' - SERVERSCOM_API_URL: '{{ sc_endpoint }}' + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" register: created_server - name: Set server ID fact diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/test_inventory_discovery.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/test_inventory_discovery.yaml new file mode 100644 index 00000000..aae4950d --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/test_inventory_discovery.yaml @@ -0,0 +1,59 @@ +--- +# Verify that the sc_inventory plugin discovers this just-created SBM server. + +- name: Create temp dir for inventory config + tempfile: + state: directory + suffix: sbm_inv + register: inv_tmp + changed_when: false + +- name: Render sc_inventory config (kind=sbm) + copy: + content: | + plugin: serverscom.sc_api.sc_inventory + resources: + - kind: sbm + dest: "{{ inv_tmp.path }}/sbm.sc_api.yml" + mode: "0600" + +- name: Run ansible-inventory against the sc_inventory config + command: + argv: + - ansible-inventory + - -i + - "{{ inv_tmp.path }}/sbm.sc_api.yml" + - --list + environment: + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" + register: inv_run + changed_when: false + +- name: Stash inventory JSON to a file + copy: + content: "{{ inv_run.stdout }}" + dest: "{{ inv_tmp.path }}/inventory.json" + mode: "0600" + changed_when: false + +- name: Load inventory JSON + include_vars: + file: "{{ inv_tmp.path }}/inventory.json" + name: inv_json + +- name: Created SBM server is discovered by sc_inventory + assert: + that: + - test_hostname in inv_json._meta.hostvars + - inv_json._meta.hostvars[test_hostname].sc_kind == "sbm" + - inv_json._meta.hostvars[test_hostname].type == "sbm_server" + fail_msg: >- + Expected to find SBM host {{ test_hostname }} in sc_inventory output. + hostvars seen: {{ inv_json._meta.hostvars | list }} + +- name: Cleanup inventory temp dir + file: + path: "{{ inv_tmp.path }}" + state: absent + changed_when: false diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/test_reinstall.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/test_reinstall.yaml index ea8c2f79..702351af 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/test_reinstall.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_server_lifecycle/tasks/test_reinstall.yaml @@ -127,7 +127,7 @@ - name: Test7, Reinstall server to Ubuntu 24.04 using operating_system_name serverscom.sc_api.sc_sbm_server_reinstall: server_id: '{{ test_sbm_server_id }}' - operating_system_name: 'Ubuntu 24.04-server x86_64' + operating_system_name: "{{ sbm_test_reinstall_os_name }}" hostname: '{{ test_hostname }}' wait: 86400 update_interval: 60 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_servers_info/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_servers_info/tasks/main.yaml index e2d6cf0f..48ae01fa 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_servers_info/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sbm_servers_info/tasks/main.yaml @@ -82,7 +82,7 @@ - name: Test6, Verify location_id and location_code are mutually exclusive serverscom.sc_api.sc_sbm_servers_info: location_id: 1 - location_code: AMS7 + location_code: "{{ sbm_test_location_code }}" environment: SERVERSCOM_API_TOKEN: '{{ sc_token }}' SERVERSCOM_API_URL: '{{ sc_endpoint }}' diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/tasks/_run.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/tasks/_run.yaml new file mode 100644 index 00000000..8f172ef0 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/tasks/_run.yaml @@ -0,0 +1,51 @@ +--- +# Helper: render `inv_template` into the per-test tmp dir and run +# `ansible-inventory --list` against it. Captures the parsed JSON as +# `inv_json`, plus rc/stderr. +# +# Arguments (set by include_tasks): +# inv_template - basename of the .j2 template (e.g. "label_match.sc_api.yml.j2") +# inv_extra_env - optional dict of extra env vars for the subprocess +# inv_expect_fail - bool; if true, allow non-zero rc and skip JSON parse + +- name: "[{{ inv_template }}] render config" + template: + src: "{{ inv_template }}" + dest: "{{ inv_tmpdir }}/{{ inv_template | regex_replace('\\.j2$', '') }}" + mode: "0600" + +- name: "[{{ inv_template }}] run ansible-inventory" + command: + argv: + - ansible-inventory + - -i + - "{{ inv_tmpdir }}/{{ inv_template | regex_replace('\\.j2$', '') }}" + - --list + environment: "{{ {'SERVERSCOM_API_TOKEN': sc_token, 'SERVERSCOM_API_URL': sc_endpoint} | combine(inv_extra_env | default({})) }}" + register: inv_run + changed_when: false + failed_when: false + +- name: "[{{ inv_template }}] show stderr on failure" + debug: + var: inv_run + when: inv_run.rc != 0 and not (inv_expect_fail | default(false)) + +- name: "[{{ inv_template }}] fail if unexpected non-zero rc" + fail: + msg: "ansible-inventory exited rc={{ inv_run.rc }}: {{ inv_run.stderr }}" + when: inv_run.rc != 0 and not (inv_expect_fail | default(false)) + +- name: "[{{ inv_template }}] stash stdout to file" + copy: + content: "{{ inv_run.stdout }}" + dest: "{{ inv_tmpdir }}/last_inventory.json" + mode: "0600" + changed_when: false + when: inv_run.rc == 0 + +- name: "[{{ inv_template }}] parse stdout (via include_vars, avoids unsafe-tag issue)" + include_vars: + file: "{{ inv_tmpdir }}/last_inventory.json" + name: inv_json + when: inv_run.rc == 0 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/tasks/main.yaml new file mode 100644 index 00000000..8a703e8a --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/tasks/main.yaml @@ -0,0 +1,461 @@ +--- +- name: Check sc_token and sc_endpoint + no_log: true + fail: + msg: "Define sc_token and sc_endpoint in tests/integration/integration_config.yml" + when: not sc_endpoint or not sc_token + +- name: Create temp dir for rendered inventory configs + tempfile: + state: directory + suffix: sc_inventory + register: tmp + changed_when: false + +- name: Stash tmp dir path + set_fact: + inv_tmpdir: "{{ tmp.path }}" + +# ------------------------------------------------------------------ # +# Prep: read server1's title, location, and IPs once +# ------------------------------------------------------------------ # +- name: Fetch server1 metadata + sc_dedicated_server_info: + id: "{{ existing_server1_id }}" + environment: + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" + register: server1 + +- name: Fetch server2 metadata + sc_dedicated_server_info: + id: "{{ existing_server2_id }}" + environment: + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" + register: server2 + +- name: Capture shared facts + set_fact: + server1_title: "{{ server1.title }}" + server1_public_ipv4: "{{ server1.public_ipv4_address }}" + server2_title: "{{ server2.title }}" + shared_location_code: "{{ server1.location_code }}" + +# ------------------------------------------------------------------ # +# Prep: cloud — discover region/flavor/image so we can build a VM +# ------------------------------------------------------------------ # +- name: List cloud regions + sc_cloud_computing_regions_info: + environment: + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" + register: regions + changed_when: false + +- name: List flavors in the first region + sc_cloud_computing_flavors_info: + region_id: "{{ regions.regions[0].id }}" + environment: + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" + register: flavors + changed_when: false + +- name: List images in the first region + sc_cloud_computing_images_info: + region_id: "{{ regions.regions[0].id }}" + environment: + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" + register: images + changed_when: false + +- name: Pick the unique cloud VM name + set_fact: + cloud_vm_name: "sc-inv-test-{{ 99999999 | random | to_uuid }}" + cloud_region_id: "{{ regions.regions[0].id }}" + cloud_region_code: "{{ regions.regions[0].code }}" + cloud_flavor_id: "{{ (flavors.cloud_flavors | sort(attribute='disk'))[0].id }}" + cloud_image_id: "{{ (images.cloud_images | selectattr('allowed_flavors', 'equalto', []) | first).id }}" + +# ------------------------------------------------------------------ # +# Main test block — labels + cloud VM, always cleaned up +# ------------------------------------------------------------------ # +- block: + # --- Set labels on bare-metal servers --- + - name: Label server1 + uri: + url: "{{ sc_endpoint }}/hosts/dedicated_servers/{{ existing_server1_id }}" + method: PUT + headers: + Authorization: "Bearer {{ sc_token }}" + Content-Type: "application/json" + body: + labels: + inv_test_marker: "server1" + inv_test_mixed: "mixed" + body_format: json + status_code: 200 + + - name: Label server2 + uri: + url: "{{ sc_endpoint }}/hosts/dedicated_servers/{{ existing_server2_id }}" + method: PUT + headers: + Authorization: "Bearer {{ sc_token }}" + Content-Type: "application/json" + body: + labels: + inv_test_marker: "server2" + body_format: json + status_code: 200 + + # --- Create one cloud VM with our test marker label --- + - name: Create test cloud VM + sc_cloud_computing_instance: + state: present + region_id: "{{ cloud_region_id }}" + flavor_id: "{{ cloud_flavor_id }}" + image_id: "{{ cloud_image_id }}" + name: "{{ cloud_vm_name }}" + backup_copies: 0 + ipv6: true + wait: 600 + update_interval: 5 + labels: + inv_test_marker: "cloud" + inv_test_mixed: "mixed" + environment: + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" + register: cloud_vm + + # ---------------------------------------------------------------- # + # 1. Default — no `resources:` at all → all kinds; known BMs present + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: default_no_resources.sc_api.yml.j2 + + - name: "default: 3 known bare-metal servers present" + assert: + that: + - server1_title in inv_json._meta.hostvars + - server2_title in inv_json._meta.hostvars + - inv_json._meta.hostvars[server1_title].sc_kind == "baremetal" + + # ---------------------------------------------------------------- # + # 2. kind: baremetal — IDs of the 3 known servers appear + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: baremetal_basic.sc_api.yml.j2 + + - name: "kind=baremetal: known servers present, all sc_kind=baremetal" + assert: + that: + - server1_title in inv_json._meta.hostvars + - inv_json._meta.hostvars.values() + | map(attribute='sc_kind') | unique | list == ['baremetal'] + + # ---------------------------------------------------------------- # + # 3. Region filter — shared region returns all 3 BMs; bogus returns 0 + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: region_match.sc_api.yml.j2 + + - name: "region match: server1 present" + assert: + that: + - server1_title in inv_json._meta.hostvars + + - include_tasks: _run.yaml + vars: + inv_template: region_nomatch.sc_api.yml.j2 + + - name: "region nomatch: hostvars is empty" + assert: + that: + - inv_json._meta.hostvars | length == 0 + + # ---------------------------------------------------------------- # + # 4. name_regex — exact-anchored regex returns only server1 + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: name_regex.sc_api.yml.j2 + + - name: "name_regex: exactly server1 returned" + assert: + that: + - inv_json._meta.hostvars | list == [server1_title] + + # ---------------------------------------------------------------- # + # 5. Label filter — only the server we labeled `server1` + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: label_match.sc_api.yml.j2 + + - name: "labels: only server1 (the one with marker=server1)" + assert: + that: + - inv_json._meta.hostvars | list == [server1_title] + + # ---------------------------------------------------------------- # + # 6. Exclusion — server1 absent, server2 still present + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: exclude_label.sc_api.yml.j2 + + - name: "exclude: server1 absent, server2 present" + assert: + that: + - server1_title not in inv_json._meta.hostvars + - server2_title in inv_json._meta.hostvars + + # ---------------------------------------------------------------- # + # 7. ansible_host: public_ipv4 → matches server1's real public IP; + # normalized vars set + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: ansible_host_public.sc_api.yml.j2 + + - name: "ansible_host=public_ipv4 maps to real IP" + assert: + that: + - inv_json._meta.hostvars[server1_title].ansible_host == server1_public_ipv4 + - inv_json._meta.hostvars[server1_title].public_ip == server1_public_ipv4 + - inv_json._meta.hostvars[server1_title].sc_kind == "baremetal" + - inv_json._meta.hostvars[server1_title].additional_ip_addresses == [] + + # ---------------------------------------------------------------- # + # 8. assign_inventory_group — server1 lands in static group + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: group_static.sc_api.yml.j2 + + - name: "assign_inventory_group: server1 in inv_test_group" + assert: + that: + - "'inv_test_group' in inv_json" + - server1_title in inv_json.inv_test_group.hosts + + # ---------------------------------------------------------------- # + # 9. group_by: location_code — group named after the shared region + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: group_by_location.sc_api.yml.j2 + + - name: "group_by: location_code group exists and contains server1" + vars: + loc_group: "{{ shared_location_code | regex_replace('[^A-Za-z0-9_]', '_') }}" + assert: + that: + - loc_group in inv_json + - server1_title in inv_json[loc_group].hosts + + # ---------------------------------------------------------------- # + # 10. ${VAR} substitution — env var fills regions + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: env_subst.sc_api.yml.j2 + inv_extra_env: + INV_TEST_REGION: "{{ shared_location_code }}" + + - name: "env subst: server1 found via ${INV_TEST_REGION}" + assert: + that: + - server1_title in inv_json._meta.hostvars + + # ---------------------------------------------------------------- # + # 11. ${VAR} substitution — unset variable fails with named error + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: env_subst_missing.sc_api.yml.j2 + inv_expect_fail: true + + # ansible-inventory catches AnsibleParserError from plugins and prints it + # as a warning (rc stays 0, hostvars come back empty). The real signal that + # our plugin rejected the bad ${VAR} is the variable name showing up in + # stderr. + - name: "env subst missing: variable name reported in stderr" + assert: + that: + - "'INV_TEST_VAR_THAT_IS_NOT_SET' in inv_run.stderr" + + # ---------------------------------------------------------------- # + # 11b. Resource block without kind is rejected + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: missing_kind.sc_api.yml.j2 + inv_expect_fail: true + + - name: "missing kind: parser error in stderr" + assert: + that: + - "'must specify `kind`' in inv_run.stderr" + + # ---------------------------------------------------------------- # + # 11c. Unknown keys are rejected by the plugin's strict validator + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: unknown_top_key.sc_api.yml.j2 + inv_expect_fail: true + + - name: "unknown top-level key: name reported in stderr" + assert: + that: + - "'bogus_top_key' in inv_run.stderr" + + - include_tasks: _run.yaml + vars: + inv_template: unknown_sub_key.sc_api.yml.j2 + inv_expect_fail: true + + - name: "unknown resources[] sub-key: name reported in stderr" + assert: + that: + - "'bogus_sub_key' in inv_run.stderr" + + - include_tasks: _run.yaml + vars: + inv_template: unknown_exclude_key.sc_api.yml.j2 + inv_expect_fail: true + + - name: "unknown exclude-rule key: name reported in stderr" + assert: + that: + - "'bogus_rule_key' in inv_run.stderr" + + # ---------------------------------------------------------------- # + # 12. Cloud — VM appears with correct sc_kind and ansible_host + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: cloud_filter.sc_api.yml.j2 + + - name: "cloud: created VM present with cloud sc_kind" + assert: + that: + - cloud_vm_name in inv_json._meta.hostvars + - inv_json._meta.hostvars[cloud_vm_name].sc_kind == "cloud" + - inv_json._meta.hostvars[cloud_vm_name].ansible_host == cloud_vm.public_ipv4_address + - inv_json._meta.hostvars[cloud_vm_name].public_ip == cloud_vm.public_ipv4_address + - inv_json._meta.hostvars[cloud_vm_name].public_ipv6 == cloud_vm.public_ipv6_address + - inv_json._meta.hostvars[cloud_vm_name].oob_ip is none + - inv_json._meta.hostvars[cloud_vm_name].region_code == cloud_region_code + + # ---------------------------------------------------------------- # + # 13. Cloud — ansible_host: public_ipv6 picks the v6 address + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: cloud_ipv6_host.sc_api.yml.j2 + + - name: "cloud: ansible_host=public_ipv6 → matches v6 address" + assert: + that: + - inv_json._meta.hostvars[cloud_vm_name].ansible_host == cloud_vm.public_ipv6_address + + # ---------------------------------------------------------------- # + # 14. Mixed inventory: explicit baremetal + cloud blocks, distinct labels + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: all_kinds.sc_api.yml.j2 + + - name: "mixed inventory: bare-metal AND cloud present in one run" + assert: + that: + - server1_title in inv_json._meta.hostvars + - cloud_vm_name in inv_json._meta.hostvars + - inv_json._meta.hostvars[server1_title].sc_kind == "baremetal" + - inv_json._meta.hostvars[cloud_vm_name].sc_kind == "cloud" + + # ---------------------------------------------------------------- # + # 15. Two same-kind blocks pointed at the same group → group merges + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: same_group_combine.sc_api.yml.j2 + + - name: "same group from two baremetal blocks: contains both servers" + assert: + that: + - "'combined_group' in inv_json" + - server1_title in inv_json.combined_group.hosts + - server2_title in inv_json.combined_group.hosts + - inv_json.combined_group.hosts | length == 2 + + # ---------------------------------------------------------------- # + # 16. Cross-kind: bare-metal + cloud blocks pointed at the same group + # ---------------------------------------------------------------- # + - include_tasks: _run.yaml + vars: + inv_template: cross_kind_group.sc_api.yml.j2 + + - name: "same group across kinds: contains server1 AND cloud VM" + assert: + that: + - "'web_tier' in inv_json" + - server1_title in inv_json.web_tier.hosts + - cloud_vm_name in inv_json.web_tier.hosts + - inv_json.web_tier.hosts | length == 2 + - inv_json._meta.hostvars[server1_title].sc_kind == "baremetal" + - inv_json._meta.hostvars[cloud_vm_name].sc_kind == "cloud" + + always: + - name: Remove labels from server1 + uri: + url: "{{ sc_endpoint }}/hosts/dedicated_servers/{{ existing_server1_id }}" + method: PUT + headers: + Authorization: "Bearer {{ sc_token }}" + Content-Type: "application/json" + body: + labels: {} + body_format: json + status_code: 200 + ignore_errors: true + + - name: Remove labels from server2 + uri: + url: "{{ sc_endpoint }}/hosts/dedicated_servers/{{ existing_server2_id }}" + method: PUT + headers: + Authorization: "Bearer {{ sc_token }}" + Content-Type: "application/json" + body: + labels: {} + body_format: json + status_code: 200 + ignore_errors: true + + - name: Tear down test cloud VM + sc_cloud_computing_instance: + name: "{{ cloud_vm_name }}" + region_id: "{{ cloud_region_id }}" + state: absent + wait: 300 + update_interval: 5 + environment: + SERVERSCOM_API_TOKEN: "{{ sc_token }}" + SERVERSCOM_API_URL: "{{ sc_endpoint }}" + ignore_errors: true + + - name: Remove tmp dir + file: + path: "{{ inv_tmpdir }}" + state: absent + changed_when: false diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/all_kinds.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/all_kinds.sc_api.yml.j2 new file mode 100644 index 00000000..c1240083 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/all_kinds.sc_api.yml.j2 @@ -0,0 +1,8 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + labels: + inv_test_marker: "server1" + - kind: cloud + labels: + inv_test_marker: "cloud" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/ansible_host_public.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/ansible_host_public.sc_api.yml.j2 new file mode 100644 index 00000000..41a83cbb --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/ansible_host_public.sc_api.yml.j2 @@ -0,0 +1,6 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + labels: + inv_test_marker: "server1" + ansible_host: public_ipv4 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/baremetal_basic.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/baremetal_basic.sc_api.yml.j2 new file mode 100644 index 00000000..848dae2c --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/baremetal_basic.sc_api.yml.j2 @@ -0,0 +1,3 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/cloud_filter.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/cloud_filter.sc_api.yml.j2 new file mode 100644 index 00000000..99dcbe2e --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/cloud_filter.sc_api.yml.j2 @@ -0,0 +1,5 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: cloud + labels: + inv_test_marker: "cloud" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/cloud_ipv6_host.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/cloud_ipv6_host.sc_api.yml.j2 new file mode 100644 index 00000000..eeb383eb --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/cloud_ipv6_host.sc_api.yml.j2 @@ -0,0 +1,6 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: cloud + labels: + inv_test_marker: "cloud" + ansible_host: public_ipv6 diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/cross_kind_group.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/cross_kind_group.sc_api.yml.j2 new file mode 100644 index 00000000..c8682a05 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/cross_kind_group.sc_api.yml.j2 @@ -0,0 +1,9 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + name_regex: "^{{ server1_title | regex_escape }}$" + assign_inventory_group: web_tier + - kind: cloud + labels: + inv_test_marker: "cloud" + assign_inventory_group: web_tier diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/default_no_resources.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/default_no_resources.sc_api.yml.j2 new file mode 100644 index 00000000..b6905174 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/default_no_resources.sc_api.yml.j2 @@ -0,0 +1 @@ +plugin: serverscom.sc_api.sc_inventory diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/env_subst.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/env_subst.sc_api.yml.j2 new file mode 100644 index 00000000..43ce1b40 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/env_subst.sc_api.yml.j2 @@ -0,0 +1,5 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: + - ${INV_TEST_REGION} diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/env_subst_missing.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/env_subst_missing.sc_api.yml.j2 new file mode 100644 index 00000000..56441777 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/env_subst_missing.sc_api.yml.j2 @@ -0,0 +1,5 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: + - ${INV_TEST_VAR_THAT_IS_NOT_SET} diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/exclude_label.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/exclude_label.sc_api.yml.j2 new file mode 100644 index 00000000..0ac959b5 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/exclude_label.sc_api.yml.j2 @@ -0,0 +1,6 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + exclude: + - labels: + inv_test_marker: "server1" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/group_by_location.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/group_by_location.sc_api.yml.j2 new file mode 100644 index 00000000..98d4e062 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/group_by_location.sc_api.yml.j2 @@ -0,0 +1,5 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: ["{{ shared_location_code }}"] + group_by: location_code diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/group_static.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/group_static.sc_api.yml.j2 new file mode 100644 index 00000000..2e748af1 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/group_static.sc_api.yml.j2 @@ -0,0 +1,6 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + labels: + inv_test_marker: "server1" + assign_inventory_group: inv_test_group diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/label_match.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/label_match.sc_api.yml.j2 new file mode 100644 index 00000000..592c37d6 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/label_match.sc_api.yml.j2 @@ -0,0 +1,5 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + labels: + inv_test_marker: "server1" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/missing_kind.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/missing_kind.sc_api.yml.j2 new file mode 100644 index 00000000..ecc370ac --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/missing_kind.sc_api.yml.j2 @@ -0,0 +1,3 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - regions: ["{{ shared_location_code }}"] diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/name_regex.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/name_regex.sc_api.yml.j2 new file mode 100644 index 00000000..648cccc7 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/name_regex.sc_api.yml.j2 @@ -0,0 +1,4 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + name_regex: "^{{ server1_title | regex_escape }}$" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/region_match.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/region_match.sc_api.yml.j2 new file mode 100644 index 00000000..64198e7b --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/region_match.sc_api.yml.j2 @@ -0,0 +1,4 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: ["{{ shared_location_code }}"] diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/region_nomatch.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/region_nomatch.sc_api.yml.j2 new file mode 100644 index 00000000..4a90c099 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/region_nomatch.sc_api.yml.j2 @@ -0,0 +1,4 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: ["ZZZ_NONEXISTENT_REGION"] diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/same_group_combine.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/same_group_combine.sc_api.yml.j2 new file mode 100644 index 00000000..d0b7d597 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/same_group_combine.sc_api.yml.j2 @@ -0,0 +1,8 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + name_regex: "^{{ server1_title | regex_escape }}$" + assign_inventory_group: combined_group + - kind: baremetal + name_regex: "^{{ server2_title | regex_escape }}$" + assign_inventory_group: combined_group diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/unknown_exclude_key.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/unknown_exclude_key.sc_api.yml.j2 new file mode 100644 index 00000000..aebdded5 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/unknown_exclude_key.sc_api.yml.j2 @@ -0,0 +1,6 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + exclude: + - regions: [AMS1] + bogus_rule_key: "this exclude-rule key does not exist" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/unknown_sub_key.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/unknown_sub_key.sc_api.yml.j2 new file mode 100644 index 00000000..c831ca42 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/unknown_sub_key.sc_api.yml.j2 @@ -0,0 +1,4 @@ +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + bogus_sub_key: "this suboption does not exist" diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/unknown_top_key.sc_api.yml.j2 b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/unknown_top_key.sc_api.yml.j2 new file mode 100644 index 00000000..21fa4272 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_inventory/templates/unknown_top_key.sc_api.yml.j2 @@ -0,0 +1,4 @@ +plugin: serverscom.sc_api.sc_inventory +bogus_top_key: "this option does not exist" +resources: + - kind: baremetal diff --git a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_no_token_tests/tasks/main.yaml b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_no_token_tests/tasks/main.yaml index aa6e7329..6efa2d38 100644 --- a/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_no_token_tests/tasks/main.yaml +++ b/ansible_collections/serverscom/sc_api/tests/integration/targets/sc_no_token_tests/tasks/main.yaml @@ -215,6 +215,26 @@ - result.status_code == 401 - "'token' in result.msg" +- name: dedicated_server_ipxe + serverscom.sc_api.dedicated_server_ipxe: + token: dummy + endpoint: "https://api.servers.com/v1" + server_id: "test123" + state: public + ipxe_config: | + #!ipxe + chain http://boot.example.com/menu.ipxe + wait: 0 + register: result + ignore_errors: true + +- name: Verify dedicated_server_ipxe + assert: + that: + - result is failed + - result.status_code == 401 + - "'token' in result.msg" + - name: sc_dedicated_server_reinstall serverscom.sc_api.dedicated_server_reinstall: token: dummy diff --git a/ansible_collections/serverscom/sc_api/tests/unit/plugins/inventory/__init__.py b/ansible_collections/serverscom/sc_api/tests/unit/plugins/inventory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ansible_collections/serverscom/sc_api/tests/unit/plugins/inventory/test_sc_inventory.py b/ansible_collections/serverscom/sc_api/tests/unit/plugins/inventory/test_sc_inventory.py new file mode 100644 index 00000000..47b4c61a --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/unit/plugins/inventory/test_sc_inventory.py @@ -0,0 +1,786 @@ +# Copyright (c) 2026 Servers.com +# 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 pytest +import mock + +from ansible.errors import AnsibleError, AnsibleParserError + +from ansible_collections.serverscom.sc_api.plugins.inventory.sc_inventory import ( + InventoryModule, +) + + +# ------------------------------------------------------------------- # +# Fixtures +# ------------------------------------------------------------------- # + + +def _make_baremetal( + id_="bm1", + title="bm-host-1", + location_code="AMS1", + status="active", + public="1.1.1.1", + private="10.0.0.1", + oob="192.168.0.1", + labels=None, + type_="dedicated_server", +): + return { + "id": id_, + "title": title, + "type": type_, + "status": status, + "location_code": location_code, + "public_ipv4_address": public, + "private_ipv4_address": private, + "oob_ipv4_address": oob, + "labels": labels or {}, + } + + +def _make_cloud( + id_="c1", + name="cloud-host-1", + region_code="ams1", + status="ACTIVE", + public="2.2.2.2", + private="10.1.0.1", + public6="2001:db8::1", + local="172.16.0.1", + labels=None, +): + return { + "id": id_, + "name": name, + "status": status, + "region_code": region_code, + "public_ipv4_address": public, + "private_ipv4_address": private, + "public_ipv6_address": public6, + "local_ipv4_address": local, + "labels": labels or {}, + } + + +@pytest.fixture +def plugin(): + p = InventoryModule() + p.inventory = mock.MagicMock() + return p + + +# ------------------------------------------------------------------- # +# verify_file +# ------------------------------------------------------------------- # + + +_OUR_PLUGIN_HEADER = "plugin: serverscom.sc_api.sc_inventory\n" + + +@pytest.mark.parametrize( + "suffix,expected", + [ + (".sc_api.yml", True), + (".sc_api.yaml", True), + (".sc_inventory.yml", True), + (".sc_inventory.yaml", True), + (".yml", False), + (".aws_ec2.yml", False), + ], +) +def test_verify_file_suffix(plugin, tmp_path, suffix, expected): + f = tmp_path / ("x" + suffix) + f.write_text(_OUR_PLUGIN_HEADER) + with mock.patch.object( + InventoryModule.__bases__[0], "verify_file", return_value=True + ): + assert plugin.verify_file(str(f)) is expected + + +def test_verify_file_super_false(plugin, tmp_path): + f = tmp_path / "x.sc_api.yml" + f.write_text(_OUR_PLUGIN_HEADER) + with mock.patch.object( + InventoryModule.__bases__[0], "verify_file", return_value=False + ): + assert plugin.verify_file(str(f)) is False + + +def test_verify_file_wrong_plugin_key(plugin, tmp_path): + """Suffix matches but `plugin:` key names a different plugin -> reject.""" + f = tmp_path / "foreign.sc_api.yml" + f.write_text("plugin: amazon.aws.aws_ec2\n") + with mock.patch.object( + InventoryModule.__bases__[0], "verify_file", return_value=True + ): + assert plugin.verify_file(str(f)) is False + + +def test_verify_file_missing_plugin_key(plugin, tmp_path): + """Suffix matches but no `plugin:` line at all -> reject.""" + f = tmp_path / "x.sc_api.yml" + f.write_text("hosts: []\n") + with mock.patch.object( + InventoryModule.__bases__[0], "verify_file", return_value=True + ): + assert plugin.verify_file(str(f)) is False + + +def test_verify_file_unreadable_path(plugin): + """Suffix matches but path is not openable -> reject (no crash).""" + with mock.patch.object( + InventoryModule.__bases__[0], "verify_file", return_value=True + ): + assert plugin.verify_file("/nonexistent/no/such/path.sc_api.yml") is False + + +# ------------------------------------------------------------------- # +# _substitute_env_vars +# ------------------------------------------------------------------- # + + +def test_subst_string(plugin, monkeypatch): + monkeypatch.setenv("FOO", "bar") + assert plugin._substitute_env_vars("x=${FOO}") == "x=bar" + + +def test_subst_list(plugin, monkeypatch): + monkeypatch.setenv("R", "AMS1") + assert plugin._substitute_env_vars(["${R}", "FRA1"]) == ["AMS1", "FRA1"] + + +def test_subst_dict(plugin, monkeypatch): + monkeypatch.setenv("E", "prod") + assert plugin._substitute_env_vars({"env": "${E}", "x": 1}) == { + "env": "prod", + "x": 1, + } + + +def test_subst_nested(plugin, monkeypatch): + monkeypatch.setenv("R", "AMS1") + monkeypatch.setenv("E", "prod") + result = plugin._substitute_env_vars( + {"regions": ["${R}"], "labels": {"env": "${E}"}} + ) + assert result == {"regions": ["AMS1"], "labels": {"env": "prod"}} + + +def test_subst_missing_raises(plugin, monkeypatch): + monkeypatch.delenv("MISSING_FOO", raising=False) + with pytest.raises(AnsibleParserError, match="MISSING_FOO"): + plugin._substitute_env_vars("${MISSING_FOO}") + + +def test_subst_passthrough_non_string(plugin): + assert plugin._substitute_env_vars(42) == 42 + assert plugin._substitute_env_vars(None) is None + assert plugin._substitute_env_vars(True) is True + + +# ------------------------------------------------------------------- # +# Token / endpoint resolution +# ------------------------------------------------------------------- # + + +def test_token_from_option(plugin, monkeypatch): + monkeypatch.delenv("SERVERSCOM_API_TOKEN", raising=False) + monkeypatch.delenv("SC_TOKEN", raising=False) + plugin.get_option = mock.MagicMock( + side_effect=lambda k: {"token": "T", "endpoint": "https://e"}[k] + ) + token, endpoint = plugin._resolve_token_endpoint() + assert token == "T" + assert endpoint == "https://e" + + +def test_token_from_servers_env(plugin, monkeypatch): + monkeypatch.setenv("SERVERSCOM_API_TOKEN", "ENV_TOKEN") + monkeypatch.delenv("SC_TOKEN", raising=False) + plugin.get_option = mock.MagicMock( + side_effect=lambda k: {"token": None, "endpoint": None}[k] + ) + monkeypatch.delenv("SERVERSCOM_API_URL", raising=False) + token, endpoint = plugin._resolve_token_endpoint() + assert token == "ENV_TOKEN" + assert endpoint == "https://api.servers.com/v1" + + +def test_token_from_sc_token_fallback(plugin, monkeypatch): + monkeypatch.delenv("SERVERSCOM_API_TOKEN", raising=False) + monkeypatch.setenv("SC_TOKEN", "OLD_TOKEN") + plugin.get_option = mock.MagicMock( + side_effect=lambda k: {"token": None, "endpoint": "https://e"}[k] + ) + token, _endpoint = plugin._resolve_token_endpoint() + assert token == "OLD_TOKEN" + + +def test_no_token_raises(plugin, monkeypatch): + monkeypatch.delenv("SERVERSCOM_API_TOKEN", raising=False) + monkeypatch.delenv("SC_TOKEN", raising=False) + plugin.get_option = mock.MagicMock( + side_effect=lambda k: {"token": None, "endpoint": None}[k] + ) + with pytest.raises(AnsibleError, match="No API token"): + plugin._resolve_token_endpoint() + + +def test_endpoint_env_fallback(plugin, monkeypatch): + monkeypatch.setenv("SERVERSCOM_API_URL", "https://staging") + plugin.get_option = mock.MagicMock( + side_effect=lambda k: {"token": "T", "endpoint": None}[k] + ) + _token, endpoint = plugin._resolve_token_endpoint() + assert endpoint == "https://staging" + + +# ------------------------------------------------------------------- # +# _list_for_kind dispatch +# ------------------------------------------------------------------- # + + +def test_list_kind_baremetal(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter([_make_baremetal()]) + result = list(plugin._list_for_kind(api, "baremetal")) + api.list_hosts.assert_called_once_with(type="dedicated_server") + assert len(result) == 1 + assert result[0][1] == "baremetal" + + +def test_list_kind_sbm(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter([_make_baremetal(type_="sbm_server")]) + result = list(plugin._list_for_kind(api, "sbm")) + api.list_hosts.assert_called_once_with(type="sbm_server") + assert result[0][1] == "sbm" + + +def test_list_kind_k8s(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter( + [_make_baremetal(type_="kubernetes_baremetal_node")] + ) + result = list(plugin._list_for_kind(api, "k8s_nodes")) + api.list_hosts.assert_called_once_with(type="kubernetes_baremetal_node") + assert result[0][1] == "k8s_nodes" + + +def test_list_kind_cloud(plugin): + api = mock.MagicMock() + api.list_instances.return_value = iter([_make_cloud()]) + result = list(plugin._list_for_kind(api, "cloud")) + api.list_instances.assert_called_once_with() + assert not api.list_hosts.called + assert result[0][1] == "cloud" + + +def test_list_kind_none_two_calls(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter( + [ + _make_baremetal(type_="dedicated_server"), + _make_baremetal(type_="sbm_server", id_="s1"), + _make_baremetal(type_="kubernetes_baremetal_node", id_="k1"), + ] + ) + api.list_instances.return_value = iter([_make_cloud()]) + result = list(plugin._list_for_kind(api, None)) + api.list_hosts.assert_called_once_with() + api.list_instances.assert_called_once_with() + kinds = [k for _server, k in result] + assert kinds == ["baremetal", "sbm", "k8s_nodes", "cloud"] + + +def test_list_kind_none_skips_unknown_type(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter( + [_make_baremetal(type_="some_future_type", id_="x1")] + ) + api.list_instances.return_value = iter([]) + result = list(plugin._list_for_kind(api, None)) + assert result == [] + + +def test_list_kind_invalid_raises(plugin): + api = mock.MagicMock() + with pytest.raises(AnsibleParserError, match="Unknown resource kind"): + list(plugin._list_for_kind(api, "vm")) + + +# ------------------------------------------------------------------- # +# Helpers +# ------------------------------------------------------------------- # + + +def test_hostname_baremetal(plugin): + assert plugin._hostname(_make_baremetal(), "baremetal") == "bm-host-1" + + +def test_hostname_cloud(plugin): + assert plugin._hostname(_make_cloud(), "cloud") == "cloud-host-1" + + +def test_hostname_fallback_to_id(plugin): + s = _make_baremetal() + s["title"] = "" + assert plugin._hostname(s, "baremetal") == "bm1" + + +def test_region_baremetal(plugin): + assert plugin._region(_make_baremetal(), "baremetal") == "AMS1" + + +def test_region_cloud(plugin): + assert plugin._region(_make_cloud(), "cloud") == "ams1" + + +# ------------------------------------------------------------------- # +# IP mapping +# ------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "ip_type,expected", + [ + ("public_ipv4", "1.1.1.1"), + ("private_ipv4", "10.0.0.1"), + ("oob_ipv4", "192.168.0.1"), + ("public_ipv6", None), + ("local_ipv4", None), + ], +) +def test_ip_baremetal(plugin, ip_type, expected): + assert plugin._ip(_make_baremetal(), "baremetal", ip_type) == expected + + +@pytest.mark.parametrize( + "ip_type,expected", + [ + ("public_ipv4", "2.2.2.2"), + ("private_ipv4", "10.1.0.1"), + ("public_ipv6", "2001:db8::1"), + ("local_ipv4", "172.16.0.1"), + ("oob_ipv4", None), + ], +) +def test_ip_cloud(plugin, ip_type, expected): + assert plugin._ip(_make_cloud(), "cloud", ip_type) == expected + + +def test_ip_oob_only_for_baremetal(plugin): + sbm = _make_baremetal(type_="sbm_server") + # sbm/k8s don't expose oob in our mapping + assert plugin._ip(sbm, "sbm", "oob_ipv4") is None + + +# ------------------------------------------------------------------- # +# Label match / exclusion +# ------------------------------------------------------------------- # + + +def test_matches_labels_empty_required(plugin): + assert plugin._matches_labels({"a": "b"}, {}) is True + + +def test_matches_labels_all_match(plugin): + assert plugin._matches_labels({"a": "b", "c": "d"}, {"a": "b"}) is True + + +def test_matches_labels_value_mismatch(plugin): + assert plugin._matches_labels({"a": "X"}, {"a": "b"}) is False + + +def test_matches_labels_missing_key(plugin): + assert plugin._matches_labels({}, {"a": "b"}) is False + + +def test_exclude_no_rules(plugin): + assert ( + plugin._is_excluded(_make_baremetal(), "baremetal", []) is False + ) + + +def test_exclude_by_region(plugin): + rules = [{"regions": ["AMS1"]}] + assert plugin._is_excluded(_make_baremetal(), "baremetal", rules) is True + + +def test_exclude_by_region_no_match(plugin): + rules = [{"regions": ["FRA1"]}] + assert plugin._is_excluded(_make_baremetal(), "baremetal", rules) is False + + +def test_exclude_by_label(plugin): + rules = [{"labels": {"env": "prod"}}] + s = _make_baremetal(labels={"env": "prod"}) + assert plugin._is_excluded(s, "baremetal", rules) is True + + +def test_exclude_region_and_label_both_required(plugin): + rules = [{"regions": ["AMS1"], "labels": {"env": "prod"}}] + s_match = _make_baremetal(labels={"env": "prod"}) + s_region_only = _make_baremetal(labels={"env": "dev"}) + assert plugin._is_excluded(s_match, "baremetal", rules) is True + assert plugin._is_excluded(s_region_only, "baremetal", rules) is False + + +def test_exclude_multiple_rules_or(plugin): + rules = [ + {"regions": ["AMS2"]}, + {"labels": {"decommissioned": "true"}}, + ] + s1 = _make_baremetal(location_code="AMS2") + s2 = _make_baremetal(labels={"decommissioned": "true"}) + s3 = _make_baremetal() + assert plugin._is_excluded(s1, "baremetal", rules) is True + assert plugin._is_excluded(s2, "baremetal", rules) is True + assert plugin._is_excluded(s3, "baremetal", rules) is False + + +# ------------------------------------------------------------------- # +# Group sanitization +# ------------------------------------------------------------------- # + + +def test_sanitize_group_simple(plugin): + assert plugin._sanitize_group("AMS1") == "AMS1" + + +def test_sanitize_group_dashes(plugin): + assert plugin._sanitize_group("ams-1") == "ams_1" + + +def test_sanitize_group_dots_and_spaces(plugin): + assert plugin._sanitize_group("a.b c") == "a_b_c" + + +# ------------------------------------------------------------------- # +# Grouping behaviour +# ------------------------------------------------------------------- # + + +def test_add_to_groups_assign(plugin): + plugin._add_to_groups("h1", _make_baremetal(), "myprod", None) + plugin.inventory.add_group.assert_called_once_with("myprod") + plugin.inventory.add_child.assert_called_once_with("myprod", "h1") + + +def test_add_to_groups_group_by(plugin): + plugin._add_to_groups("h1", _make_baremetal(), None, "location_code") + plugin.inventory.add_group.assert_called_once_with("AMS1") + plugin.inventory.add_child.assert_called_once_with("AMS1", "h1") + + +def test_add_to_groups_group_by_missing_attr(plugin): + plugin._add_to_groups("h1", _make_baremetal(), None, "nope") + assert not plugin.inventory.add_group.called + + +def test_add_to_groups_both_set_raises(plugin): + with pytest.raises(AnsibleParserError, match="mutually exclusive"): + plugin._add_to_groups("h1", _make_baremetal(), "g", "location_code") + + +def test_add_to_groups_neither_set(plugin): + plugin._add_to_groups("h1", _make_baremetal(), None, None) + assert not plugin.inventory.add_group.called + + +# ------------------------------------------------------------------- # +# Host var assignment +# ------------------------------------------------------------------- # + + +def test_set_host_vars_baremetal(plugin): + s = _make_baremetal() + plugin._set_host_vars("h1", s, "baremetal", "public_ipv4", {}) + calls = {c.args[1]: c.args[2] for c in plugin.inventory.set_variable.mock_calls} + assert calls["ansible_host"] == "1.1.1.1" + assert calls["public_ip"] == "1.1.1.1" + assert calls["private_ip"] == "10.0.0.1" + assert calls["oob_ip"] == "192.168.0.1" + assert calls["public_ipv6"] is None # baremetal + assert calls["local_ip"] is None + assert calls["additional_ip_addresses"] == [] + assert calls["sc_kind"] == "baremetal" + assert calls["title"] == "bm-host-1" + assert calls["status"] == "active" + + +def test_set_host_vars_cloud(plugin): + s = _make_cloud() + plugin._set_host_vars("h1", s, "cloud", "public_ipv6", {}) + calls = {c.args[1]: c.args[2] for c in plugin.inventory.set_variable.mock_calls} + assert calls["ansible_host"] == "2001:db8::1" + assert calls["public_ipv6"] == "2001:db8::1" + assert calls["local_ip"] == "172.16.0.1" + assert calls["oob_ip"] is None + assert calls["sc_kind"] == "cloud" + + +def test_set_host_vars_missing_ansible_host_ip(plugin): + s = _make_baremetal(public=None) + plugin._set_host_vars("h1", s, "baremetal", "public_ipv4", {}) + calls = {c.args[1]: c.args[2] for c in plugin.inventory.set_variable.mock_calls} + assert "ansible_host" not in calls + assert calls["public_ip"] is None + + +def test_set_host_vars_extra_vars_override_raw(plugin): + s = _make_baremetal() + plugin._set_host_vars( + "h1", s, "baremetal", "public_ipv4", {"status": "overridden"} + ) + # Last write wins; collect ordered (host, key, value) tuples + assignments = [ + (c.args[1], c.args[2]) for c in plugin.inventory.set_variable.mock_calls + ] + # Find every assignment to 'status'; the last one must be from extra_vars + status_assignments = [v for k, v in assignments if k == "status"] + assert status_assignments[-1] == "overridden" + + +# ------------------------------------------------------------------- # +# Full filter pipeline via _apply_resource +# ------------------------------------------------------------------- # + + +def test_apply_resource_region_filter(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter( + [ + _make_baremetal(id_="a", title="ha", location_code="AMS1"), + _make_baremetal(id_="b", title="hb", location_code="FRA1"), + ] + ) + plugin._apply_resource( + api, {"kind": "baremetal", "regions": ["AMS1"]} + ) + added = [c.args[0] for c in plugin.inventory.add_host.mock_calls] + assert added == ["ha"] + + +def test_apply_resource_name_regex(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter( + [ + _make_baremetal(id_="a", title="web-1"), + _make_baremetal(id_="b", title="db-1"), + ] + ) + plugin._apply_resource( + api, {"kind": "baremetal", "name_regex": "^web-"} + ) + added = [c.args[0] for c in plugin.inventory.add_host.mock_calls] + assert added == ["web-1"] + + +def test_apply_resource_labels_and(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter( + [ + _make_baremetal(id_="a", title="ha", labels={"env": "prod"}), + _make_baremetal( + id_="b", title="hb", labels={"env": "prod", "tier": "web"} + ), + _make_baremetal(id_="c", title="hc", labels={"env": "dev"}), + ] + ) + plugin._apply_resource( + api, + {"kind": "baremetal", "labels": {"env": "prod", "tier": "web"}}, + ) + added = [c.args[0] for c in plugin.inventory.add_host.mock_calls] + assert added == ["hb"] + + +def test_apply_resource_status_filter(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter( + [ + _make_baremetal(id_="a", title="ha", status="active"), + _make_baremetal(id_="b", title="hb", status="pending"), + ] + ) + plugin._apply_resource( + api, {"kind": "baremetal", "status_filter": ["active"]} + ) + added = [c.args[0] for c in plugin.inventory.add_host.mock_calls] + assert added == ["ha"] + + +def test_apply_resource_exclude(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter( + [ + _make_baremetal( + id_="a", title="ha", location_code="AMS1", + labels={"env": "production"}, + ), + _make_baremetal( + id_="b", title="hb", location_code="AMS1", + labels={"env": "dev"}, + ), + ] + ) + plugin._apply_resource( + api, + { + "kind": "baremetal", + "regions": ["AMS1"], + "exclude": [{"labels": {"env": "production"}}], + }, + ) + added = [c.args[0] for c in plugin.inventory.add_host.mock_calls] + assert added == ["hb"] + + +def test_apply_resource_invalid_ansible_host_raises(plugin): + api = mock.MagicMock() + with pytest.raises(AnsibleParserError, match="Invalid ansible_host"): + plugin._apply_resource( + api, {"kind": "baremetal", "ansible_host": "bogus"} + ) + + +def test_apply_resource_group_and_group_by_raises(plugin): + api = mock.MagicMock() + api.list_hosts.return_value = iter([]) + with pytest.raises(AnsibleParserError, match="mutually exclusive"): + plugin._apply_resource( + api, + { + "kind": "baremetal", + "assign_inventory_group": "g", + "group_by": "location_code", + }, + ) + + +# ------------------------------------------------------------------- # +# parse(): resources contract +# ------------------------------------------------------------------- # + + +def _stub_parse_deps(p, resources): + """Stub out parse() side-effects so we can drive validation only.""" + p._reject_unknown_keys = mock.MagicMock() + p._read_config_data = mock.MagicMock() + p._resolve_token_endpoint = mock.MagicMock(return_value=("T", "E")) + p._build_api = mock.MagicMock(return_value=mock.MagicMock()) + p._apply_resource = mock.MagicMock() + p.get_option = mock.MagicMock(return_value=resources) + # BaseInventoryPlugin.parse() needs inventory/loader/path; mock them. + with mock.patch.object( + InventoryModule.__bases__[0], "parse", return_value=None + ): + p.parse(mock.MagicMock(), mock.MagicMock(), "/tmp/foo.sc_api.yml") + + +def test_parse_no_resources_fetches_all(plugin): + _stub_parse_deps(plugin, None) + plugin._apply_resource.assert_called_once_with(mock.ANY, {}) + + +def test_parse_empty_resources_fetches_all(plugin): + _stub_parse_deps(plugin, []) + plugin._apply_resource.assert_called_once_with(mock.ANY, {}) + + +def test_parse_block_without_kind_raises(plugin): + plugin._reject_unknown_keys = mock.MagicMock() + plugin._read_config_data = mock.MagicMock() + plugin._resolve_token_endpoint = mock.MagicMock(return_value=("T", "E")) + plugin._build_api = mock.MagicMock(return_value=mock.MagicMock()) + plugin._apply_resource = mock.MagicMock() + plugin.get_option = mock.MagicMock(return_value=[{}]) + with mock.patch.object( + InventoryModule.__bases__[0], "parse", return_value=None + ): + with pytest.raises(AnsibleParserError, match="must specify `kind`"): + plugin.parse( + mock.MagicMock(), mock.MagicMock(), "/tmp/foo.sc_api.yml" + ) + + +def _fake_loader(yaml_obj): + """Build a MagicMock loader.load_from_file that returns the given dict.""" + loader = mock.MagicMock() + loader.load_from_file.return_value = yaml_obj + return loader + + +def test_reject_unknown_top_key(plugin): + loader = _fake_loader({"plugin": "x", "bogus_top": 1}) + with pytest.raises(AnsibleParserError, match="bogus_top"): + plugin._reject_unknown_keys(loader, "/tmp/x.sc_api.yml") + + +def test_reject_unknown_block_key(plugin): + loader = _fake_loader( + {"plugin": "x", "resources": [{"kind": "baremetal", "bogus_sub": "v"}]} + ) + with pytest.raises(AnsibleParserError, match="bogus_sub"): + plugin._reject_unknown_keys(loader, "/tmp/x.sc_api.yml") + + +def test_reject_unknown_exclude_rule_key(plugin): + loader = _fake_loader( + { + "plugin": "x", + "resources": [ + { + "kind": "baremetal", + "exclude": [{"regions": ["AMS1"], "bogus_rule": "x"}], + } + ], + } + ) + with pytest.raises(AnsibleParserError, match="bogus_rule"): + plugin._reject_unknown_keys(loader, "/tmp/x.sc_api.yml") + + +def test_accept_all_known_keys(plugin): + loader = _fake_loader( + { + "plugin": "serverscom.sc_api.sc_inventory", + "token": "T", + "endpoint": "E", + "resources": [ + { + "kind": "baremetal", + "regions": ["AMS1"], + "name_regex": "^x", + "labels": {"a": "b"}, + "status_filter": ["active"], + "exclude": [{"regions": ["AMS2"], "labels": {"k": "v"}}], + "ansible_host": "public_ipv4", + "assign_inventory_group": "g", + "extra_vars": {"x": 1}, + } + ], + } + ) + plugin._reject_unknown_keys(loader, "/tmp/x.sc_api.yml") # no raise + + +def test_reject_non_mapping_root(plugin): + loader = _fake_loader(["not", "a", "dict"]) + with pytest.raises(AnsibleParserError, match="YAML mapping"): + plugin._reject_unknown_keys(loader, "/tmp/x.sc_api.yml") + + +def test_parse_block_with_kind_runs(plugin): + _stub_parse_deps(plugin, [{"kind": "baremetal"}]) + plugin._apply_resource.assert_called_once_with( + mock.ANY, {"kind": "baremetal"} + ) diff --git a/ansible_collections/serverscom/sc_api/tests/unit/plugins/modules/test_api.py b/ansible_collections/serverscom/sc_api/tests/unit/plugins/modules/test_api.py index 022056cb..1c2cac0c 100644 --- a/ansible_collections/serverscom/sc_api/tests/unit/plugins/modules/test_api.py +++ b/ansible_collections/serverscom/sc_api/tests/unit/plugins/modules/test_api.py @@ -204,6 +204,118 @@ def json(self): assert "decode-456" in exc_info.value.msg +@pytest.fixture +def sc_api_obj(): + return sc_api.ScApi(token="token", endpoint="http://api") + + +class TestGetDedicatedServerFeatures: + def test_returns_features_list(self, sc_api_obj, clock): + features = [ + {"name": "public_ipxe_boot", "status": "deactivated"}, + {"name": "private_ipxe_boot", "status": "activated"}, + ] + sequencer = SendSequencer([FakeResponse(200, {}, json_data=features)]) + sc_api_obj.api_helper.session.send = sequencer + + result = sc_api_obj.get_dedicated_server_features("srv123") + + assert result == features + assert sequencer.calls == 1 + + def test_passes_retry_rules(self, sc_api_obj, clock): + features = [{"name": "public_ipxe_boot", "status": "activated"}] + sequencer = SendSequencer([ + FakeResponse(429, {}), + FakeResponse(200, {}, json_data=features), + ]) + sc_api_obj.api_helper.session.send = sequencer + retry_rules = {"codes": {429}, "delay": 1, "max_wait": 10} + + result = sc_api_obj.get_dedicated_server_features("srv123", retry_rules=retry_rules) + + assert result == features + assert sequencer.calls == 2 + + def test_401_raises_error(self, sc_api_obj, clock): + sequencer = SendSequencer([FakeResponse(401, {})]) + sc_api_obj.api_helper.session.send = sequencer + + with pytest.raises(sc_api.APIError401): + sc_api_obj.get_dedicated_server_features("srv123") + + +class TestPostDedicatedServerFeatureActivate: + def test_activate_without_body(self, sc_api_obj, clock): + feature = {"name": "public_ipxe_boot", "status": "activation"} + sequencer = SendSequencer([FakeResponse(202, {}, json_data=feature)]) + sc_api_obj.api_helper.session.send = sequencer + + result = sc_api_obj.post_dedicated_server_feature_activate("srv123", "public_ipxe_boot") + + assert result == feature + assert sequencer.calls == 1 + + def test_activate_with_body(self, sc_api_obj, clock): + feature = {"name": "public_ipxe_boot", "status": "activation"} + sequencer = SendSequencer([FakeResponse(202, {}, json_data=feature)]) + sc_api_obj.api_helper.session.send = sequencer + + result = sc_api_obj.post_dedicated_server_feature_activate( + "srv123", "public_ipxe_boot", body={"ipxe_config": "#!ipxe\nchain http://example.com"} + ) + + assert result == feature + + def test_401_raises_error(self, sc_api_obj, clock): + sequencer = SendSequencer([FakeResponse(401, {})]) + sc_api_obj.api_helper.session.send = sequencer + + with pytest.raises(sc_api.APIError401): + sc_api_obj.post_dedicated_server_feature_activate("srv123", "public_ipxe_boot") + + +class TestPostDedicatedServerFeatureDeactivate: + def test_deactivate(self, sc_api_obj, clock): + feature = {"name": "public_ipxe_boot", "status": "deactivation"} + sequencer = SendSequencer([FakeResponse(202, {}, json_data=feature)]) + sc_api_obj.api_helper.session.send = sequencer + + result = sc_api_obj.post_dedicated_server_feature_deactivate("srv123", "public_ipxe_boot") + + assert result == feature + assert sequencer.calls == 1 + + def test_401_raises_error(self, sc_api_obj, clock): + sequencer = SendSequencer([FakeResponse(401, {})]) + sc_api_obj.api_helper.session.send = sequencer + + with pytest.raises(sc_api.APIError401): + sc_api_obj.post_dedicated_server_feature_deactivate("srv123", "public_ipxe_boot") + + +class TestPutDedicatedServer: + def test_update_ipxe_config(self, sc_api_obj, clock): + server = {"id": "srv123", "ipxe_config": "#!ipxe\nnew"} + sequencer = SendSequencer([FakeResponse(200, {}, json_data=server)]) + sc_api_obj.api_helper.session.send = sequencer + + status_code, result = sc_api_obj.put_dedicated_server( + "srv123", {"ipxe_config": "#!ipxe\nnew"} + ) + + assert status_code == 200 + assert result == server + assert sequencer.calls == 1 + + def test_401_raises_error(self, sc_api_obj, clock): + sequencer = SendSequencer([FakeResponse(401, {})]) + sc_api_obj.api_helper.session.send = sequencer + + with pytest.raises(sc_api.APIError401): + sc_api_obj.put_dedicated_server("srv123", {"ipxe_config": "x"}) + + def test_correlation_id_all_error_codes(api_helper, clock): headers = {"X-Correlation-ID": "test-corr"} error_map = { diff --git a/ansible_collections/serverscom/sc_api/tests/unit/plugins/modules/test_dedicated_server_ipxe.py b/ansible_collections/serverscom/sc_api/tests/unit/plugins/modules/test_dedicated_server_ipxe.py new file mode 100644 index 00000000..3a1f7566 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/unit/plugins/modules/test_dedicated_server_ipxe.py @@ -0,0 +1,721 @@ +# Copyright (c) 2026 Servers.com +# 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 +import pytest +import mock +from ansible_collections.serverscom.sc_api.plugins.module_utils.dedicated_server import ( + ScDedicatedServerIpxe, +) +from ansible_collections.serverscom.sc_api.plugins.module_utils.modules import ( + WaitError, +) + + +__metaclass__ = type + +ENDPOINT = "https://api.servers.com/v1" +TOKEN = "test-token" +SERVER_ID = "srv123" + + +def _make_feature(name, status, ipxe_config=None): + f = {"name": name, "status": status} + if ipxe_config is not None: + f["ipxe_config"] = ipxe_config + return f + + +def _make_handler(state="public", ipxe_config=None, + wait=0, update_interval=10, checkmode=False): + return ScDedicatedServerIpxe( + endpoint=ENDPOINT, + token=TOKEN, + server_id=SERVER_ID, + state=state, + ipxe_config=ipxe_config, + wait=wait, + update_interval=update_interval, + checkmode=checkmode, + ) + + +class TestActivate: + def test_activate_public_ipxe(self): + handler = _make_handler( + state="public", + ipxe_config="#!ipxe\nchain http://boot.example.com", + ) + features = [ + _make_feature("public_ipxe_boot", "deactivated"), + _make_feature("private_ipxe_boot", "deactivated"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + handler.api.post_dedicated_server_feature_activate.return_value = ( + _make_feature("public_ipxe_boot", "activation") + ) + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_activate.assert_called_once_with( + SERVER_ID, "public_ipxe_boot", + body={"ipxe_config": "#!ipxe\nchain http://boot.example.com"}, + ) + + def test_activate_private_ipxe(self): + handler = _make_handler( + state="private", + ipxe_config="#!ipxe\nchain http://boot.example.com", + ) + features = [ + _make_feature("public_ipxe_boot", "deactivated"), + _make_feature("private_ipxe_boot", "deactivated"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + handler.api.post_dedicated_server_feature_activate.return_value = ( + _make_feature("private_ipxe_boot", "activation") + ) + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_activate.assert_called_once_with( + SERVER_ID, "private_ipxe_boot", + body={"ipxe_config": "#!ipxe\nchain http://boot.example.com"}, + ) + + def test_activate_with_ipxe_config(self): + handler = _make_handler( + state="public", + ipxe_config="#!ipxe\nchain http://boot.example.com" + ) + features = [_make_feature("public_ipxe_boot", "deactivated")] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_activate.assert_called_once_with( + SERVER_ID, "public_ipxe_boot", + body={"ipxe_config": "#!ipxe\nchain http://boot.example.com"}, + ) + + def test_activate_already_active_same_config(self): + handler = _make_handler( + state="public", + ipxe_config="existing-config", + ) + features = [ + _make_feature("public_ipxe_boot", "activated", ipxe_config="existing-config") + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + handler.api.get_dedicated_servers.return_value = {"ipxe_config": "existing-config"} + + result = handler.run() + + assert result["changed"] is False + handler.api.put_dedicated_server.assert_not_called() + + def test_activate_already_active_with_config_update(self): + handler = _make_handler( + state="public", + ipxe_config="#!ipxe\nnew-config" + ) + features = [ + _make_feature("public_ipxe_boot", "activated", ipxe_config="old-config") + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + handler.api.get_dedicated_servers.return_value = {"ipxe_config": "old-config"} + + result = handler.run() + + assert result["changed"] is True + handler.api.put_dedicated_server.assert_called_once_with( + SERVER_ID, {"ipxe_config": "#!ipxe\nnew-config"} + ) + handler.api.post_dedicated_server_feature_activate.assert_not_called() + + def test_activate_already_active_config_unchanged(self): + handler = _make_handler( + state="public", + ipxe_config="same-config" + ) + features = [ + _make_feature("public_ipxe_boot", "activated", ipxe_config="same-config") + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + handler.api.get_dedicated_servers.return_value = {"ipxe_config": "same-config"} + + result = handler.run() + + assert result["changed"] is False + handler.api.put_dedicated_server.assert_not_called() + + def test_activate_from_incompatible(self): + handler = _make_handler( + state="public", + ipxe_config="#!ipxe\nchain http://boot.example.com", + ) + features = [_make_feature("public_ipxe_boot", "incompatible")] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_activate.assert_called_once() + + def test_activate_from_unavailable(self): + handler = _make_handler( + state="public", + ipxe_config="#!ipxe\nchain http://boot.example.com", + ) + features = [_make_feature("public_ipxe_boot", "unavailable")] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_activate.assert_called_once() + + def test_activate_already_in_activation(self): + handler = _make_handler( + state="public", + ipxe_config="#!ipxe\nchain http://boot.example.com", + wait=0, + ) + features = [_make_feature("public_ipxe_boot", "activation")] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is False + handler.api.post_dedicated_server_feature_activate.assert_not_called() + + +class TestDeactivate: + def test_deactivate_public_active(self): + handler = _make_handler(state="absent") + features = [ + _make_feature("public_ipxe_boot", "activated"), + _make_feature("private_ipxe_boot", "deactivated"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_called_once_with( + SERVER_ID, "public_ipxe_boot" + ) + + def test_deactivate_private_active(self): + handler = _make_handler(state="absent") + features = [ + _make_feature("public_ipxe_boot", "deactivated"), + _make_feature("private_ipxe_boot", "activated"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_called_once_with( + SERVER_ID, "private_ipxe_boot" + ) + + def test_deactivate_both_already_inactive(self): + handler = _make_handler(state="absent") + features = [ + _make_feature("public_ipxe_boot", "deactivated"), + _make_feature("private_ipxe_boot", "deactivated"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is False + handler.api.post_dedicated_server_feature_deactivate.assert_not_called() + + def test_deactivate_already_in_deactivation(self): + handler = _make_handler(state="absent", wait=0) + features = [ + _make_feature("public_ipxe_boot", "deactivation"), + _make_feature("private_ipxe_boot", "deactivated"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is False + handler.api.post_dedicated_server_feature_deactivate.assert_not_called() + + def test_deactivate_incompatible(self): + handler = _make_handler(state="absent") + features = [ + _make_feature("public_ipxe_boot", "incompatible"), + _make_feature("private_ipxe_boot", "incompatible"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is False + + def test_deactivate_unavailable(self): + handler = _make_handler(state="absent") + features = [ + _make_feature("public_ipxe_boot", "unavailable"), + _make_feature("private_ipxe_boot", "unavailable"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is False + + def test_deactivate_feature_in_activation(self): + """If a feature is being activated, absent should wait then deactivate.""" + handler = _make_handler(state="absent", wait=0) + features = [ + _make_feature("public_ipxe_boot", "activation"), + _make_feature("private_ipxe_boot", "deactivated"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_called_once_with( + SERVER_ID, "public_ipxe_boot" + ) + + +class TestCheckMode: + def test_activate_checkmode(self): + handler = _make_handler( + state="public", checkmode=True, + ipxe_config="#!ipxe\nchain http://boot.example.com", + ) + features = [_make_feature("public_ipxe_boot", "deactivated")] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_activate.assert_not_called() + + def test_activate_checkmode_already_active_same_config(self): + """In check mode with matching ipxe_config, changed=False.""" + handler = _make_handler( + state="public", checkmode=True, + ipxe_config="existing-config", + ) + features = [ + _make_feature("public_ipxe_boot", "activated", ipxe_config="existing-config") + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + handler.api.get_dedicated_servers.return_value = {"ipxe_config": "existing-config"} + + result = handler.run() + + assert result["changed"] is False + handler.api.put_dedicated_server.assert_not_called() + + def test_activate_checkmode_with_different_config(self): + handler = _make_handler( + state="public", checkmode=True, ipxe_config="new" + ) + features = [_make_feature("public_ipxe_boot", "activated")] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + handler.api.get_dedicated_servers.return_value = {"ipxe_config": "old"} + + result = handler.run() + + assert result["changed"] is True + handler.api.put_dedicated_server.assert_not_called() + + def test_deactivate_checkmode(self): + handler = _make_handler(state="absent", checkmode=True) + features = [ + _make_feature("public_ipxe_boot", "activated"), + _make_feature("private_ipxe_boot", "deactivated"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_not_called() + + def test_deactivate_checkmode_already_inactive(self): + handler = _make_handler(state="absent", checkmode=True) + features = [ + _make_feature("public_ipxe_boot", "deactivated"), + _make_feature("private_ipxe_boot", "deactivated"), + ] + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = features + + result = handler.run() + + assert result["changed"] is False + + +class TestModeSwitch: + def test_switch_public_to_private(self): + handler = _make_handler( + state="private", + ipxe_config="#!ipxe\nchain http://boot.example.com", + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.side_effect = [ + # _get_feature_status: private deactivated + [ + _make_feature("public_ipxe_boot", "activated"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + # _get_opposite_feature_status: public activated + [ + _make_feature("public_ipxe_boot", "activated"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + ] + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_called_once_with( + SERVER_ID, "public_ipxe_boot" + ) + handler.api.post_dedicated_server_feature_activate.assert_called_once_with( + SERVER_ID, "private_ipxe_boot", + body={"ipxe_config": "#!ipxe\nchain http://boot.example.com"}, + ) + + def test_switch_private_to_public(self): + handler = _make_handler( + state="public", + ipxe_config="#!ipxe\nchain http://boot.example.com", + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.side_effect = [ + # _get_feature_status: public deactivated + [ + _make_feature("public_ipxe_boot", "deactivated"), + _make_feature("private_ipxe_boot", "activated"), + ], + # _get_opposite_feature_status: private activated + [ + _make_feature("public_ipxe_boot", "deactivated"), + _make_feature("private_ipxe_boot", "activated"), + ], + ] + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_called_once_with( + SERVER_ID, "private_ipxe_boot" + ) + handler.api.post_dedicated_server_feature_activate.assert_called_once_with( + SERVER_ID, "public_ipxe_boot", + body={"ipxe_config": "#!ipxe\nchain http://boot.example.com"}, + ) + + @mock.patch("ansible_collections.serverscom.sc_api.plugins.module_utils.dedicated_server.time") + def test_switch_opposite_in_deactivation(self, mock_time): + mock_time.time.side_effect = [ + # wait_for_status("deactivated") for opposite + 0, 0, 5, 5, 10, + # wait_for_status("activated") for private + 10, 10, 15, + ] + mock_time.sleep = mock.MagicMock() + + handler = _make_handler( + state="private", + ipxe_config="#!ipxe\ntest", wait=60, update_interval=5, + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.side_effect = [ + # _get_feature_status: private deactivated + [ + _make_feature("public_ipxe_boot", "deactivation"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + # _get_opposite_feature_status: public in deactivation + [ + _make_feature("public_ipxe_boot", "deactivation"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + # wait_for_status poll for public: still deactivating + [ + _make_feature("public_ipxe_boot", "deactivation"), + ], + # wait_for_status poll for public: done + [ + _make_feature("public_ipxe_boot", "deactivated"), + ], + # wait_for_status poll for private: activated + [ + _make_feature("private_ipxe_boot", "activated"), + ], + ] + + result = handler.run() + + assert result["changed"] is True + assert result["feature"]["status"] == "activated" + handler.api.post_dedicated_server_feature_deactivate.assert_not_called() + handler.api.post_dedicated_server_feature_activate.assert_called_once_with( + SERVER_ID, "private_ipxe_boot", body={"ipxe_config": "#!ipxe\ntest"}, + ) + + def test_switch_opposite_in_deactivation_no_wait(self): + handler = _make_handler( + state="private", + ipxe_config="#!ipxe\ntest", wait=0, + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.side_effect = [ + [ + _make_feature("public_ipxe_boot", "deactivation"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + # _get_feature_status re-fetch + [ + _make_feature("public_ipxe_boot", "deactivation"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + ] + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_not_called() + handler.api.post_dedicated_server_feature_activate.assert_called_once() + + def test_switch_with_wait_zero(self): + handler = _make_handler( + state="private", + ipxe_config="#!ipxe\ntest", wait=0, + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.side_effect = [ + # _get_feature_status: private deactivated + [ + _make_feature("public_ipxe_boot", "activated"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + # _get_opposite_feature_status: public activated + [ + _make_feature("public_ipxe_boot", "activated"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + ] + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_called_once_with( + SERVER_ID, "public_ipxe_boot" + ) + handler.api.post_dedicated_server_feature_activate.assert_called_once() + + def test_switch_checkmode(self): + handler = _make_handler( + state="private", + ipxe_config="#!ipxe\ntest", checkmode=True, + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = [ + _make_feature("public_ipxe_boot", "activated"), + _make_feature("private_ipxe_boot", "deactivated"), + ] + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_not_called() + handler.api.post_dedicated_server_feature_activate.assert_not_called() + + @mock.patch("ansible_collections.serverscom.sc_api.plugins.module_utils.dedicated_server.time") + def test_switch_with_wait_full_cycle(self, mock_time): + mock_time.time.side_effect = [ + # _deactivate_opposite wait_for_status: start, elapsed, sleep, elapsed + 0, 0, 5, 5, 10, + # _ensure_present wait_for_status: start, elapsed, sleep, elapsed + 10, 10, 15, 15, 20, + ] + mock_time.sleep = mock.MagicMock() + + handler = _make_handler( + state="private", + ipxe_config="#!ipxe\ntest", wait=60, update_interval=5, + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.side_effect = [ + # _get_feature_status: private deactivated + [ + _make_feature("public_ipxe_boot", "activated"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + # _get_opposite_feature_status: public activated + [ + _make_feature("public_ipxe_boot", "activated"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + # wait for public deactivation: still deactivating + [ + _make_feature("public_ipxe_boot", "deactivation"), + ], + # wait for public deactivation: done + [ + _make_feature("public_ipxe_boot", "deactivated"), + ], + # wait for private activation: still activating + [ + _make_feature("private_ipxe_boot", "activation"), + ], + # wait for private activation: done + [ + _make_feature("private_ipxe_boot", "activated"), + ], + ] + + result = handler.run() + + assert result["changed"] is True + assert result["feature"]["status"] == "activated" + handler.api.post_dedicated_server_feature_deactivate.assert_called_once_with( + SERVER_ID, "public_ipxe_boot" + ) + handler.api.post_dedicated_server_feature_activate.assert_called_once_with( + SERVER_ID, "private_ipxe_boot", body={"ipxe_config": "#!ipxe\ntest"}, + ) + + def test_no_switch_when_opposite_deactivated(self): + handler = _make_handler( + state="private", + ipxe_config="#!ipxe\ntest", + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = [ + _make_feature("public_ipxe_boot", "deactivated"), + _make_feature("private_ipxe_boot", "deactivated"), + ] + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_not_called() + handler.api.post_dedicated_server_feature_activate.assert_called_once() + + def test_no_switch_when_opposite_not_in_features(self): + handler = _make_handler( + state="private", + ipxe_config="#!ipxe\ntest", + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.return_value = [ + _make_feature("private_ipxe_boot", "deactivated"), + ] + + result = handler.run() + + assert result["changed"] is True + handler.api.post_dedicated_server_feature_deactivate.assert_not_called() + handler.api.post_dedicated_server_feature_activate.assert_called_once() + + +class TestWait: + @mock.patch("ansible_collections.serverscom.sc_api.plugins.module_utils.dedicated_server.time") + def test_wait_for_activation(self, mock_time): + mock_time.time.side_effect = [0, 0, 5, 5, 10] + mock_time.sleep = mock.MagicMock() + + handler = _make_handler( + state="public", wait=60, update_interval=5, + ipxe_config="#!ipxe\nchain http://boot.example.com", + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.side_effect = [ + [_make_feature("public_ipxe_boot", "deactivated")], + [_make_feature("public_ipxe_boot", "activation")], + [_make_feature("public_ipxe_boot", "activated")], + ] + + result = handler.run() + + assert result["changed"] is True + assert result["feature"]["status"] == "activated" + assert handler.api.post_dedicated_server_feature_activate.call_count == 1 + + @mock.patch("ansible_collections.serverscom.sc_api.plugins.module_utils.dedicated_server.time") + def test_wait_for_deactivation(self, mock_time): + mock_time.time.side_effect = [0, 0, 5, 5, 10] + mock_time.sleep = mock.MagicMock() + + handler = _make_handler(state="absent", wait=60, update_interval=5) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.side_effect = [ + # initial call in _ensure_absent + [ + _make_feature("public_ipxe_boot", "activated"), + _make_feature("private_ipxe_boot", "deactivated"), + ], + # wait poll: deactivating + [_make_feature("public_ipxe_boot", "deactivation")], + # wait poll: done + [_make_feature("public_ipxe_boot", "deactivated")], + ] + + result = handler.run() + + assert result["changed"] is True + assert result["feature"]["status"] == "deactivated" + + @mock.patch("ansible_collections.serverscom.sc_api.plugins.module_utils.dedicated_server.time") + def test_wait_timeout(self, mock_time): + # time.time() calls: start=0, loop1_check=1, loop1_retry=2, loop2_check=601 + mock_time.time.side_effect = [0, 1, 2, 601] + mock_time.sleep = mock.MagicMock() + + handler = _make_handler( + state="public", wait=600, update_interval=10, + ipxe_config="#!ipxe\nchain http://boot.example.com", + ) + handler.api = mock.MagicMock() + handler.api.get_dedicated_server_features.side_effect = [ + # _get_feature_status + [_make_feature("public_ipxe_boot", "deactivated")], + # _get_opposite_feature_status (no opposite found) + [_make_feature("public_ipxe_boot", "deactivated")], + # wait poll: still activating + [_make_feature("public_ipxe_boot", "activation")], + ] + + with pytest.raises(WaitError): + handler.run() diff --git a/ansible_collections/serverscom/sc_api/tests/unit/plugins/modules/test_dedicated_server_rescue.py b/ansible_collections/serverscom/sc_api/tests/unit/plugins/modules/test_dedicated_server_rescue.py new file mode 100644 index 00000000..b830c6b3 --- /dev/null +++ b/ansible_collections/serverscom/sc_api/tests/unit/plugins/modules/test_dedicated_server_rescue.py @@ -0,0 +1,568 @@ +# Copyright (c) 2026 Servers.com +# 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 +import pytest +import mock +from ansible_collections.serverscom.sc_api.plugins.module_utils.modules import ( + ModuleError, + WaitError, +) +from ansible_collections.serverscom.sc_api.plugins.module_utils.dedicated_server import ( + ScDedicatedServerRescue, +) +from ansible_collections.serverscom.sc_api.plugins.module_utils.api import ( + APIError409, + APIError412, +) + + +__metaclass__ = type + + +READY_SERVER = { + "id": "test-server", + "status": "active", + "operational_status": "normal", + "power_status": "powered_on", +} + +RESCUE_SERVER = { + "id": "test-server", + "status": "active", + "operational_status": "rescue_mode", + "power_status": "powered_on", +} + + +def create_rescue_instance( + state="rescue", + auth_methods=None, + ssh_key_fingerprints=None, + ssh_key_name=None, + wait=600, + update_interval=10, + checkmode=False, +): + """Create a ScDedicatedServerRescue instance with mocked API.""" + if auth_methods is None and state == "rescue": + auth_methods = ["password"] + with mock.patch( + "ansible_collections.serverscom.sc_api.plugins.module_utils" + ".dedicated_server.ScApi" + ) as mock_api: + instance = ScDedicatedServerRescue( + endpoint="https://api.servers.com/v1", + token="test-token", + server_id="test-server", + state=state, + auth_methods=auth_methods, + ssh_key_fingerprints=ssh_key_fingerprints, + ssh_key_name=ssh_key_name, + wait=wait, + update_interval=update_interval, + checkmode=checkmode, + ) + return instance, mock_api + + +# --- Idempotency: steady states --- + + +def test_activate_already_activated(): + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "activated"}, + ] + instance.api.get_dedicated_servers.return_value = dict(RESCUE_SERVER) + + result = instance.run() + + assert result["changed"] is False + instance.api.post_dedicated_server_rescue_activate.assert_not_called() + + +def test_deactivate_already_deactivated(): + instance, _mock_api = create_rescue_instance(state="normal", auth_methods=None) + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "deactivated"}, + ] + instance.api.get_dedicated_servers.return_value = dict(READY_SERVER) + + result = instance.run() + + assert result["changed"] is False + instance.api.post_dedicated_server_rescue_deactivate.assert_not_called() + + +# --- Idempotency: transitional states (wait for completion) --- + + +@mock.patch( + "ansible_collections.serverscom.sc_api.plugins.module_utils" + ".dedicated_server.time" +) +def test_activate_while_activating(mock_time): + mock_time.time.side_effect = [0, 0] + mock_time.sleep = mock.MagicMock() + + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.side_effect = [ + [{"name": "host_rescue_mode", "status": "activation"}], + [{"name": "host_rescue_mode", "status": "activated"}], + ] + instance.api.get_dedicated_servers.return_value = dict(RESCUE_SERVER) + + result = instance.run() + + assert result["changed"] is False + instance.api.post_dedicated_server_rescue_activate.assert_not_called() + + +@mock.patch( + "ansible_collections.serverscom.sc_api.plugins.module_utils" + ".dedicated_server.time" +) +def test_deactivate_while_deactivating(mock_time): + mock_time.time.side_effect = [0, 0] + mock_time.sleep = mock.MagicMock() + + instance, _mock_api = create_rescue_instance(state="normal", auth_methods=None) + instance.api.get_dedicated_server_features.side_effect = [ + [{"name": "host_rescue_mode", "status": "deactivation"}], + [{"name": "host_rescue_mode", "status": "deactivated"}], + ] + instance.api.get_dedicated_servers.return_value = dict(READY_SERVER) + + result = instance.run() + + assert result["changed"] is False + instance.api.post_dedicated_server_rescue_deactivate.assert_not_called() + + +# --- Check mode --- + + +def test_activate_checkmode(): + instance, _mock_api = create_rescue_instance(state="rescue", checkmode=True) + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "deactivated"}, + ] + instance.api.get_dedicated_servers.return_value = dict(READY_SERVER) + + result = instance.run() + + assert result["changed"] is True + instance.api.post_dedicated_server_rescue_activate.assert_not_called() + + +def test_deactivate_checkmode(): + instance, _mock_api = create_rescue_instance( + state="normal", auth_methods=None, checkmode=True + ) + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "activated"}, + ] + instance.api.get_dedicated_servers.return_value = dict(RESCUE_SERVER) + + result = instance.run() + + assert result["changed"] is True + instance.api.post_dedicated_server_rescue_deactivate.assert_not_called() + + +# --- Success flows --- + + +@mock.patch( + "ansible_collections.serverscom.sc_api.plugins.module_utils" + ".dedicated_server.time" +) +def test_activate_success(mock_time): + mock_time.time.side_effect = [0, 0, 0, 0] + mock_time.sleep = mock.MagicMock() + + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.side_effect = [ + [{"name": "host_rescue_mode", "status": "deactivated"}], + [{"name": "host_rescue_mode", "status": "activated"}], + ] + instance.api.post_dedicated_server_rescue_activate.return_value = { + "name": "host_rescue_mode", + "status": "activation", + } + instance.api.get_dedicated_servers.return_value = dict(RESCUE_SERVER) + + result = instance.run() + + assert result["changed"] is True + assert result["operational_status"] == "rescue_mode" + instance.api.post_dedicated_server_rescue_activate.assert_called_once_with( + "test-server", + auth_methods=["password"], + ssh_key_fingerprints=None, + ) + + +@mock.patch( + "ansible_collections.serverscom.sc_api.plugins.module_utils" + ".dedicated_server.time" +) +def test_deactivate_success(mock_time): + mock_time.time.side_effect = [0, 0, 0, 0] + mock_time.sleep = mock.MagicMock() + + instance, _mock_api = create_rescue_instance(state="normal", auth_methods=None) + instance.api.get_dedicated_server_features.side_effect = [ + [{"name": "host_rescue_mode", "status": "activated"}], + [{"name": "host_rescue_mode", "status": "deactivated"}], + ] + instance.api.post_dedicated_server_rescue_deactivate.return_value = { + "name": "host_rescue_mode", + "status": "deactivation", + } + instance.api.get_dedicated_servers.return_value = dict(READY_SERVER) + + result = instance.run() + + assert result["changed"] is True + assert result["operational_status"] == "normal" + instance.api.post_dedicated_server_rescue_deactivate.assert_called_once_with( + "test-server" + ) + + +# --- No wait (fire-and-forget) --- + + +def test_activate_no_wait(): + instance, _mock_api = create_rescue_instance(state="rescue", wait=0) + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "deactivated"}, + ] + instance.api.post_dedicated_server_rescue_activate.return_value = { + "name": "host_rescue_mode", + "status": "activation", + } + instance.api.get_dedicated_servers.return_value = dict(READY_SERVER) + + result = instance.run() + + assert result["changed"] is True + instance.api.post_dedicated_server_rescue_activate.assert_called_once() + + +def test_deactivate_no_wait(): + instance, _mock_api = create_rescue_instance( + state="normal", auth_methods=None, wait=0 + ) + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "activated"}, + ] + instance.api.post_dedicated_server_rescue_deactivate.return_value = { + "name": "host_rescue_mode", + "status": "deactivation", + } + instance.api.get_dedicated_servers.return_value = dict(RESCUE_SERVER) + + result = instance.run() + + assert result["changed"] is True + instance.api.post_dedicated_server_rescue_deactivate.assert_called_once() + + +# --- run() dispatcher --- + + +def test_run_state_rescue(): + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "activated"}, + ] + instance.api.get_dedicated_servers.return_value = dict(RESCUE_SERVER) + + result = instance.run() + + assert result["changed"] is False + + +def test_run_state_normal(): + instance, _mock_api = create_rescue_instance(state="normal", auth_methods=None) + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "deactivated"}, + ] + instance.api.get_dedicated_servers.return_value = dict(READY_SERVER) + + result = instance.run() + + assert result["changed"] is False + + +def test_run_unknown_state(): + instance, _mock_api = create_rescue_instance(state="invalid", auth_methods=None) + + with pytest.raises(ModuleError) as exc_info: + instance.run() + + assert "Unknown state" in str(exc_info.value.msg) + + +# --- Error cases: feature status --- + + +def test_activate_unavailable(): + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "unavailable"}, + ] + + with pytest.raises(ModuleError) as exc_info: + instance.run() + + assert "unavailable" in str(exc_info.value.msg) + + +def test_activate_incompatible(): + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "incompatible"}, + ] + + with pytest.raises(ModuleError) as exc_info: + instance.run() + + assert "incompatible" in str(exc_info.value.msg) + + +def test_deactivate_unavailable(): + instance, _mock_api = create_rescue_instance(state="normal", auth_methods=None) + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "unavailable"}, + ] + + with pytest.raises(ModuleError) as exc_info: + instance.run() + + assert "unavailable" in str(exc_info.value.msg) + + +def test_feature_not_found(): + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.return_value = [ + {"name": "some_other_feature", "status": "activated"}, + ] + + with pytest.raises(ModuleError) as exc_info: + instance.run() + + assert "host_rescue_mode feature not found" in str(exc_info.value.msg) + + +# --- 409/412 retry --- + + +INCOMPATIBLE_STATE_MSG = ( + '409 Conflict. b\'{"message":"Incompatible feature state",' + '"code":"INCOMPATIBLE_FEATURE_STATE"}\'' +) + +PRECONDITION_FAILED_MSG = ( + '412 Precondition Failed. b\'{"message":"Host is not ready",' + '"code":"PRECONDITION_FAILED"}\'' +) + + +@mock.patch( + "ansible_collections.serverscom.sc_api.plugins.module_utils" + ".dedicated_server.time" +) +def test_409_retry_success(mock_time): + mock_time.time.side_effect = [0, 0, 0, 0, 0, 0] + mock_time.sleep = mock.MagicMock() + + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.side_effect = [ + [{"name": "host_rescue_mode", "status": "deactivated"}], + [{"name": "host_rescue_mode", "status": "activated"}], + ] + instance.api.post_dedicated_server_rescue_activate.side_effect = [ + APIError409( + msg=INCOMPATIBLE_STATE_MSG, api_url="/test", status_code=409 + ), + {"name": "host_rescue_mode", "status": "activation"}, + ] + instance.api.get_dedicated_servers.return_value = dict(RESCUE_SERVER) + + result = instance.run() + + assert result["changed"] is True + assert instance.api.post_dedicated_server_rescue_activate.call_count == 2 + + +@mock.patch( + "ansible_collections.serverscom.sc_api.plugins.module_utils" + ".dedicated_server.time" +) +def test_412_retry_success(mock_time): + mock_time.time.side_effect = [0, 0, 0, 0, 0, 0] + mock_time.sleep = mock.MagicMock() + + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.side_effect = [ + [{"name": "host_rescue_mode", "status": "deactivated"}], + [{"name": "host_rescue_mode", "status": "activated"}], + ] + instance.api.post_dedicated_server_rescue_activate.side_effect = [ + APIError412( + msg=PRECONDITION_FAILED_MSG, api_url="/test", status_code=412 + ), + {"name": "host_rescue_mode", "status": "activation"}, + ] + instance.api.get_dedicated_servers.return_value = dict(RESCUE_SERVER) + + result = instance.run() + + assert result["changed"] is True + assert instance.api.post_dedicated_server_rescue_activate.call_count == 2 + + +@mock.patch( + "ansible_collections.serverscom.sc_api.plugins.module_utils" + ".dedicated_server.time" +) +def test_409_412_timeout(mock_time): + mock_time.time.side_effect = [0, 100000] + mock_time.sleep = mock.MagicMock() + + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "deactivated"}, + ] + instance.api.post_dedicated_server_rescue_activate.side_effect = APIError409( + msg=INCOMPATIBLE_STATE_MSG, api_url="/test", status_code=409 + ) + + with pytest.raises(APIError409): + instance.run() + + +def test_409_non_feature_state_not_retried(): + """409 with a code other than INCOMPATIBLE_FEATURE_STATE should not be retried.""" + other_msg = '409 Conflict. b\'{"message":"Other error","code":"OTHER_ERROR"}\'' + instance, _mock_api = create_rescue_instance(state="rescue") + instance.api.get_dedicated_server_features.return_value = [ + {"name": "host_rescue_mode", "status": "deactivated"}, + ] + instance.api.post_dedicated_server_rescue_activate.side_effect = APIError409( + msg=other_msg, api_url="/test", status_code=409 + ) + + with pytest.raises(APIError409): + instance.run() + + instance.api.post_dedicated_server_rescue_activate.assert_called_once() + + +# --- Wait timeout --- + + +@mock.patch( + "ansible_collections.serverscom.sc_api.plugins.module_utils" + ".dedicated_server.time" +) +def test_wait_timeout(mock_time): + # time calls: _retry_on_api_error(start), _wait(start), _wait loop(elapsed) + mock_time.time.side_effect = [0, 0, 100000] + mock_time.sleep = mock.MagicMock() + + instance, _mock_api = create_rescue_instance(state="rescue") + # features calls: activate_rescue(initial check), _wait loop iter 1 + instance.api.get_dedicated_server_features.side_effect = [ + [{"name": "host_rescue_mode", "status": "deactivated"}], + [{"name": "host_rescue_mode", "status": "activation"}], + ] + instance.api.post_dedicated_server_rescue_activate.return_value = { + "name": "host_rescue_mode", + "status": "activation", + } + + with pytest.raises(WaitError) as exc_info: + instance.run() + + assert "Timeout" in str(exc_info.value.msg) + + +# --- SSH key resolution --- + + +def test_ssh_key_name_resolution(): + instance, _mock_api = create_rescue_instance( + state="rescue", + auth_methods=["ssh_key"], + ssh_key_name="my-key", + ) + + instance.api.toolbox.get_ssh_fingerprints_by_key_name.assert_called_once_with( + "my-key", must=True + ) + assert instance.ssh_key_fingerprints == [ + instance.api.toolbox.get_ssh_fingerprints_by_key_name.return_value + ] + + +@mock.patch( + "ansible_collections.serverscom.sc_api.plugins.module_utils" + ".dedicated_server.time" +) +def test_activate_with_ssh_key_fingerprints(mock_time): + mock_time.time.side_effect = [0, 0, 0, 0] + mock_time.sleep = mock.MagicMock() + + instance, _mock_api = create_rescue_instance( + state="rescue", + auth_methods=["ssh_key"], + ssh_key_fingerprints=["ab:cd:ef:12:34"], + ) + instance.api.get_dedicated_server_features.side_effect = [ + [{"name": "host_rescue_mode", "status": "deactivated"}], + [{"name": "host_rescue_mode", "status": "activated"}], + ] + instance.api.post_dedicated_server_rescue_activate.return_value = { + "name": "host_rescue_mode", + "status": "activation", + } + instance.api.get_dedicated_servers.return_value = dict(RESCUE_SERVER) + + result = instance.run() + + assert result["changed"] is True + instance.api.post_dedicated_server_rescue_activate.assert_called_once_with( + "test-server", + auth_methods=["ssh_key"], + ssh_key_fingerprints=["ab:cd:ef:12:34"], + ) + + +# --- auth_methods validation --- + + +def test_invalid_auth_method(): + with pytest.raises(ModuleError) as exc_info: + create_rescue_instance( + state="rescue", + auth_methods=["password", "invalid"], + ) + + assert "Invalid auth_methods" in str(exc_info.value.msg) + assert "invalid" in str(exc_info.value.msg) + + +def test_ssh_key_without_fingerprints_or_name(): + with pytest.raises(ModuleError) as exc_info: + create_rescue_instance( + state="rescue", + auth_methods=["ssh_key"], + ) + + assert "ssh_key_fingerprints or ssh_key_name is required" in str(exc_info.value.msg) diff --git a/examples/dynamic_inventory/01_everything.sc_api.yml b/examples/dynamic_inventory/01_everything.sc_api.yml new file mode 100644 index 00000000..74bc87f6 --- /dev/null +++ b/examples/dynamic_inventory/01_everything.sc_api.yml @@ -0,0 +1,7 @@ +--- +# Minimal config: no `resources:` at all. +# Omitting `resources:` (or setting it to []) fetches every kind +# (baremetal + sbm + k8s_nodes + cloud) with no filters. If you add any +# entry to `resources:`, that entry must specify `kind:`. +# Token is read from SERVERSCOM_API_TOKEN (or SC_TOKEN) env var. +plugin: serverscom.sc_api.sc_inventory diff --git a/examples/dynamic_inventory/02_all_cloud_grouped_by_region.sc_api.yml b/examples/dynamic_inventory/02_all_cloud_grouped_by_region.sc_api.yml new file mode 100644 index 00000000..e86715f6 --- /dev/null +++ b/examples/dynamic_inventory/02_all_cloud_grouped_by_region.sc_api.yml @@ -0,0 +1,7 @@ +--- +# All cloud-computing instances, dynamically grouped by their region_code. +# Creates one group per region (e.g. ams1, fra1, ...). +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: cloud + group_by: region_code diff --git a/examples/dynamic_inventory/03_all_baremetal_grouped_by_location.sc_api.yml b/examples/dynamic_inventory/03_all_baremetal_grouped_by_location.sc_api.yml new file mode 100644 index 00000000..a0fb6a98 --- /dev/null +++ b/examples/dynamic_inventory/03_all_baremetal_grouped_by_location.sc_api.yml @@ -0,0 +1,7 @@ +--- +# All dedicated bare-metal servers, dynamically grouped by location_code. +# Creates one group per location (e.g. AMS1, FRA1, ...). +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + group_by: location_code diff --git a/examples/dynamic_inventory/04_baremetal_in_specific_regions.sc_api.yml b/examples/dynamic_inventory/04_baremetal_in_specific_regions.sc_api.yml new file mode 100644 index 00000000..04d493e1 --- /dev/null +++ b/examples/dynamic_inventory/04_baremetal_in_specific_regions.sc_api.yml @@ -0,0 +1,8 @@ +--- +# Bare-metal servers in a fixed list of regions, all into one static group. +# `regions:` is matched against the host's location_code. +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: [AMS1, AMS7] + assign_inventory_group: amsterdam_servers diff --git a/examples/dynamic_inventory/05_cloud_labels_private_ip.sc_api.yml b/examples/dynamic_inventory/05_cloud_labels_private_ip.sc_api.yml new file mode 100644 index 00000000..4307a9fc --- /dev/null +++ b/examples/dynamic_inventory/05_cloud_labels_private_ip.sc_api.yml @@ -0,0 +1,14 @@ +--- +# Cloud instances filtered by label, addressed over private IPv4. +# All matched hosts go into the `staging` group and pick up extra connection vars. +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: cloud + labels: + environment: staging + status_filter: [ACTIVE] + ansible_host: private_ipv4 + assign_inventory_group: staging + extra_vars: + ansible_user: ubuntu + ansible_python_interpreter: /usr/bin/python3 diff --git a/examples/dynamic_inventory/06_exclusion_ams1_except_production.sc_api.yml b/examples/dynamic_inventory/06_exclusion_ams1_except_production.sc_api.yml new file mode 100644 index 00000000..c8cc77c9 --- /dev/null +++ b/examples/dynamic_inventory/06_exclusion_ams1_except_production.sc_api.yml @@ -0,0 +1,12 @@ +--- +# All bare-metal servers in AMS1 EXCEPT those labeled environment=production. +# Implementation: keep AMS1 hosts (positive filter), then drop the subset that +# carries the `environment: production` label (exclusion rule). +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: [AMS1] + exclude: + - labels: + environment: production + assign_inventory_group: ams1_nonprod diff --git a/examples/dynamic_inventory/07_multi_rule_exclusion.sc_api.yml b/examples/dynamic_inventory/07_multi_rule_exclusion.sc_api.yml new file mode 100644 index 00000000..a63482ec --- /dev/null +++ b/examples/dynamic_inventory/07_multi_rule_exclusion.sc_api.yml @@ -0,0 +1,14 @@ +--- +# Two independent exclusion rules combined with OR: a host is dropped if it +# matches EITHER rule. +# - rule 1: every host in AMS2 +# - rule 2: every host labeled `decommissioned: "true"` in any region +# Everything left ends up in the `active_fleet` group. +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + exclude: + - regions: [AMS2] + - labels: + decommissioned: "true" + assign_inventory_group: active_fleet diff --git a/examples/dynamic_inventory/08_env_var_substitution.sc_api.yml b/examples/dynamic_inventory/08_env_var_substitution.sc_api.yml new file mode 100644 index 00000000..7c982827 --- /dev/null +++ b/examples/dynamic_inventory/08_env_var_substitution.sc_api.yml @@ -0,0 +1,16 @@ +--- +# ${VAR} substitution is applied recursively to every string in the config +# (including inside lists and dicts) before the API calls run. +# Usage: +# export DEPLOY_REGION=AMS1 +# export SC_ENV=production +# ansible-inventory -i 08_env_var_substitution.sc_api.yml --list +# An unset variable raises AnsibleParserError naming the missing variable. +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: + - ${DEPLOY_REGION} + labels: + environment: ${SC_ENV} + group_by: location_code diff --git a/examples/dynamic_inventory/09_k8s_nodes.sc_api.yml b/examples/dynamic_inventory/09_k8s_nodes.sc_api.yml new file mode 100644 index 00000000..97ffa8ad --- /dev/null +++ b/examples/dynamic_inventory/09_k8s_nodes.sc_api.yml @@ -0,0 +1,11 @@ +--- +# Bare-metal Kubernetes nodes (servers with type=kubernetes_baremetal_node). +# Reached over their private network, gathered into one group for cluster-wide +# playbooks. +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: k8s_nodes + ansible_host: private_ipv4 + assign_inventory_group: kubernetes_nodes + extra_vars: + ansible_user: root diff --git a/examples/dynamic_inventory/10_multi_kind_inventory.sc_api.yml b/examples/dynamic_inventory/10_multi_kind_inventory.sc_api.yml new file mode 100644 index 00000000..f85bcb53 --- /dev/null +++ b/examples/dynamic_inventory/10_multi_kind_inventory.sc_api.yml @@ -0,0 +1,30 @@ +--- +# One inventory covering three resource kinds, each with its own settings. +# Use separate blocks when filters / IP choice / groups differ per kind. +# Every `resources:` entry must specify `kind:`; if you want every kind +# with no filters, omit `resources:` entirely instead (see 01). +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: baremetal + regions: [AMS1, FRA1] + labels: {managed: "true"} + status_filter: [active] + ansible_host: public_ipv4 + assign_inventory_group: baremetal_fleet + extra_vars: + ansible_user: root + + - kind: cloud + regions: [AMS1, FRA1] + labels: {managed: "true"} + status_filter: [ACTIVE] + ansible_host: public_ipv4 + group_by: region_code + extra_vars: + ansible_user: ubuntu + + - kind: k8s_nodes + ansible_host: private_ipv4 + assign_inventory_group: k8s_nodes + extra_vars: + ansible_user: root diff --git a/examples/dynamic_inventory/11_name_regex_two_kinds_same_group.sc_api.yml b/examples/dynamic_inventory/11_name_regex_two_kinds_same_group.sc_api.yml new file mode 100644 index 00000000..e62fb4f3 --- /dev/null +++ b/examples/dynamic_inventory/11_name_regex_two_kinds_same_group.sc_api.yml @@ -0,0 +1,15 @@ +--- +# `name_regex` is applied with `re.search` against the host's title +# (bare-metal) or name (cloud). Both blocks point at `web_tier`, so cloud and +# bare-metal hosts matching `^web-` land in the same Ansible group. +plugin: serverscom.sc_api.sc_inventory +resources: + - kind: cloud + name_regex: "^web-" + status_filter: [ACTIVE] + assign_inventory_group: web_tier + + - kind: baremetal + name_regex: "^web-" + status_filter: [active] + assign_inventory_group: web_tier diff --git a/integration_config.yml.template b/integration_config.yml.template index b3e9e9cf..346f6e91 100644 --- a/integration_config.yml.template +++ b/integration_config.yml.template @@ -5,3 +5,44 @@ existing_server2_id: 3dzAvZmK existing_server3_id: bdkMlWpL non_existing_id: gd0EL519 sc_token: "${SC_TOKEN}" + +# RBS (Block Storage) tests +rbs_test_location_id: 46 +rbs_test_location_code: AMS7 +rbs_test_flavor_id: 18590 +rbs_test_flavor_name: Performance + +# Load Balancer tests +lb_test_location_id: 32 +lb_test_upstream_ip1: "10.33.180.116" +lb_test_upstream_ip2: "10.33.180.196" +lb_test_network: "192.168.1.0/24" + +# Cloud Computing tests +cloud_test_region_id: 2 +cloud_test_flavor_id: "33227-1" +cloud_test_flavor_name: SSD.30 +cloud_test_ssh_key_fingerprint: "9b:08:4d:a5:6d:45:26:72:2c:e0:9a:ee:bf:7d:03:a6" +cloud_test_region_search_pattern: WAS +cloud_test_region_search_match: WAS1 +cloud_test_region_search_nomatch: AMS1 + +# Baremetal tests +baremetal_test_location_search_pattern: US +baremetal_test_location_search_match: DFW1 +baremetal_test_location_search_nomatch: AMS1 +baremetal_test_os_location_id: 34 +baremetal_test_os_location_code: ams1 +baremetal_test_os_server_model_id: 11940 +baremetal_test_os_server_model_name: "Dell R730xd / 2xIntel Xeon E5-2680 v3 / 32 GB RAM / 4x600 GB SAS" + +# Dedicated server reinstall tests +dedicated_test_reinstall_os_id: 49 +dedicated_test_reinstall_ssh_key_fingerprint: "f7:90:27:e6:97:5e:6d:ad:31:51:65:26:8d:82:ac:f9" +dedicated_test_reinstall_ssh_key_name: amarao + +# SBM (Scalable Baremetal) tests +sbm_test_location_code: AMS7 +sbm_test_flavor_name: DL-01 +sbm_test_os_regex: "Debian 13" +sbm_test_reinstall_os_name: "Ubuntu 24.04-server x86_64"