diff --git a/README.md b/README.md index 297fa89..ee1f99c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ This collection provides comprehensive management of NetBird resources: - **Tokens** - Create and manage personal access tokens - **Identity Providers** - Configure identity providers (Google, Okta, Entra, OIDC, etc.) - **Invites** - Manage user invite links with expiration and regeneration -- **Info** - Gather information about any resource +- **Service Domains** - Manage custom domains for reverse-proxy services +- **Info** - Gather information about any resource (including services, service domains, proxy clusters) ## Requirements @@ -579,6 +580,52 @@ Manage NetBird user invites. state: absent ``` +### netbird_service_domain + +Manage custom domains for NetBird reverse-proxy services. + +```yaml +# Create a custom domain +- name: Add custom domain + community.ansible_netbird.netbird_service_domain: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + domain: "app.example.com" + target_cluster: "eu.proxy.netbird.io" + state: present + +# Create and trigger DNS validation +- name: Add and validate custom domain + community.ansible_netbird.netbird_service_domain: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + domain: "app.example.com" + target_cluster: "eu.proxy.netbird.io" + validate: true + state: present + +# Delete a custom domain +- name: Remove custom domain + community.ansible_netbird.netbird_service_domain: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + domain: "app.example.com" + state: absent +``` + +### netbird_proxy_cluster + +Remove NetBird self-hosted reverse-proxy clusters. + +> **Note:** Proxy clusters are registered automatically by the proxy software; only deletion is available through the API, so this module only supports C(state=absent). + +```yaml +- name: "Delete existent cluster" + community.ansible_netbird.netbird_proxy_cluster: + address: "subdomain.proxy.example.com" + state: absent +``` + ### netbird_info Gather information about NetBird resources. @@ -606,7 +653,7 @@ Gather information about NetBird resources. register: me ``` -Available resources: `accounts`, `users`, `peers`, `groups`, `setup_keys`, `policies`, `networks`, `routes`, `dns_nameservers`, `dns_settings`, `posture_checks`, `events`, `countries`, `current_user`, `identity_providers`, `invites` +Available resources: `accounts`, `users`, `peers`, `groups`, `setup_keys`, `policies`, `networks`, `routes`, `dns_nameservers`, `dns_zones`, `dns_settings`, `posture_checks`, `events`, `countries`, `current_user`, `identity_providers`, `invites`, `services`, `service_domains`, `proxy_clusters` ## Role Usage @@ -749,7 +796,7 @@ For inventory-based workflows (e.g., AAP), use the roles directly in your own pl - **Strict mode** — enforces full IaC by removing resources not defined in YAML - **Setup key management** — create/rotate enrollment keys with auto_groups name resolution; key values registered for downstream Vault storage - **Name-based config** — use plain names ("developers") instead of API IDs; resolved automatically -- **Dependency ordering** — resources applied in correct order (settings → posture checks → groups → setup keys → DNS → networks → policies) +- **Dependency ordering** — resources applied in correct order (settings → posture checks → groups → setup keys → DNS → networks → services → policies) - **Export utility** — captures current API state as clean, ready-to-use YAML config files - **Roles** — use `community.ansible_netbird.configure` and `community.ansible_netbird.export` directly in your own playbooks for full control @@ -759,6 +806,7 @@ For inventory-based workflows (e.g., AAP), use the roles directly in your own pl my_netbird_config/ ├── settings.yml # Account-wide settings ├── networks.yml # Networks with routers and resources +├── services.yml # Reverse-proxy services and custom domains (optional) ├── setup_keys.yml # Peer enrollment keys (optional) ├── access_control/ │ ├── groups.yml # Groups @@ -788,6 +836,7 @@ This collection implements the [NetBird REST API](https://docs.netbird.io/api). - [Networks](https://docs.netbird.io/api/resources/networks) - [DNS](https://docs.netbird.io/api/resources/dns) - [Posture Checks](https://docs.netbird.io/api/resources/posture-checks) +- [Services](https://docs.netbird.io/api/resources/services) - [Identity Providers](https://docs.netbird.io/api/resources/identity-providers) - [Events](https://docs.netbird.io/api/resources/events) diff --git a/changelogs/fragments/service-domains-and-proxy-clusters.yml b/changelogs/fragments/service-domains-and-proxy-clusters.yml new file mode 100644 index 0000000..e9c4f1b --- /dev/null +++ b/changelogs/fragments/service-domains-and-proxy-clusters.yml @@ -0,0 +1,66 @@ +--- +major_changes: + - 'Full reverse-proxy service domain and proxy cluster management. Two new + modules (``netbird_service_domain``, ``netbird_proxy_cluster``), two new + ``netbird_info`` resource types (``service_domains``, ``proxy_clusters``), + six new API helpers, service ``access_groups`` name resolution, service + drift detection in the diff filter, and end-to-end integration in the + configure role (preview, apply, strict-mode sweep) and export role + (clean + raw output with group-ID-to-name resolution). Includes + integration tests covering domain lifecycle, proxy cluster noop delete, + and service auth variants (bearer, password, PIN). Completes the + ``/api/reverse-proxies/*`` surface started in 1.3.0.' + +minor_changes: + - 'netbird_service_domain - a ``target_cluster`` change now emits + ``module.warn()`` explaining the delete-and-recreate consequences + (new ID, validation reset, bound services may break).' + - 'export role - service and service-domain API fetches are gated so + the role does not 404 on management servers without the reverse-proxy + API.' + - 'export role - service auth metadata (bearer_auth enabled flags and + distribution_groups) is preserved in the exported config.' + - 'defaults/main.yml - added ``netbird_services``, + ``netbird_service_domains``, and ``netbird_proxy_clusters_absent`` + variables with documented examples.' + - 'config_skeleton/services.yml - added config-as-code skeleton for + services and service domains.' + +bugfixes: + - 'netbird_service_domain - ``find_domain_by_name`` now filters to + ``type == ''custom''``, skipping free/proxy entries that have an empty + ``id``. Matching one of those issued ``DELETE /domains/`` with no ID + path segment.' + - 'netbird_service_domain - ``validate: true`` now triggers validation on + existing unvalidated domains. Previously it only fired on the create + path, where DNS was not yet configured, and was silently ignored on + re-runs when the domain already existed.' + - 'netbird_service_domain - a failed re-create on cluster change now + rolls back to the original cluster instead of leaving the domain + deleted. The create response is also validated before proceeding.' + - 'defaults/main.yml - the ``mode: tcp`` example no longer includes + ``path`` and ``protocol: http``, which are L7 options the API rejects + for TCP services.' + - 'export role - password_auth and pin_auth blocks are no longer exported + with empty secrets. An export-then-apply round-trip previously sent + ``password: ""`` / ``pin: ""`` to the API, silently producing + unauthenticated services. Only bearer_auth (no secrets) is now + exported; a YAML comment flags the omission.' + - 'export role - guarded the ``type`` attribute in ``selectattr`` filters + for service domains so entries without a type field do not cause + template errors.' + - 'tests/integration/test_services.yml - fixed ``lookup(''env'')`` + defaults that never applied and converted string values to proper + types. Conditional blocks now always run so later phases that depend + on their resources are not skipped.' + +security_fixes: + - 'tasks/services.yml - the raw ``ansible.builtin.uri`` task for + cluster deletion has been replaced by the new ``netbird_proxy_cluster`` + module, which inherits ``no_log: true`` on ``api_token`` from the + shared argument spec. The previous task interpolated + ``Authorization: Token ...`` in headers, leaking the PAT under + ``-vvvv``.' + - 'tests/integration/test_services.yml - test services with known + credentials (passwords, PINs) are no longer left running on the live + tenant after the test completes.' diff --git a/config_skeleton/services.yml b/config_skeleton/services.yml new file mode 100644 index 0000000..dec6b32 --- /dev/null +++ b/config_skeleton/services.yml @@ -0,0 +1,38 @@ +--- +# NetBird Reverse-Proxy Services +# +# Services expose internal resources via the NetBird reverse-proxy +# (Ingress). Each service publishes a domain and forwards to one +# or more backend targets that reference a network resource. +# +# Populate from export: netbird_config_export/services.yml +# +# Services are matched by domain (unique). Targets reference network +# resource IDs (target_id) — find them via export or the dashboard. +# access_groups use plain group names (resolved automatically). +# +# Reference: https://docs.netbird.io/api/resources/services + +netbird_services: [] + # - domain: "myapp.netbird.example.com" + # name: "My App" + # mode: http # http, tcp, udp, tls + # private: true # overlay-only (http mode only) + # enabled: true + # access_groups: + # - all-users # resolved to ID automatically + # targets: + # - host: "10.0.0.30" + # port: 8080 + # protocol: http + # target_id: "subnet-resource-id" + # target_type: subnet # subnet, host, domain, peer, cluster + # enabled: true + # direct_upstream: true + # state: present + +netbird_service_domains: [] + # - domain: "app.example.com" + # target_cluster: "eu.proxy.netbird.io" + # validate: true + # state: present diff --git a/defaults/main.yml b/defaults/main.yml index 6d0607b..cf88578 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -183,6 +183,70 @@ netbird_invites: [] # expires_in: 604800 # 7 days in seconds # state: present +# Service (reverse-proxy) management +netbird_services: [] +# Example: +# netbird_services: +# - domain: "myapp.netbird.example.com" +# name: "My App" +# mode: "http" # http, tcp, udp, tls +# private: true # overlay-only (http) +# enabled: true +# access_groups: # Required when private: true +# - "all-users-group-id" +# targets: +# - host: "10.0.0.30" +# port: 8080 +# protocol: http # http, https +# target_id: "subnet-resource-id" # NetBird network resource ID +# target_type: subnet # subnet, host, domain, peer, cluster +# enabled: true +# direct_upstream: true +# skip_tls_verify: false +# path: / +# path_rewrite: preserve +# auth: # public only +# bearer_auth: +# enabled: true +# distribution_groups: +# - "auth-group-id" +# password_auth: +# enabled: false +# password: "" +# pin_auth: +# enabled: false +# pin: "" +# state: present +# +# - domain: "tcp-service.netbird.example.com" +# mode: "tcp" +# private: false +# listen_port: 5432 +# targets: +# - host: "10.0.0.50" +# port: 5432 +# target_id: "subdomain.netbird.io" +# target_type: cluster +# state: present + +# Service custom domain management +# NOTE: Changing target_cluster on an existing domain triggers a +# delete+recreate (no PUT exists). The domain gets a new ID, DNS +# validation is reset, and services bound to it may break. +netbird_service_domains: [] +# Example: +# netbird_service_domains: +# - domain: "app.example.com" +# target_cluster: "eu.proxy.netbird.io" +# validate: true +# state: present + +# Proxy cluster cleanup (delete-only — no create API) +netbird_proxy_clusters_absent: [] +# Example: +# netbird_proxy_clusters_absent: +# - address: "old.proxy.example.com" + # Account settings netbird_account_settings: {} # Example: diff --git a/meta/runtime.yml b/meta/runtime.yml index 2a130ce..ee8b584 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -14,8 +14,10 @@ action_groups: - netbird_peer - netbird_policy - netbird_posture_check + - netbird_proxy_cluster - netbird_route - netbird_service + - netbird_service_domain - netbird_setup_key - netbird_token - netbird_user diff --git a/plugins/filter/netbird_diff.py b/plugins/filter/netbird_diff.py index 3dcbbb1..4d2f33c 100644 --- a/plugins/filter/netbird_diff.py +++ b/plugins/filter/netbird_diff.py @@ -12,6 +12,63 @@ ) +_SERVICE_SKIP = frozenset(( + 'id', 'meta', 'proxy_cluster', 'port_auto_assigned', 'terminated', 'state' +)) + +_TARGET_OPTIONS = ('direct_upstream', 'skip_tls_verify', 'path_rewrite', + 'proxy_protocol') + + +def _normalize(value): + """Recursively normalize a value for stable comparison. + + - dicts: sorted by key, None values dropped + - lists of dicts: sorted by JSON repr (order-independent) + - lists of scalars: sorted + - strings/bools/ints: returned as-is + """ + if isinstance(value, dict): + return {k: _normalize(v) for k, v in sorted(value.items()) + if v is not None} + if isinstance(value, list): + normalized = [_normalize(v) for v in value] + try: + return sorted(normalized, key=repr) + except TypeError: + return normalized + return value + + +def _deep_diff(current, desired, path=''): + """Recursively compare two normalized structures. + + Returns a list of human-readable change descriptions. + """ + diffs = [] + if isinstance(desired, dict) and isinstance(current, dict): + all_keys = sorted(set(list(current.keys()) + list(desired.keys()))) + for key in all_keys: + sub = '{0}.{1}'.format(path, key) if path else key + if key not in current: + diffs.append('{0}: + added'.format(sub)) + elif key not in desired: + diffs.append('{0}: - removed'.format(sub)) + else: + diffs.extend( + _deep_diff(current[key], desired[key], sub)) + elif isinstance(desired, list) and isinstance(current, list): + if len(current) != len(desired): + diffs.append('{0}: {1} \u2192 {2} items'.format( + path or 'list', len(current), len(desired))) + elif current != desired: + diffs.append('{0}: changed'.format(path or 'list')) + elif current != desired: + diffs.append('{0}: {1} \u2192 {2}'.format( + path or 'value', current, desired)) + return diffs + + def _extract_peer_id(peer): """Extract peer ID from either a dict or plain string.""" if isinstance(peer, dict): @@ -36,7 +93,19 @@ def _effective_name(item): return item.get('domain') or '' -def _classify(desired_list, current_map, protected=None): +def _item_key(item, key_field=None): + """Return the lookup key for a config item. + + When *key_field* is given (e.g. ``'domain'`` for services), use that + field directly. Otherwise fall back to :func:`_effective_name`. + """ + if key_field: + return item.get(key_field) or _effective_name(item) + return _effective_name(item) + + +def _classify(desired_list, current_map, protected=None, + key_field=None): """Classify resources into new/existing/remove/orphaned. Returns (present_names, remove_names, orphaned_names) where @@ -47,7 +116,7 @@ def _classify(desired_list, current_map, protected=None): remove_names = [] for item in desired_list: - name = _effective_name(item) + name = _item_key(item, key_field) state = item.get('state', 'present') if state == 'absent': if name in current_map: @@ -56,7 +125,8 @@ def _classify(desired_list, current_map, protected=None): present_names.append(name) current_names = set(current_map.keys()) - desired_names = set(_effective_name(item) for item in desired_list) + desired_names = set( + _item_key(item, key_field) for item in desired_list) orphaned = sorted(current_names - desired_names - set(protected)) return present_names, remove_names, orphaned @@ -186,14 +256,128 @@ def _compare_policy(current, desired): return diffs +def _flatten_target(target): + """Flatten a target's nested ``options`` dict to top-level keys. + + The API returns targets with ``options: {direct_upstream: true, ...}`` + while the export template (and the module's input schema) writes these + as top-level keys. Normalizing to the flat shape lets the comparison + work on either representation. + """ + flat = {k: v for k, v in target.items() if k != 'options'} + options = target.get('options') + if isinstance(options, dict): + for key in _TARGET_OPTIONS: + if key in options: + flat.setdefault(key, options[key]) + return flat + + +def _resolve_group_list(names, group_ids): + """Resolve a list of group names to IDs using group_ids map. + + Values that are already known IDs pass through unchanged. + """ + if not group_ids: + return list(names) + known_ids = set(group_ids.values()) + return [group_ids.get(n, n) if n not in known_ids else n + for n in names] + + +def _compare_service(current, desired, group_ids=None): + """Compare a single service using recursive normalized diff. + + Server-computed fields (id, meta, proxy_cluster, etc.) are excluded. + Only fields present in the desired config are compared, so omitted + optional fields do not trigger false positives. + + group_ids resolves exported group names back to IDs before comparison + (access_groups and auth.bearer_auth.distribution_groups). + + Targets are flattened from the API's nested options shape to match the + export's flat shape. + + Auth sub-dicts are filtered to only compare declared sub-keys (e.g. + exported bearer_auth-only config does not report password_auth as + removed). + """ + group_ids = group_ids or {} + + cur = {k: _normalize(v) for k, v in current.items() + if k not in _SERVICE_SKIP and v is not None} + des = {k: _normalize(v) for k, v in desired.items() + if k not in _SERVICE_SKIP and v is not None} + + # access_groups: normalize to sorted ID lists + if 'access_groups' in cur: + cur['access_groups'] = sorted( + _extract_ids(current.get('access_groups') or [])) + if 'access_groups' in des: + des['access_groups'] = sorted( + _resolve_group_list(desired.get('access_groups') or [], group_ids)) + + # targets: flatten nested options to top-level keys, then filter API + # targets to only keys the desired targets declare (the export omits + # defaults like path='/' and path_rewrite='preserve') + if 'targets' in des: + des_flat = [_flatten_target(t) + for t in (desired.get('targets') or [])] + des_keys = set() + for t in des_flat: + des_keys.update(t.keys()) + des['targets'] = _normalize(des_flat) + if 'targets' in cur: + cur['targets'] = _normalize([ + {k: v for k, v in _flatten_target(t).items() if k in des_keys} + for t in (current.get('targets') or [])]) + elif 'targets' in cur: + cur['targets'] = _normalize([ + _flatten_target(t) for t in (current.get('targets') or [])]) + + # auth: resolve distribution_groups and filter to declared sub-keys + cur_auth = current.get('auth') + des_auth = desired.get('auth') + if isinstance(des_auth, dict) and isinstance(cur_auth, dict): + filtered_auth_cur = {} + filtered_auth_des = {} + for scheme in des_auth: + if scheme in cur_auth: + cur_scheme = dict(cur_auth[scheme] or {}) + des_scheme = dict(des_auth[scheme] or {}) + # resolve bearer distribution_groups names → IDs + if scheme == 'bearer_auth': + if 'distribution_groups' in cur_scheme: + cur_scheme['distribution_groups'] = sorted( + _extract_ids( + cur_scheme.get('distribution_groups') or [])) + if 'distribution_groups' in des_scheme: + des_scheme['distribution_groups'] = sorted( + _resolve_group_list( + des_scheme.get('distribution_groups') or [], + group_ids)) + filtered_auth_cur[scheme] = _normalize(cur_scheme) + filtered_auth_des[scheme] = _normalize(des_scheme) + else: + filtered_auth_des[scheme] = _normalize(des_auth[scheme]) + cur['auth'] = filtered_auth_cur + des['auth'] = filtered_auth_des + elif 'auth' in des and des_auth is None: + des.pop('auth', None) + + # compare only keys the desired config declares + filtered_cur = {k: cur.get(k) for k in des if k in cur} + return _deep_diff(filtered_cur, des) + + def netbird_diff(desired_list, current_map, resource_type='simple', **kwargs): """Compute diff between desired config and current API state. Args: desired_list: list of desired resource dicts from YAML config current_map: dict mapping resource names to current API state - resource_type: 'network', 'dns', 'policy', or 'simple' - **kwargs: peer_ids, peer_id_name, group_ids, protected + resource_type: 'network', 'dns', 'policy', 'service', or 'simple' + **kwargs: peer_ids, peer_id_name, group_ids, protected, key_field Returns: dict with: new, changed (dict of name: [changes]), unchanged, remove, orphaned @@ -207,15 +391,18 @@ def netbird_diff(desired_list, current_map, resource_type='simple', **kwargs): peer_id_name = kwargs.get('peer_id_name') or {} group_ids = kwargs.get('group_ids') or {} protected = kwargs.get('protected') or [] + key_field = kwargs.get('key_field') - present_names, remove_names, orphaned = _classify(desired_list, current_map, protected) + present_names, remove_names, orphaned = _classify( + desired_list, current_map, protected, key_field=key_field) new_names = [] changed = {} unchanged = [] - desired_by_name = {_effective_name(item): item for item in desired_list - if _effective_name(item)} + desired_by_name = {_item_key(item, key_field): item + for item in desired_list + if _item_key(item, key_field)} for name in present_names: if name not in current_map: @@ -231,6 +418,8 @@ def netbird_diff(desired_list, current_map, resource_type='simple', **kwargs): diffs = _compare_dns(current, desired, group_ids) elif resource_type == 'policy': diffs = _compare_policy(current, desired) + elif resource_type == 'service': + diffs = _compare_service(current, desired, group_ids) else: diffs = [] diff --git a/plugins/filter/netbird_resolve.py b/plugins/filter/netbird_resolve.py index a26aa6b..5738bf6 100644 --- a/plugins/filter/netbird_resolve.py +++ b/plugins/filter/netbird_resolve.py @@ -230,6 +230,35 @@ def _resolve_dns_zone(zone, group_ids, missing=None): return result +def _resolve_service(service, group_ids, missing=None): + """Resolve a single service's access_groups and bearer_auth distribution_groups.""" + result = dict(service) + if 'access_groups' in service: + result['access_groups'] = _resolve_names( + service.get('access_groups', []), + group_ids, + kind='group', + context="service '%s' access_groups" % service.get('domain', ''), + missing=missing, + ) + auth = service.get('auth') + if isinstance(auth, dict): + bearer = auth.get('bearer_auth') + if isinstance(bearer, dict) and 'distribution_groups' in bearer: + result['auth'] = dict(auth, bearer_auth=dict( + bearer, + distribution_groups=_resolve_names( + bearer.get('distribution_groups', []), + group_ids, + kind='group', + context="service '%s' bearer_auth distribution_groups" + % service.get('domain', ''), + missing=missing, + ), + )) + return result + + def _dispatch_resolve(item, resource_type, group_ids, peer_ids, posture_check_ids, missing=None): """Resolve one config item by resource_type, returning the resolved dict. @@ -248,6 +277,8 @@ def _dispatch_resolve(item, resource_type, group_ids, peer_ids, posture_check_id return _resolve_dns_nameserver_group(item, group_ids, missing=missing) if resource_type == 'dns_zone': return _resolve_dns_zone(item, group_ids, missing=missing) + if resource_type == 'service': + return _resolve_service(item, group_ids, missing=missing) return item diff --git a/plugins/module_utils/netbird_api.py b/plugins/module_utils/netbird_api.py index 5f22ce5..df0aba1 100644 --- a/plugins/module_utils/netbird_api.py +++ b/plugins/module_utils/netbird_api.py @@ -1072,6 +1072,32 @@ def delete_service(self, service_id): """Delete a reverse-proxy service.""" return self.delete(f'/api/reverse-proxies/services/{service_id}') + # Service domain operations - /api/reverse-proxies/domains. + def list_service_domains(self): + """List all reverse-proxy service domains.""" + return self.get('/api/reverse-proxies/domains') + + def create_service_domain(self, data): + """Create a custom service domain.""" + return self.post('/api/reverse-proxies/domains', data=data) + + def delete_service_domain(self, domain_id): + """Delete a custom service domain.""" + return self.delete(f'/api/reverse-proxies/domains/{_q(domain_id)}') + + def validate_service_domain(self, domain_id): + """Trigger validation for a custom service domain.""" + return self.get(f'/api/reverse-proxies/domains/{_q(domain_id)}/validate') + + # Proxy cluster operations - /api/reverse-proxies/clusters. + def list_proxy_clusters(self): + """List available proxy clusters.""" + return self.get('/api/reverse-proxies/clusters') + + def delete_proxy_cluster(self, cluster_address): + """Delete a self-hosted (BYOP) proxy cluster.""" + return self.delete(f'/api/reverse-proxies/clusters/{_q(cluster_address)}') + def netbird_argument_spec(): """Return the argument spec common to all NetBird modules.""" diff --git a/plugins/modules/netbird_info.py b/plugins/modules/netbird_info.py index e678bd6..bdd686a 100644 --- a/plugins/modules/netbird_info.py +++ b/plugins/modules/netbird_info.py @@ -27,7 +27,7 @@ 'networks', 'routes', 'dns_nameservers', 'dns_zones', 'dns_settings', 'posture_checks', 'events', 'countries', 'current_user', 'identity_providers', 'invites', - 'services'] + 'services', 'service_domains', 'proxy_clusters'] required: true service_user: description: @@ -146,7 +146,7 @@ def run_module(): 'policies', 'networks', 'routes', 'dns_nameservers', 'dns_zones', 'dns_settings', 'posture_checks', 'events', 'countries', 'current_user', 'identity_providers', 'invites', - 'services'] + 'services', 'service_domains', 'proxy_clusters'] ), service_user=dict(type='bool'), country_code=dict(type='str') @@ -211,6 +211,10 @@ def run_module(): data, _unused = api.list_user_invites() elif resource == 'services': data, _unused = api.list_services() + elif resource == 'service_domains': + data, _unused = api.list_service_domains() + elif resource == 'proxy_clusters': + data, _unused = api.list_proxy_clusters() else: module.fail_json(msg=f"Unknown resource type: {resource}") diff --git a/plugins/modules/netbird_proxy_cluster.py b/plugins/modules/netbird_proxy_cluster.py new file mode 100644 index 0000000..69beb27 --- /dev/null +++ b/plugins/modules/netbird_proxy_cluster.py @@ -0,0 +1,124 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2026, Community +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Ansible module for managing NetBird reverse-proxy clusters.""" + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: netbird_proxy_cluster +short_description: Remove NetBird self-hosted reverse-proxy clusters +description: + - Delete self-hosted (BYOP) proxy cluster registrations via the + NetBird API (C(/api/reverse-proxies/clusters)). + - Proxy clusters are registered automatically by the proxy software; + only deletion is available through the API, so this module only + supports C(state=absent). +version_added: "1.4.0" +author: + - Jan Zboril (@RollLikeRollo) +options: + state: + description: + - The desired state. Only C(absent) is supported. + type: str + choices: ['absent'] + default: absent + address: + description: + - The cluster address to remove (e.g. C(old.proxy.example.com)). + type: str + required: true +extends_documentation_fragment: + - community.ansible_netbird.netbird +attributes: + check_mode: + description: Can run in C(check_mode) and predict changes without modifying the target. + support: full + diff_mode: + description: This module does not report a diff of the changes it makes. + support: none +requirements: + - python >= 3.9 +''' + +EXAMPLES = r''' +- name: Remove a self-hosted proxy cluster + community.ansible_netbird.netbird_proxy_cluster: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + address: "old.proxy.example.com" + state: absent +''' + +RETURN = r''' +cluster_info: + description: Empty dict (the cluster has been removed). + returned: success + type: dict +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.ansible_netbird.plugins.module_utils.netbird_api import ( + NetBirdAPI, + NetBirdAPIError, + netbird_argument_spec +) + + +def cluster_exists(api, address): + """Check whether a cluster with the given address is registered.""" + clusters, _unused = api.list_proxy_clusters() + for cluster in (clusters or []): + if cluster.get('address') == address: + return True + return False + + +def run_module(): + """Main module execution.""" + argument_spec = netbird_argument_spec() + argument_spec.update( + state=dict(type='str', choices=['absent'], default='absent'), + address=dict(type='str', required=True), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + api = NetBirdAPI( + module, + module.params['api_url'], + module.params['api_token'], + module.params['validate_certs'], + timeout=module.params['timeout'] + ) + + address = module.params['address'] + result = dict(changed=False, cluster_info={}) + + try: + if cluster_exists(api, address): + if not module.check_mode: + api.delete_proxy_cluster(address) + result['changed'] = True + result['msg'] = 'Proxy cluster removed successfully' + + module.exit_json(**result) + + except NetBirdAPIError as e: + module.fail_json(msg=str(e), status_code=e.status_code, response=e.response) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/netbird_service_domain.py b/plugins/modules/netbird_service_domain.py new file mode 100644 index 0000000..9d51735 --- /dev/null +++ b/plugins/modules/netbird_service_domain.py @@ -0,0 +1,261 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2026, Community +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Ansible module for managing NetBird reverse-proxy custom domains.""" + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: netbird_service_domain +short_description: Manage NetBird reverse-proxy custom domains +description: + - Create and delete custom domains for NetBird reverse-proxy services + (C(/api/reverse-proxies/domains)). + - Custom domains allow services to be served on your own domain instead + of a NetBird-provided subdomain. + - Creating a domain triggers initial DNS validation. Use the + C(validate) option to re-trigger validation after DNS records are set. + - Domains are matched by the C(domain) name, which must be unique. +version_added: "1.4.0" +author: + - Jan Zboril (@RollLikeRollo) +options: + state: + description: + - The desired state of the custom domain. + type: str + choices: ['present', 'absent'] + default: present + domain: + description: + - The custom domain name (e.g. C(app.example.com)). + - Required. + type: str + required: true + target_cluster: + description: + - The proxy cluster address to associate the domain with + (e.g. C(eu.proxy.netbird.io)). + - Required when C(state=present). + - B(Changing the cluster) on an existing domain triggers a + delete-and-recreate (no PUT exists). The domain receives a new ID, + DNS validation is reset, and services bound to the domain may break. + type: str + validate: + description: + - Whether to trigger the asynchronous domain ownership validation. + - When the domain already exists and is not yet validated, re-triggers + validation. Success means "triggered," not "validated." Has no effect + when C(state=absent) or when + the domain is already validated. + type: bool + default: false +extends_documentation_fragment: + - community.ansible_netbird.netbird +attributes: + check_mode: + description: Can run in C(check_mode) and predict changes without modifying the target. + support: full + diff_mode: + description: This module does not report a diff of the changes it makes. + support: none +requirements: + - python >= 3.9 +''' + +EXAMPLES = r''' +- name: Create a custom domain + community.ansible_netbird.netbird_service_domain: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + domain: "app.example.com" + target_cluster: "eu.proxy.netbird.io" + state: present + +- name: Create and validate a custom domain + community.ansible_netbird.netbird_service_domain: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + domain: "app.example.com" + target_cluster: "eu.proxy.netbird.io" + validate: true + state: present + +- name: Delete a custom domain + community.ansible_netbird.netbird_service_domain: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + domain: "app.example.com" + state: absent + +- name: Validate a custom domain + community.ansible_netbird.netbird_service_domain: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + domain: "app.example.com" + validate: true + state: present +''' + +RETURN = r''' +domain_info: + description: The custom domain object. + returned: success + type: dict +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.ansible_netbird.plugins.module_utils.netbird_api import ( + NetBirdAPI, + NetBirdAPIError, + netbird_argument_spec +) + + +def find_domain_by_name(api, domain_name): + """Find a custom domain by its domain name. Ignore proxy clusters.""" + domains, _unused = api.list_service_domains() + for domain in (domains or []): + if domain.get('type') != 'custom': + continue + if domain.get('domain') == domain_name: + return domain + return None + + +def run_module(): + """Main module execution.""" + argument_spec = netbird_argument_spec() + argument_spec.update( + state=dict(type='str', choices=['present', 'absent'], default='present'), + domain=dict(type='str', required=True), + target_cluster=dict(type='str'), + validate=dict(type='bool', default=False), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ('state', 'present', ['target_cluster']), + ], + ) + + api = NetBirdAPI( + module, + module.params['api_url'], + module.params['api_token'], + module.params['validate_certs'], + timeout=module.params['timeout'] + ) + + state = module.params['state'] + domain_name = module.params['domain'] + target_cluster = module.params['target_cluster'] + do_validate = module.params['validate'] + + result = dict(changed=False, domain_info={}) + + try: + existing = find_domain_by_name(api, domain_name) + + if state == 'absent': + if existing: + if not module.check_mode: + api.delete_service_domain(existing['id']) + result['changed'] = True + result['msg'] = 'Custom domain deleted successfully' + module.exit_json(**result) + + # state == 'present' + if existing: + if existing.get('target_cluster') != target_cluster: + module.warn( + f"Changing target_cluster for '{domain_name}' requires " + f"delete+recreate: new domain ID, validation reset, " + f"and services using this domain may break." + ) + if not module.check_mode: + # Delete the domain before re-creating it + api.delete_service_domain(existing['id']) + # Try to re-create the domain on the new cluster + created = None + try: + created, _unused = api.create_service_domain({ + 'domain': domain_name, + 'target_cluster': target_cluster, + }) + # If the re-creation fails, try to rollback to the original cluster + except NetBirdAPIError as create_err: + rollback = None + try: + rollback, _unused = api.create_service_domain({ + 'domain': domain_name, + 'target_cluster': existing.get('target_cluster'), + }) + except NetBirdAPIError: + module.fail_json( + msg=( + f"Failed to re-create domain '{domain_name}' " + f"on cluster '{target_cluster}' and rollback to " + f"'{existing.get('target_cluster')}' also " + f"failed: {create_err}" + ) + ) + if do_validate and isinstance(rollback, dict) and rollback.get('id'): + api.validate_service_domain(rollback['id']) + module.fail_json( + msg=( + f"Failed to re-create domain '{domain_name}' " + f"on cluster '{target_cluster}'; rolled back to " + f"'{existing.get('target_cluster')}': {create_err}" + ), + domain_info=rollback, + ) + if not isinstance(created, dict) or not created.get('id'): + module.fail_json( + msg=( + f"Unexpected response when creating domain " + f"'{domain_name}': {created!r}" + ) + ) + if do_validate: + api.validate_service_domain(created['id']) + result['domain_info'] = created + else: + result['domain_info'] = existing + result['changed'] = True + else: + if do_validate and not existing.get('validated', False): + if not module.check_mode: + api.validate_service_domain(existing['id']) + result['changed'] = True + result['domain_info'] = existing + + else: + if not module.check_mode: + created, _unused = api.create_service_domain({ + 'domain': domain_name, + 'target_cluster': target_cluster, + }) + if do_validate: + api.validate_service_domain(created['id']) + result['domain_info'] = created + result['changed'] = True + + module.exit_json(**result) + + except NetBirdAPIError as e: + module.fail_json(msg=str(e), status_code=e.status_code, response=e.response) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/roles/configure/tasks/main.yml b/roles/configure/tasks/main.yml index e2ba3fa..622d9f8 100644 --- a/roles/configure/tasks/main.yml +++ b/roles/configure/tasks/main.yml @@ -3,7 +3,7 @@ # # Applies NetBird logical configuration from YAML files via the NetBird REST API. # Manages groups, policies, networks, DNS (nameservers + zones), posture checks, -# and account settings. +# services, service domains, and account settings. # # Runs in preview mode by default — shows a read-only diff of what would change. # To apply changes, set commit=true. Strict mode (strict=true) removes resources @@ -16,7 +16,9 @@ # 4. Setup keys (depend on groups for auto_groups) # 5. DNS (depend on groups) # 6. Networks (depend on groups) -# 7. Policies (depend on groups + posture checks) +# 7. Service domains (no dependencies) +# 8. Services (depend on groups for access_groups) +# 9. Policies (depend on groups + posture checks) # # Required variables: # - config_dir: Path to your config directory @@ -108,6 +110,17 @@ file: "{{ _config_dir_abs }}/setup_keys.yml" when: _setup_keys_file.stat.exists +- name: Check if services config exists + ansible.builtin.stat: + path: "{{ _config_dir_abs }}/services.yml" + register: _services_file + delegate_to: localhost + +- name: Load services + ansible.builtin.include_vars: + file: "{{ _config_dir_abs }}/services.yml" + when: _services_file.stat.exists + - name: Display configuration summary ansible.builtin.debug: msg: @@ -120,6 +133,8 @@ - " DNS nameservers: {{ netbird_dns_nameserver_groups | default([]) | length }}" - " DNS zones: {{ netbird_dns_zones | default([]) | length }}" - " Networks: {{ netbird_networks | default([]) | length }}" + - " Services: {{ netbird_services | default([]) | length }}" + - " Service domains: {{ netbird_service_domains | default([]) | length }}" # ========================================================================= # All API-calling tasks wrapped in a block with module_defaults @@ -228,6 +243,7 @@ + (netbird_setup_keys | default([]) | community.ansible_netbird.netbird_missing_refs('setup_key', group_ids=_pf_group_ids)) + (netbird_dns_nameserver_groups | default([]) | community.ansible_netbird.netbird_missing_refs('dns_nameserver_group', group_ids=_pf_group_ids)) + (netbird_dns_zones | default([]) | community.ansible_netbird.netbird_missing_refs('dns_zone', group_ids=_pf_group_ids)) + + (netbird_services | default([]) | community.ansible_netbird.netbird_missing_refs('service', group_ids=_pf_group_ids)) + (netbird_dns_disabled_management_groups | default([]) | community.ansible_netbird.netbird_missing_names(_pf_group_ids, 'group', 'DNS disabled_management_groups')) + ((netbird_settings | default({})).jwt_allow_groups | default([]) | community.ansible_netbird.netbird_missing_names(_pf_group_ids, 'group', 'settings jwt_allow_groups')) + ((netbird_settings | default({})).peer_expose_groups | default([]) | community.ansible_netbird.netbird_missing_names(_pf_group_ids, 'group', 'settings peer_expose_groups')) @@ -278,7 +294,8 @@ (netbird_dns_nameserver_groups | default([])) + (netbird_dns_zones | default([])) + (netbird_setup_keys | default([])) + - (netbird_posture_checks | default([])) + (netbird_posture_checks | default([])) + + (netbird_services | default([])) ) | length > 0 quiet: true fail_msg: | @@ -529,6 +546,18 @@ register: api_dns_zones when: not commit | bool or strict | bool + - name: Fetch current services + community.ansible_netbird.netbird_info: + resource: services + register: api_services + when: not commit | bool or strict | bool + + - name: Fetch current service domains + community.ansible_netbird.netbird_info: + resource: service_domains + register: api_service_domains + when: not commit | bool or strict | bool + - name: Fetch current policies community.ansible_netbird.netbird_info: resource: policies @@ -615,7 +644,54 @@ - commit | bool - netbird_networks | default([]) | length > 0 - # --- 8. Policies (depend on groups + posture checks) --- + # --- 8. Services (depend on groups for access_groups) --- + - name: Manage service custom domains + community.ansible_netbird.netbird_service_domain: + domain: "{{ item.domain }}" + target_cluster: "{{ item.target_cluster | default(omit) }}" + validate: "{{ item.validate | default(false) }}" + state: "{{ item.state | default('present') }}" + loop: "{{ netbird_service_domains | default([]) }}" + loop_control: + label: "{{ item.domain }}" + when: + - commit | bool + - netbird_service_domains | default([]) | length > 0 + + - name: Resolve access_groups in services + ansible.builtin.set_fact: + _resolved_services: >- + {{ netbird_services | default([]) + | community.ansible_netbird.netbird_resolve_ids( + 'service', group_ids=group_ids) }} + when: + - commit | bool + - netbird_services | default([]) | length > 0 + + - name: Manage services + community.ansible_netbird.netbird_service: + domain: "{{ item.domain }}" + name: "{{ item.name | default(omit) }}" + mode: "{{ item.mode | default(omit) }}" + private: "{{ item.private | default(omit) }}" + enabled: "{{ item.enabled | default(omit) }}" + listen_port: "{{ item.listen_port | default(omit) }}" + pass_host_header: "{{ item.pass_host_header | default(omit) }}" + rewrite_redirects: "{{ item.rewrite_redirects | default(omit) }}" + access_groups: "{{ item.access_groups | default(omit) }}" + targets: "{{ item.targets | default(omit) }}" + auth: "{{ item.auth | default(omit) }}" + state: "{{ item.state | default('present') }}" + loop: >- + {{ _resolved_services + | default(netbird_services | default([])) }} + loop_control: + label: "{{ item.domain }}" + when: + - commit | bool + - netbird_services | default([]) | length > 0 + + # --- 9. Policies (depend on groups + posture checks) --- - name: Manage policies community.ansible_netbird.netbird_policy: name: "{{ item.name }}" @@ -654,6 +730,8 @@ + (netbird_dns_zones | default([]) | map(attribute='domain', default='') | list) }} _yaml_net_names: "{{ netbird_networks | default([]) | map(attribute='name') | list }}" _yaml_pol_names: "{{ netbird_policies | default([]) | map(attribute='name') | list }}" + _yaml_svc_domains: "{{ netbird_services | default([]) | map(attribute='domain') | list }}" + _yaml_svc_dom_domains: "{{ netbird_service_domains | default([]) | map(attribute='domain') | list }}" when: commit | bool and strict | bool - name: "Strict: Identify unmanaged resources" @@ -665,6 +743,8 @@ _orphaned_setup_keys: "{{ api_setup_keys.data | map(attribute='name') | list | difference(_yaml_sk_names) }}" _orphaned_posture_checks: "{{ api_posture_checks.data | map(attribute='name') | list | difference(_yaml_pc_names) }}" _orphaned_groups: "{{ api_groups.data | map(attribute='name') | list | difference(_yaml_group_names) | difference(_protected_groups) }}" + _orphaned_services: "{{ api_services.data | default([]) | map(attribute='domain') | list | difference(_yaml_svc_domains) }}" + _orphaned_service_domains: "{{ api_service_domains.data | default([]) | selectattr('type', 'defined') | selectattr('type', 'equalto', 'custom') | map(attribute='domain') | list | difference(_yaml_svc_dom_domains) }}" when: commit | bool and strict | bool - name: "Strict: Display resources to be removed" @@ -678,6 +758,8 @@ - " Setup keys: {{ _orphaned_setup_keys | length }} ({{ _orphaned_setup_keys | join(', ') }})" - " Posture checks: {{ _orphaned_posture_checks | length }} ({{ _orphaned_posture_checks | join(', ') }})" - " Groups: {{ _orphaned_groups | length }} ({{ _orphaned_groups | join(', ') }})" + - " Services: {{ _orphaned_services | length }} ({{ _orphaned_services | join(', ') }})" + - " Service domains: {{ _orphaned_service_domains | length }} ({{ _orphaned_service_domains | join(', ') }})" when: commit | bool and strict | bool - name: "Strict: Remove unmanaged policies" @@ -735,6 +817,24 @@ label: "{{ item }}" when: commit | bool and strict | bool and _orphaned_posture_checks | length > 0 + - name: "Strict: Remove unmanaged services" + community.ansible_netbird.netbird_service: + domain: "{{ item }}" + state: absent + loop: "{{ _orphaned_services }}" + loop_control: + label: "{{ item }}" + when: commit | bool and strict | bool and _orphaned_services | length > 0 + + - name: "Strict: Remove unmanaged service domains" + community.ansible_netbird.netbird_service_domain: + domain: "{{ item }}" + state: absent + loop: "{{ _orphaned_service_domains }}" + loop_control: + label: "{{ item }}" + when: commit | bool and strict | bool and _orphaned_service_domains | length > 0 + - name: "Strict: Remove unmanaged groups" community.ansible_netbird.netbird_group: name: "{{ item }}" @@ -769,7 +869,7 @@ if not (_nb_mutations_started | default(false) | bool) else [ 'APPLY FAILED PART-WAY THROUGH — the control plane may be partially updated.', - 'Resources are applied in this order: posture checks → groups → account settings → setup keys → DNS nameservers → DNS settings → DNS zones → networks → policies.', + 'Resources are applied in this order: posture checks → groups → account settings → setup keys → DNS nameservers → DNS settings → DNS zones → networks → services → policies.', 'Everything before the failed task was applied; the failed task and everything after were NOT.', 'Failed task: ' ~ (ansible_failed_task.name | default('unknown')), 'Error: ' ~ (ansible_failed_result.msg | default(ansible_failed_result | to_json)), diff --git a/roles/configure/tasks/preview_diff_report.yml b/roles/configure/tasks/preview_diff_report.yml index 1e4c1b4..3319512 100644 --- a/roles/configure/tasks/preview_diff_report.yml +++ b/roles/configure/tasks/preview_diff_report.yml @@ -26,6 +26,8 @@ current_dns_zones_map: "{{ dict(api_dns_zones.data | map(attribute='name') | zip(api_dns_zones.data)) }}" current_networks_map: "{{ dict(api_networks.data | map(attribute='name') | zip(api_networks.data)) }}" current_policies_map: "{{ dict(api_policies.data | map(attribute='name') | zip(api_policies.data)) }}" + current_services_map: "{{ dict(api_services.data | default([]) | map(attribute='domain') | zip(api_services.data | default([]))) }}" + current_service_domains_map: "{{ dict(api_service_domains.data | default([]) | selectattr('type', 'defined') | selectattr('type', 'equalto', 'custom') | map(attribute='domain') | zip(api_service_domains.data | default([]) | selectattr('type', 'defined') | selectattr('type', 'equalto', 'custom'))) }}" - name: Build reverse lookup maps ansible.builtin.set_fact: @@ -64,6 +66,8 @@ diff_dz_data: "{{ netbird_dns_zones | default([]) | community.ansible_netbird.netbird_diff(current_dns_zones_map) }}" diff_net_data: "{{ netbird_networks | default([]) | community.ansible_netbird.netbird_diff(current_networks_enriched, 'network', peer_ids=peer_ids, peer_id_name=peer_id_name) }}" diff_pol_data: "{{ netbird_policies | default([]) | community.ansible_netbird.netbird_diff(current_policies_map, 'policy') }}" + diff_svc_data: "{{ netbird_services | default([]) | community.ansible_netbird.netbird_diff(current_services_map, 'service', key_field='domain', group_ids=group_ids) }}" + diff_svc_dom_data: "{{ netbird_service_domains | default([]) | community.ansible_netbird.netbird_diff(current_service_domains_map, 'simple', key_field='domain') }}" # ========================================================================= # Format and display @@ -84,6 +88,8 @@ + diff_dns_data | community.ansible_netbird.netbird_format_diff('DNS Nameserver Groups') + [''] + diff_dz_data | community.ansible_netbird.netbird_format_diff('DNS Zones') + [''] + diff_net_data | community.ansible_netbird.netbird_format_diff('Networks') + [''] + + diff_svc_data | community.ansible_netbird.netbird_format_diff('Services') + [''] + + diff_svc_dom_data | community.ansible_netbird.netbird_format_diff('Service Domains') + [''] + diff_pol_data | community.ansible_netbird.netbird_format_diff('Policies') }} # ========================================================================= @@ -95,9 +101,9 @@ - "══════════════════════════════════════════════════════════════" - " SUMMARY — {{ _config_dir_abs | basename | upper }}{{ ' (STRICT)' if strict | default(false) | bool else '' }}" - "──────────────────────────────────────────────────────────────" - - " + Add: {{ (diff_groups_data.new | length) + (diff_pc_data.new | length) + (diff_sk_data.new | length) + (diff_dns_data.new | length) + (diff_dz_data.new | length) + (diff_net_data.new | length) + (diff_pol_data.new | length) }} resource(s)" - - " ~ Changed: {{ (diff_groups_data.changed | length) + (diff_pc_data.changed | length) + (diff_sk_data.changed | length) + (diff_dns_data.changed | length) + (diff_dz_data.changed | length) + (diff_net_data.changed | length) + (diff_pol_data.changed | length) }} resource(s)" - - " = Unchanged: {{ (diff_groups_data.unchanged | length) + (diff_pc_data.unchanged | length) + (diff_sk_data.unchanged | length) + (diff_dns_data.unchanged | length) + (diff_dz_data.unchanged | length) + (diff_net_data.unchanged | length) + (diff_pol_data.unchanged | length) }} resource(s)" - - " - Remove: {{ (diff_groups_data.remove | length) + (diff_pc_data.remove | length) + (diff_sk_data.remove | length) + (diff_dns_data.remove | length) + (diff_dz_data.remove | length) + (diff_net_data.remove | length) + (diff_pol_data.remove | length) }} resource(s)" - - "{% set total_orphan = (diff_groups_data.orphaned | length) + (diff_pc_data.orphaned | length) + (diff_sk_data.orphaned | length) + (diff_dns_data.orphaned | length) + (diff_dz_data.orphaned | length) + (diff_net_data.orphaned | length) + (diff_pol_data.orphaned | length) %}{% if total_orphan > 0 %} - Orphan: {{ total_orphan }} resource(s) (not in config{{ ', will be removed with strict=true' if not strict | default(false) | bool else ', WILL BE REMOVED' }}){% endif %}" + - " + Add: {{ (diff_groups_data.new | length) + (diff_pc_data.new | length) + (diff_sk_data.new | length) + (diff_dns_data.new | length) + (diff_dz_data.new | length) + (diff_net_data.new | length) + (diff_svc_data.new | length) + (diff_svc_dom_data.new | length) + (diff_pol_data.new | length) }} resource(s)" + - " ~ Changed: {{ (diff_groups_data.changed | length) + (diff_pc_data.changed | length) + (diff_sk_data.changed | length) + (diff_dns_data.changed | length) + (diff_dz_data.changed | length) + (diff_net_data.changed | length) + (diff_svc_data.changed | length) + (diff_svc_dom_data.changed | length) + (diff_pol_data.changed | length) }} resource(s)" + - " = Unchanged: {{ (diff_groups_data.unchanged | length) + (diff_pc_data.unchanged | length) + (diff_sk_data.unchanged | length) + (diff_dns_data.unchanged | length) + (diff_dz_data.unchanged | length) + (diff_net_data.unchanged | length) + (diff_svc_data.unchanged | length) + (diff_svc_dom_data.unchanged | length) + (diff_pol_data.unchanged | length) }} resource(s)" + - " - Remove: {{ (diff_groups_data.remove | length) + (diff_pc_data.remove | length) + (diff_sk_data.remove | length) + (diff_dns_data.remove | length) + (diff_dz_data.remove | length) + (diff_net_data.remove | length) + (diff_svc_data.remove | length) + (diff_svc_dom_data.remove | length) + (diff_pol_data.remove | length) }} resource(s)" + - "{% set total_orphan = (diff_groups_data.orphaned | length) + (diff_pc_data.orphaned | length) + (diff_sk_data.orphaned | length) + (diff_dns_data.orphaned | length) + (diff_dz_data.orphaned | length) + (diff_net_data.orphaned | length) + (diff_svc_data.orphaned | length) + (diff_svc_dom_data.orphaned | length) + (diff_pol_data.orphaned | length) %}{% if total_orphan > 0 %} - Orphan: {{ total_orphan }} resource(s) (not in config{{ ', will be removed with strict=true' if not strict | default(false) | bool else ', WILL BE REMOVED' }}){% endif %}" - "══════════════════════════════════════════════════════════════" diff --git a/roles/export/tasks/main.yml b/roles/export/tasks/main.yml index 87fe2ce..69e9d96 100644 --- a/roles/export/tasks/main.yml +++ b/roles/export/tasks/main.yml @@ -149,6 +149,28 @@ networks_enriched: [] when: networks_data.data | length == 0 + - name: Fetch services + community.ansible_netbird.netbird_info: + resource: services + register: services_data + ignore_errors: true + + - name: Fetch service domains + community.ansible_netbird.netbird_info: + resource: service_domains + register: service_domains_data + ignore_errors: true + + - name: Set empty services data when API unavailable + ansible.builtin.set_fact: + services_data: { data: [] } + when: services_data is failed + + - name: Set empty service domains data when API unavailable + ansible.builtin.set_fact: + service_domains_data: { data: [] } + when: service_domains_data is failed + - name: Fetch policies community.ansible_netbird.netbird_info: resource: policies @@ -243,6 +265,12 @@ dest: "{{ export_dir }}/networks.yml" mode: "0644" + - name: Export clean services + ansible.builtin.template: + src: export/services.yml.j2 + dest: "{{ export_dir }}/services.yml" + mode: "0644" + - name: Export clean routes (deprecated API) ansible.builtin.template: src: export/routes.yml.j2 @@ -300,6 +328,18 @@ dest: "{{ export_dir }}/raw/routes_raw.yml" mode: "0644" + - name: Export raw services + ansible.builtin.copy: + content: "{{ services_data.data | to_nice_yaml(indent=2) }}" + dest: "{{ export_dir }}/raw/services_raw.yml" + mode: "0644" + + - name: Export raw service domains + ansible.builtin.copy: + content: "{{ service_domains_data.data | to_nice_yaml(indent=2) }}" + dest: "{{ export_dir }}/raw/service_domains_raw.yml" + mode: "0644" + - name: Export raw policies ansible.builtin.copy: content: "{{ policies_data.data | to_nice_yaml(indent=2) }}" @@ -349,10 +389,11 @@ - " zones.yml ({{ dns_zones_data.data | length }} zones)" - " settings.yml (DNS settings)" - " - networks.yml ({{ networks_data.data | length }} networks)" + - " - services.yml ({{ services_data.data | default([]) | length }} services, {{ service_domains_data.data | default([]) | length }} domains)" - " - routes.yml ({{ routes_data.data | length }} routes — deprecated API)" - "" - "Raw API data (for debugging):" - - " raw/ (account, groups, posture_checks, setup_keys, dns, routes, networks, policies, users, peers)" + - " raw/ (account, groups, posture_checks, setup_keys, dns, routes, networks, policies, users, peers, services, service_domains)" - "" - "Next steps:" - " 1. Review the clean config files" diff --git a/roles/export/templates/export/services.yml.j2 b/roles/export/templates/export/services.yml.j2 new file mode 100644 index 0000000..b20c5f3 --- /dev/null +++ b/roles/export/templates/export/services.yml.j2 @@ -0,0 +1,111 @@ +--- +# NetBird Reverse-Proxy Services +# Exported from {{ netbird_api_url }} on {{ ansible_date_time.date }} +# +# Total services: {{ services_data.data | default([]) | length }} +# Total custom domains: {{ service_domains_data.data | default([]) | length }} +# +# access_groups use group names (resolved from IDs). +# target_id values are network resource IDs — keep as-is. +# Auth: bearer_auth is fully exported (no secrets). password_auth and +# pin_auth are NOT exported — the API masks secrets, so including them +# would overwrite real credentials with empty strings on apply. +# Omitting auth is safe — the module preserves existing auth on update. +# +# Reference: https://docs.netbird.io/api/resources/services + +netbird_services: +{% for svc in services_data.data | default([]) | sort(attribute='domain') %} + - domain: "{{ svc.domain }}" +{% if svc.name is defined and svc.name != svc.domain %} + name: "{{ svc.name }}" +{% endif %} +{% if svc.mode is defined and svc.mode != 'http' %} + mode: "{{ svc.mode }}" +{% endif %} +{% if svc.private is defined %} + private: {{ svc.private | lower }} +{% endif %} +{% if svc.enabled is defined %} + enabled: {{ svc.enabled | lower }} +{% endif %} +{% if svc.listen_port is defined and svc.listen_port | int > 0 %} + listen_port: {{ svc.listen_port }} +{% endif %} +{% if svc.pass_host_header is defined and svc.pass_host_header %} + pass_host_header: true +{% endif %} +{% if svc.rewrite_redirects is defined and svc.rewrite_redirects %} + rewrite_redirects: true +{% endif %} +{% if svc.access_groups is defined and svc.access_groups is not none and svc.access_groups | length > 0 %} + access_groups: +{% for grp in svc.access_groups %} +{% if grp is mapping %} + - {{ group_id_map[grp.id] | default(grp.id) }} +{% else %} + - {{ group_id_map[grp] | default(grp) }} +{% endif %} +{% endfor %} +{% endif %} +{% if svc.targets is defined and svc.targets is not none and svc.targets | length > 0 %} + targets: +{% for target in svc.targets %} + - host: "{{ target.host }}" + port: {{ target.port }} + protocol: "{{ target.protocol | default('http') }}" + target_id: "{{ target.target_id }}" + target_type: "{{ target.target_type | default('subnet') }}" + enabled: {{ target.enabled | default(true) | lower }} +{% if target.options is defined and target.options is not none %} +{% if target.options.direct_upstream | default(false) %} + direct_upstream: true +{% endif %} +{% if target.options.skip_tls_verify | default(false) %} + skip_tls_verify: true +{% endif %} +{% if target.options.path_rewrite is defined and target.options.path_rewrite != 'preserve' %} + path_rewrite: "{{ target.options.path_rewrite }}" +{% endif %} +{% endif %} +{% if target.path is defined and target.path != '/' %} + path: "{{ target.path }}" +{% endif %} +{% endfor %} +{% endif %} +{% set auth = svc.auth | default({}) %} +{% set has_bearer = (auth.bearer_auth | default({})).get('enabled', false) %} +{% set has_password = (auth.password_auth | default({})).get('enabled', false) %} +{% set has_pin = (auth.pin_auth | default({})).get('enabled', false) %} +{% if has_bearer %} + auth: + bearer_auth: + enabled: true +{% set bearer_groups = auth.bearer_auth.get('distribution_groups') or [] %} +{% if bearer_groups | length > 0 %} + distribution_groups: +{% for gid in bearer_groups %} + - {{ group_id_map[gid] | default(gid) }} +{% endfor %} +{% endif %} +{% endif %} +{% if has_password or has_pin %} + # auth: password_auth/pin_auth enabled but secrets cannot be exported + # — re-add manually before applying (omitting preserves existing auth) +{% endif %} + state: present +{% endfor %} +{% if services_data.data | default([]) | length == 0 %} + [] +{% endif %} + +netbird_service_domains: +{% for dom in service_domains_data.data | default([]) | sort(attribute='domain') %} +{% if dom.type | default('') == 'custom' %} + - domain: "{{ dom.domain }}" + target_cluster: "{{ dom.target_cluster }}" + state: present +{% endif %} +{% endfor %} +{% if service_domains_data.data | default([]) | selectattr('type', 'defined') | selectattr('type', 'equalto', 'custom') | list | length == 0 %} [] +{% endif %} diff --git a/tasks/main.yml b/tasks/main.yml index 2805699..32c68d3 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -50,6 +50,16 @@ - netbird - netbird-networks +- name: Include service management tasks + ansible.builtin.include_tasks: services.yml + when: >- + netbird_services | length > 0 + or netbird_service_domains | length > 0 + or netbird_proxy_clusters_absent | length > 0 + tags: + - netbird + - netbird-services + - name: Include route management tasks ansible.builtin.include_tasks: routes.yml when: netbird_routes | length > 0 diff --git a/tasks/services.yml b/tasks/services.yml new file mode 100644 index 0000000..cd2c2c9 --- /dev/null +++ b/tasks/services.yml @@ -0,0 +1,50 @@ +--- +# tasks/services.yml - Service, domain, and cluster management + +- name: Manage NetBird service custom domains + community.ansible_netbird.netbird_service_domain: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + validate_certs: "{{ netbird_validate_certs }}" + domain: "{{ item.domain }}" + target_cluster: "{{ item.target_cluster | default(omit) }}" + validate: "{{ item.validate | default(false) }}" + state: "{{ item.state | default('present') }}" + loop: "{{ netbird_service_domains }}" + loop_control: + label: "{{ item.domain }}" + when: netbird_service_domains | length > 0 + +- name: Manage NetBird reverse-proxy services + community.ansible_netbird.netbird_service: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + validate_certs: "{{ netbird_validate_certs }}" + domain: "{{ item.domain }}" + name: "{{ item.name | default(omit) }}" + mode: "{{ item.mode | default(omit) }}" + private: "{{ item.private | default(omit) }}" + enabled: "{{ item.enabled | default(omit) }}" + listen_port: "{{ item.listen_port | default(omit) }}" + pass_host_header: "{{ item.pass_host_header | default(omit) }}" + rewrite_redirects: "{{ item.rewrite_redirects | default(omit) }}" + access_groups: "{{ item.access_groups | default(omit) }}" + targets: "{{ item.targets | default(omit) }}" + auth: "{{ item.auth | default(omit) }}" + state: "{{ item.state | default('present') }}" + loop: "{{ netbird_services }}" + loop_control: + label: "{{ item.domain }}" + when: netbird_services | length > 0 + +- name: Remove self-hosted proxy cluster registrations + community.ansible_netbird.netbird_proxy_cluster: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + validate_certs: "{{ netbird_validate_certs }}" + address: "{{ item.address }}" + state: absent + loop: "{{ netbird_proxy_clusters_absent }}" + loop_control: + label: "{{ item.address }}" + when: netbird_proxy_clusters_absent | length > 0 diff --git a/tests/integration/test_services.yml b/tests/integration/test_services.yml new file mode 100644 index 0000000..bc6433e --- /dev/null +++ b/tests/integration/test_services.yml @@ -0,0 +1,779 @@ +--- +# Integration test: services, service domains, and full smoke test +# +# Run: +# NETBIRD_API_URL=https://your.netbird.instance.com \ +# NETBIRD_API_TOKEN=your_token \ +# SMOKE_TEST=true \ +# CLEANUP=false \ +# ansible-playbook tests/integration/test_services.yml +# +# To clean up after the run: +# NETBIRD_API_URL=https://your.netbird.instance.com \ +# NETBIRD_API_TOKEN=your_token \ +# SMOKE_TEST=false \ +# CLEANUP=true \ +# ansible-playbook tests/integration/test_services.yml +# +# Resources are left for manual inspection by default. +# All test resources use the "ci-test-" prefix. + +- name: Integration test — services and smoke test + hosts: localhost + connection: local + gather_facts: false + + vars: + netbird_api_url: "{{ lookup('env', 'NETBIRD_API_URL') }}" + netbird_api_token: "{{ lookup('env', 'NETBIRD_API_TOKEN') }}" + test_prefix: "{{ lookup('env', 'TEST_PREFIX') | default('ci-test-', true) }}" + smoke_test: "{{ lookup('env', 'SMOKE_TEST') | default('true', true) | bool }}" + cleanup: "{{ lookup('env', 'CLEANUP') | default('false', true) | bool }}" + _test_password: "{{ lookup('password', '/dev/null length=24') }}" + _test_pin: "{{ 999999 | random(start=100000) }}" + + module_defaults: + group/community.ansible_netbird.netbird: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + + tasks: + # ============================================================= + # PHASE 0: Connectivity + # ============================================================= + - name: "Phase 0: Verify API connectivity" + community.ansible_netbird.netbird_info: + resource: current_user + register: _current_user + + - name: "Phase 0: Show connected user" + ansible.builtin.debug: + msg: >- + Connected to {{ netbird_api_url }} + as {{ _current_user.data.name }} + ({{ _current_user.data.email + | default('no email') }}) + + # ============================================================= + # Prerequisites: groups needed by Phase 1 and Phase 4 + # ============================================================= + - name: "Setup: Create test group — services" + community.ansible_netbird.netbird_group: + name: "{{ test_prefix }}services-group" + state: present + register: _group_svc + + - name: "Setup: Create test group — access" + community.ansible_netbird.netbird_group: + name: "{{ test_prefix }}access-group" + state: present + register: _group_access + + # ============================================================= + # PHASE 1: Smoke test existing features + # ============================================================= + - block: + - name: "Phase 1: Assert groups created" + ansible.builtin.assert: + that: + - _group_svc.group.name == test_prefix + "services-group" + - _group_svc.group.id | length > 0 + - _group_access.group.id | length > 0 + quiet: true + + - name: "Phase 1: List groups — verify ours exist" + community.ansible_netbird.netbird_info: + resource: groups + register: _groups + + - name: "Phase 1: Assert test groups in list" + ansible.builtin.assert: + that: + - >- + _groups.data + | selectattr('name', 'equalto', + test_prefix + 'services-group') + | list | length == 1 + - >- + _groups.data + | selectattr('name', 'equalto', + test_prefix + 'access-group') + | list | length == 1 + quiet: true + + - name: "Phase 1: Create test network (no routers)" + community.ansible_netbird.netbird_network: + name: "{{ test_prefix }}network" + description: "Integration test network" + state: present + register: _network + + - name: "Phase 1: Assert network created" + ansible.builtin.assert: + that: + - _network.network.name == test_prefix + "network" + quiet: true + + - name: "Phase 1: Create test policy" + community.ansible_netbird.netbird_policy: + name: "{{ test_prefix }}policy" + description: "Integration test policy" + enabled: true + rules: + - name: "{{ test_prefix }}rule" + sources: + - "{{ _group_svc.group.id }}" + destinations: + - "{{ _group_access.group.id }}" + bidirectional: true + protocol: "all" + action: "accept" + state: present + register: _policy + + - name: "Phase 1: Assert policy created" + ansible.builtin.assert: + that: + - _policy.policy.name == test_prefix + "policy" + quiet: true + + - name: "Phase 1: Verify networks list" + community.ansible_netbird.netbird_info: + resource: networks + register: _networks + + - name: "Phase 1: Assert network in list" + ansible.builtin.assert: + that: + - >- + _networks.data + | selectattr('name', 'equalto', + test_prefix + 'network') + | list | length == 1 + quiet: true + + - name: "Phase 1: Verify policies list" + community.ansible_netbird.netbird_info: + resource: policies + register: _policies + + - name: "Phase 1: Assert policy in list" + ansible.builtin.assert: + that: + - >- + _policies.data + | selectattr('name', 'equalto', + test_prefix + 'policy') + | list | length == 1 + quiet: true + + - name: "Phase 1: PASSED" + ansible.builtin.debug: + msg: "Smoke test passed — groups, networks, policies OK" + + when: smoke_test is defined and smoke_test + + # ============================================================= + # PHASE 2: New netbird_info resource types + # ============================================================= + - name: "Phase 2: List services" + community.ansible_netbird.netbird_info: + resource: services + register: _info_services + + - name: "Phase 2: Assert services returns a list" + ansible.builtin.assert: + that: + - _info_services.data is iterable + quiet: true + + - name: "Phase 2: List service domains" + community.ansible_netbird.netbird_info: + resource: service_domains + register: _info_domains + + - name: "Phase 2: Assert service_domains returns a list" + ansible.builtin.assert: + that: + - _info_domains.data is iterable + quiet: true + + - name: "Phase 2: List proxy clusters" + community.ansible_netbird.netbird_info: + resource: proxy_clusters + register: _info_clusters + + - name: "Phase 2: Assert proxy_clusters returns a list" + ansible.builtin.assert: + that: + - _info_clusters.data is iterable + quiet: true + + - name: "Phase 2: Show available clusters" + ansible.builtin.debug: + msg: >- + Found {{ _info_clusters.data | length }} + proxy cluster(s): + {{ _info_clusters.data + | map(attribute='address') + | list }} + when: _info_clusters.data | length > 0 + + - name: "Phase 2: No proxy clusters available" + ansible.builtin.debug: + msg: >- + No proxy clusters found — service domain + tests will be skipped + when: _info_clusters.data | length == 0 + + - name: "Phase 2: PASSED" + ansible.builtin.debug: + msg: >- + New info resource types OK — + {{ _info_services.data | length }} services, + {{ _info_domains.data | length }} domains, + {{ _info_clusters.data | length }} clusters + + # ============================================================= + # PHASE 2b: Proxy cluster module — noop delete + # ============================================================= + - name: "Phase 2b: Delete non-existent cluster — noop" + community.ansible_netbird.netbird_proxy_cluster: + address: "{{ test_prefix }}nonexistent.proxy.example.com" + state: absent + register: _cluster_del_noop + + - name: "Phase 2b: Assert noop" + ansible.builtin.assert: + that: + - not _cluster_del_noop.changed + quiet: true + + - name: "Phase 2b: PASSED" + ansible.builtin.debug: + msg: "Proxy cluster module OK — noop delete verified" + + # ============================================================= + # PHASE 3: Service domain management + # ============================================================= + - name: "Phase 3: Derive cluster address" + ansible.builtin.set_fact: + _cluster_addr: >- + {{ netbird_api_url + | regex_replace('^https?://', '') }} + + - name: "Phase 3: Pick target cluster" + ansible.builtin.set_fact: + _target_cluster: >- + {{ _info_clusters.data[0].address }} + when: _info_clusters.data | length > 0 + + - name: "Phase 3: Derive base domain" + ansible.builtin.set_fact: + _base_domain: >- + {{ _info_domains.data[0].domain + | default('netbird.app') }} + + - name: "Phase 3a: Create custom domain" + community.ansible_netbird.netbird_service_domain: + domain: "{{ test_prefix }}app.example.com" + target_cluster: "{{ _target_cluster }}" + state: present + register: _domain_create + when: _target_cluster is defined + + - name: "Phase 3a: Assert domain created" + ansible.builtin.assert: + that: + - _domain_create.changed + quiet: true + when: >- + _target_cluster is defined + and _domain_create is defined + + - name: "Phase 3b: Idempotency — same domain" + community.ansible_netbird.netbird_service_domain: + domain: "{{ test_prefix }}app.example.com" + target_cluster: "{{ _target_cluster }}" + state: present + register: _domain_idem + when: _target_cluster is defined + + - name: "Phase 3b: Assert idempotent" + ansible.builtin.assert: + that: + - not _domain_idem.changed + quiet: true + when: _target_cluster is defined + + - name: "Phase 3c: Delete custom domain" + community.ansible_netbird.netbird_service_domain: + domain: "{{ test_prefix }}app.example.com" + state: absent + register: _domain_delete + when: _target_cluster is defined + + - name: "Phase 3c: Assert domain deleted" + ansible.builtin.assert: + that: + - _domain_delete.changed + quiet: true + when: >- + _target_cluster is defined + and _domain_delete is defined + + - name: "Phase 3d: Delete absent domain — noop" + community.ansible_netbird.netbird_service_domain: + domain: "{{ test_prefix }}app.example.com" + state: absent + register: _domain_del_noop + when: _target_cluster is defined + + - name: "Phase 3d: Assert noop" + ansible.builtin.assert: + that: + - not _domain_del_noop.changed + quiet: true + when: _target_cluster is defined + + - name: "Phase 3e: Re-create domain after delete" + community.ansible_netbird.netbird_service_domain: + domain: "{{ test_prefix }}app.example.com" + target_cluster: "{{ _target_cluster }}" + state: present + register: _domain_recreate + when: _target_cluster is defined + + - name: "Phase 3e: Assert re-created" + ansible.builtin.assert: + that: + - _domain_recreate.changed + quiet: true + when: >- + _target_cluster is defined + and _domain_recreate is defined + + - name: "Phase 3: PASSED" + ansible.builtin.debug: + msg: >- + {{ 'Domain tests passed (create, idem, delete, noop, re-create)' + if _target_cluster is defined + else 'SKIPPED — no proxy clusters' }} + + # ============================================================= + # PHASE 4: Service — private with access_groups + # ============================================================= + - name: "Phase 4a: Create private service" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}private.{{ _base_domain }}" + name: "{{ test_prefix }}private-svc" + mode: http + private: true + enabled: true + access_groups: + - "{{ _group_access.group.id }}" + targets: + - target_id: "{{ _cluster_addr }}" + target_type: cluster + host: "127.0.0.1" + port: 8080 + state: present + register: _svc_private + + - name: "Phase 4a: Assert private service" + ansible.builtin.assert: + that: + - _svc_private.changed + - _svc_private.service.private == true + - _svc_private.service.mode == 'http' + - _svc_private.service.enabled == true + - _svc_private.service.id | length > 0 + quiet: true + + - name: "Phase 4a: Idempotency" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}private.{{ _base_domain }}" + name: "{{ test_prefix }}private-svc" + mode: http + private: true + enabled: true + access_groups: + - "{{ _group_access.group.id }}" + targets: + - target_id: "{{ _cluster_addr }}" + target_type: cluster + host: "127.0.0.1" + port: 8080 + state: present + register: _svc_private_idem + + - name: "Phase 4a: Assert idempotent" + ansible.builtin.assert: + that: + - not _svc_private_idem.changed + quiet: true + + # ============================================================= + # PHASE 4b: Service — update enabled/disabled + # ============================================================= + - name: "Phase 4b: Disable service" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}private.{{ _base_domain }}" + enabled: false + state: present + register: _svc_disable + + - name: "Phase 4b: Assert disabled" + ansible.builtin.assert: + that: + - _svc_disable.changed + - _svc_disable.service.enabled == false + quiet: true + + - name: "Phase 4b: Re-enable service" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}private.{{ _base_domain }}" + enabled: true + state: present + register: _svc_reenable + + - name: "Phase 4b: Assert re-enabled" + ansible.builtin.assert: + that: + - _svc_reenable.changed + - _svc_reenable.service.enabled == true + quiet: true + + # ============================================================= + # PHASE 4c: Service — pass_host_header + rewrite_redirects + # ============================================================= + - name: "Phase 4c: Create service with proxy headers" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}headers.{{ _base_domain }}" + name: "{{ test_prefix }}headers-svc" + mode: http + private: true + enabled: true + pass_host_header: true + rewrite_redirects: true + access_groups: + - "{{ _group_svc.group.id }}" + targets: + - target_id: "{{ _cluster_addr }}" + target_type: cluster + host: "10.0.0.1" + port: 443 + protocol: https + skip_tls_verify: true + direct_upstream: true + state: present + register: _svc_headers + + - name: "Phase 4c: Assert header options" + ansible.builtin.assert: + that: + - _svc_headers.changed + - _svc_headers.service.pass_host_header == true + - _svc_headers.service.rewrite_redirects == true + quiet: true + + - name: "Phase 4c: Idempotency" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}headers.{{ _base_domain }}" + name: "{{ test_prefix }}headers-svc" + mode: http + private: true + enabled: true + pass_host_header: true + rewrite_redirects: true + access_groups: + - "{{ _group_svc.group.id }}" + targets: + - target_id: "{{ _cluster_addr }}" + target_type: cluster + host: "10.0.0.1" + port: 443 + protocol: https + skip_tls_verify: true + direct_upstream: true + state: present + register: _svc_headers_idem + + - name: "Phase 4c: Assert idempotent" + ansible.builtin.assert: + that: + - not _svc_headers_idem.changed + quiet: true + + # ============================================================= + # PHASE 4d: Service — path-based routing (multiple targets) + # ============================================================= + - name: "Phase 4d: Create service with path routing" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}paths.{{ _base_domain }}" + name: "{{ test_prefix }}paths-svc" + mode: http + private: true + enabled: true + access_groups: + - "{{ _group_access.group.id }}" + targets: + - target_id: "{{ _cluster_addr }}" + target_type: cluster + host: "10.0.1.1" + port: 8080 + path: /api + path_rewrite: preserve + - target_id: "{{ _cluster_addr }}" + target_type: cluster + host: "10.0.1.2" + port: 3000 + path: /app + path_rewrite: preserve + state: present + register: _svc_paths + + - name: "Phase 4d: Assert multi-target" + ansible.builtin.assert: + that: + - _svc_paths.changed + - _svc_paths.service.targets | length == 2 + quiet: true + + # ============================================================= + # PHASE 4e: Service — public with password auth + # ============================================================= + - name: "Phase 4e: Create public service with password" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}pwauth.{{ _base_domain }}" + name: "{{ test_prefix }}pwauth-svc" + mode: http + private: false + enabled: true + auth: + password_auth: + enabled: true + password: "{{ _test_password }}" + targets: + - target_id: "{{ _cluster_addr }}" + target_type: cluster + host: "10.0.2.1" + port: 8080 + state: present + register: _svc_pwauth + + - name: "Phase 4e: Assert public + password auth" + ansible.builtin.assert: + that: + - _svc_pwauth.changed + - _svc_pwauth.service.private == false + - >- + _svc_pwauth.service.auth.password_auth.enabled + == true + quiet: true + + # ============================================================= + # PHASE 4f: Service — public with PIN auth + # ============================================================= + - name: "Phase 4f: Create public service with PIN" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}pinauth.{{ _base_domain }}" + name: "{{ test_prefix }}pinauth-svc" + mode: http + private: false + enabled: true + auth: + pin_auth: + enabled: true + pin: "{{ _test_pin }}" + targets: + - target_id: "{{ _cluster_addr }}" + target_type: cluster + host: "10.0.3.1" + port: 9090 + state: present + register: _svc_pinauth + + - name: "Phase 4f: Assert PIN auth" + ansible.builtin.assert: + that: + - _svc_pinauth.changed + - >- + _svc_pinauth.service.auth.pin_auth.enabled + == true + quiet: true + + # ============================================================= + # PHASE 4g: Service — public with bearer/SSO auth + # ============================================================= + - name: "Phase 4g: Create public service with bearer" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}bearer.{{ _base_domain }}" + name: "{{ test_prefix }}bearer-svc" + mode: http + private: false + enabled: true + auth: + bearer_auth: + enabled: true + distribution_groups: + - "{{ _group_access.group.id }}" + targets: + - target_id: "{{ _cluster_addr }}" + target_type: cluster + host: "10.0.4.1" + port: 8443 + protocol: https + skip_tls_verify: true + state: present + register: _svc_bearer + + - name: "Phase 4g: Assert bearer auth" + ansible.builtin.assert: + that: + - _svc_bearer.changed + - >- + _svc_bearer.service.auth.bearer_auth.enabled + == true + quiet: true + + # ============================================================= + # PHASE 4h: Service — delete and re-create + # ============================================================= + - name: "Phase 4h: Delete private service" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}private.{{ _base_domain }}" + state: absent + register: _svc_delete + + - name: "Phase 4h: Assert deleted" + ansible.builtin.assert: + that: + - _svc_delete.changed + quiet: true + + - name: "Phase 4h: Delete absent service — noop" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}private.{{ _base_domain }}" + state: absent + register: _svc_del_noop + + - name: "Phase 4h: Assert noop" + ansible.builtin.assert: + that: + - not _svc_del_noop.changed + quiet: true + + - name: "Phase 4h: Re-create private service" + community.ansible_netbird.netbird_service: + domain: "{{ test_prefix }}private.{{ _base_domain }}" + name: "{{ test_prefix }}private-svc-v2" + mode: http + private: true + enabled: true + access_groups: + - "{{ _group_svc.group.id }}" + - "{{ _group_access.group.id }}" + targets: + - target_id: "{{ _cluster_addr }}" + target_type: cluster + host: "127.0.0.1" + port: 9999 + state: present + register: _svc_recreate + + - name: "Phase 4h: Assert re-created" + ansible.builtin.assert: + that: + - _svc_recreate.changed + - >- + _svc_recreate.service.name + == test_prefix + 'private-svc-v2' + quiet: true + + - name: "Phase 4: PASSED" + ansible.builtin.debug: + msg: >- + Service tests passed: + private, disable/enable, headers+tls, + path routing, password auth, PIN auth, + bearer auth, delete/noop/re-create + + # ============================================================= + # PHASE 5: Summary + # ============================================================= + - name: "SUMMARY: All tests passed" + ansible.builtin.debug: + msg: + - "All integration tests PASSED" + - "" + - "Resources left in preprod ({{ test_prefix }}*):" + - " Groups: services-group, access-group" + - " Network: network" + - " Policy: policy" + - " Services:" + - " - {{ test_prefix }}private (re-created v2)" + - " - {{ test_prefix }}headers (pass_host+rewrite)" + - " - {{ test_prefix }}paths (multi-target)" + - " - {{ test_prefix }}pwauth (password)" + - " - {{ test_prefix }}pinauth (PIN)" + - " - {{ test_prefix }}bearer (SSO)" + - >- + {{ ' Domain: ' + test_prefix + 'app.example.com' + if _target_cluster is defined + else ' Domain: (skipped)' }} + + # ============================================================= + # PHASE 6: Cleanup (reverse order of creation) + # ============================================================= + - name: "Phase 6: Cleanup" + block: + - name: "Cleanup: Delete services" + community.ansible_netbird.netbird_service: + domain: "{{ item }}" + state: absent + loop: + - "{{ test_prefix }}private.{{ _base_domain }}" + - "{{ test_prefix }}headers.{{ _base_domain }}" + - "{{ test_prefix }}paths.{{ _base_domain }}" + - "{{ test_prefix }}pwauth.{{ _base_domain }}" + - "{{ test_prefix }}pinauth.{{ _base_domain }}" + - "{{ test_prefix }}bearer.{{ _base_domain }}" + loop_control: + label: "{{ item }}" + ignore_errors: true + + - name: "Cleanup: Delete custom domain" + community.ansible_netbird.netbird_service_domain: + domain: "{{ test_prefix }}app.example.com" + state: absent + when: _target_cluster is defined + ignore_errors: true + + - name: "Cleanup: Delete policy" + community.ansible_netbird.netbird_policy: + name: "{{ test_prefix }}policy" + state: absent + ignore_errors: true + + - name: "Cleanup: Delete network" + community.ansible_netbird.netbird_network: + name: "{{ test_prefix }}network" + state: absent + ignore_errors: true + + - name: "Cleanup: Delete groups" + community.ansible_netbird.netbird_group: + name: "{{ item }}" + state: absent + loop: + - "{{ test_prefix }}services-group" + - "{{ test_prefix }}access-group" + loop_control: + label: "{{ item }}" + ignore_errors: true + + - name: "Cleanup: DONE" + ansible.builtin.debug: + msg: "All test resources removed" + when: cleanup | bool diff --git a/tests/unit/plugins/filter/test_netbird_diff_service.py b/tests/unit/plugins/filter/test_netbird_diff_service.py new file mode 100644 index 0000000..4f2a7a3 --- /dev/null +++ b/tests/unit/plugins/filter/test_netbird_diff_service.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2024-2026, NetBird and contributors +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +"""Unit tests for service preview diff accuracy. + +Run via: + ansible-test units --docker default + +Three shape mismatches between the exported YAML and what _compare_service +reads from the API made the preview flag every service as CHANGED on a +freshly exported config: + +1. access_groups / distribution_groups: names vs IDs — the export writes + group names, but the API returns IDs. Without resolution, every service + with groups shows drift. +2. targets: the export flattens options.direct_upstream etc. to top-level + target keys, while the API returns them in a nested options dict. +3. auth: the declared-keys filter is top-level only, so an exported + bearer-only auth reports password_auth and pin_auth as removed. + +_compare_service is pure; no request is made. +""" + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import pytest + +from ansible_collections.community.ansible_netbird.plugins.filter.netbird_diff import ( + netbird_diff, + _compare_service, +) + +GROUP_IDS = { + 'ci-test-group': 'grp-abc-123', + 'developers': 'grp-dev-001', +} + + +def api_service(**overrides): + """A service as the API returns it.""" + base = { + 'id': 'svc-1', + 'domain': 'app.example.com', + 'name': 'app.example.com', + 'mode': 'http', + 'private': False, + 'enabled': True, + 'listen_port': 0, + 'pass_host_header': False, + 'rewrite_redirects': False, + 'access_groups': [{'id': 'grp-abc-123'}], + 'targets': [{ + 'target_id': 'res-1', + 'target_type': 'subnet', + 'host': '10.0.0.1', + 'port': 8080, + 'path': '/', + 'protocol': 'http', + 'enabled': True, + 'options': { + 'direct_upstream': True, + 'skip_tls_verify': False, + 'path_rewrite': 'preserve', + 'proxy_protocol': False, + }, + }], + 'auth': { + 'bearer_auth': {'enabled': False, 'distribution_groups': []}, + 'password_auth': {'enabled': False, 'password': ''}, + 'pin_auth': {'enabled': False, 'pin': ''}, + }, + 'meta': {}, + 'proxy_cluster': 'cluster-1', + } + base.update(overrides) + return base + + +def exported_service(**overrides): + """A service as the export template writes it (module input shape).""" + base = { + 'domain': 'app.example.com', + 'private': False, + 'enabled': True, + 'access_groups': ['ci-test-group'], + 'targets': [{ + 'host': '10.0.0.1', + 'port': 8080, + 'protocol': 'http', + 'target_id': 'res-1', + 'target_type': 'subnet', + 'enabled': True, + 'direct_upstream': True, + 'skip_tls_verify': False, + }], + 'state': 'present', + } + base.update(overrides) + return base + + +class TestAccessGroupResolution: + + def test_exported_names_match_api_ids(self): + """The core fix: name→ID resolution prevents false positives.""" + diffs = _compare_service( + api_service(), exported_service(), group_ids=GROUP_IDS) + assert not any('access_groups' in d for d in diffs) + + def test_without_group_ids_names_differ(self): + diffs = _compare_service(api_service(), exported_service()) + assert any('access_groups' in d for d in diffs) + + def test_raw_ids_in_config_still_work(self): + svc = exported_service(access_groups=['grp-abc-123']) + diffs = _compare_service(api_service(), svc, group_ids=GROUP_IDS) + assert not any('access_groups' in d for d in diffs) + + +class TestTargetNormalization: + + def test_flat_vs_nested_options_are_equal(self): + """Export writes flat keys; API returns nested options dict.""" + diffs = _compare_service( + api_service(), exported_service(), group_ids=GROUP_IDS) + assert not any('targets' in d for d in diffs) + + def test_a_real_target_change_is_detected(self): + changed = exported_service(targets=[{ + 'host': '10.0.0.2', + 'port': 9090, + 'protocol': 'http', + 'target_id': 'res-1', + 'target_type': 'subnet', + 'enabled': True, + }]) + diffs = _compare_service(api_service(), changed, group_ids=GROUP_IDS) + assert any('targets' in d for d in diffs) + + def test_skip_tls_change_detected(self): + changed = exported_service(targets=[{ + 'host': '10.0.0.1', + 'port': 8080, + 'protocol': 'http', + 'target_id': 'res-1', + 'target_type': 'subnet', + 'enabled': True, + 'direct_upstream': True, + 'skip_tls_verify': True, + }]) + diffs = _compare_service(api_service(), changed, group_ids=GROUP_IDS) + assert any('targets' in d for d in diffs) + + +class TestAuthDeclaredKeys: + + def test_bearer_only_does_not_report_other_schemes_removed(self): + """Export omits password_auth and pin_auth when only bearer is + enabled. The diff must not flag those as removed.""" + current = api_service(auth={ + 'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['grp-abc-123'], + }, + 'password_auth': {'enabled': False, 'password': ''}, + 'pin_auth': {'enabled': False, 'pin': ''}, + }) + desired = exported_service(auth={ + 'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['ci-test-group'], + }, + }) + diffs = _compare_service(current, desired, group_ids=GROUP_IDS) + assert not any('password_auth' in d for d in diffs) + assert not any('pin_auth' in d for d in diffs) + + def test_bearer_distribution_groups_resolved(self): + current = api_service(auth={ + 'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['grp-abc-123'], + }, + 'password_auth': {'enabled': False, 'password': ''}, + 'pin_auth': {'enabled': False, 'pin': ''}, + }) + desired = exported_service(auth={ + 'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['ci-test-group'], + }, + }) + diffs = _compare_service(current, desired, group_ids=GROUP_IDS) + assert not any('distribution_groups' in d for d in diffs) + + def test_no_auth_in_desired_skips_auth_comparison(self): + diffs = _compare_service(api_service(), exported_service(), + group_ids=GROUP_IDS) + assert not any('auth' in d for d in diffs) + + def test_real_auth_change_is_detected(self): + desired = exported_service(auth={ + 'bearer_auth': {'enabled': True, 'distribution_groups': []}, + }) + current = api_service(auth={ + 'bearer_auth': {'enabled': False, 'distribution_groups': []}, + 'password_auth': {'enabled': False, 'password': ''}, + 'pin_auth': {'enabled': False, 'pin': ''}, + }) + diffs = _compare_service(current, desired, group_ids=GROUP_IDS) + assert any('enabled' in d for d in diffs) + + +class TestFullRoundTrip: + """End-to-end through netbird_diff with service resource_type.""" + + def test_freshly_exported_config_reports_no_changes(self): + current_map = {'app.example.com': api_service()} + desired_list = [exported_service()] + result = netbird_diff(desired_list, current_map, 'service', + key_field='domain', group_ids=GROUP_IDS) + assert result['changed'] == {} + assert result['unchanged'] == ['app.example.com'] + + def test_a_real_change_is_still_detected(self): + current_map = {'app.example.com': api_service()} + desired_list = [exported_service(private=True)] + result = netbird_diff(desired_list, current_map, 'service', + key_field='domain', group_ids=GROUP_IDS) + assert 'app.example.com' in result['changed'] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/unit/plugins/filter/test_netbird_resolve_service.py b/tests/unit/plugins/filter/test_netbird_resolve_service.py new file mode 100644 index 0000000..4c4495d --- /dev/null +++ b/tests/unit/plugins/filter/test_netbird_resolve_service.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2024-2026, NetBird and contributors +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +"""Unit tests for service resolution in netbird_resolve. + +Run via: + ansible-test units --docker default + +The export template resolves group IDs to names in both access_groups and +auth.bearer_auth.distribution_groups. On apply, _resolve_service must turn +those names back to IDs so the API receives valid group references. Without +this, an export→apply round trip silently replaces stored group IDs with group +names, breaking bearer auth. +""" + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import pytest + +from ansible.errors import AnsibleFilterError + +from ansible_collections.community.ansible_netbird.plugins.filter.netbird_resolve import ( + netbird_resolve_ids, + netbird_missing_refs, +) + +GROUP_IDS = { + 'ci-test-access-group': 'da5v832fadhs73aet620', + 'developers': 'grp-dev-001', +} + + +class TestServiceAccessGroups: + + def test_resolves_names_to_ids(self): + services = [{'domain': 'app.example.com', 'access_groups': ['developers']}] + result = netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + assert result[0]['access_groups'] == ['grp-dev-001'] + + def test_passes_through_raw_ids(self): + services = [{'domain': 'app.example.com', 'access_groups': ['grp-dev-001']}] + result = netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + assert result[0]['access_groups'] == ['grp-dev-001'] + + def test_raises_on_unknown_name(self): + services = [{'domain': 'app.example.com', 'access_groups': ['no-such-group']}] + with pytest.raises(AnsibleFilterError, match='no-such-group'): + netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + + +class TestServiceBearerDistributionGroups: + + def test_resolves_names_to_ids(self): + services = [{ + 'domain': 'app.example.com', + 'auth': {'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['ci-test-access-group'], + }}, + }] + result = netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + bearer = result[0]['auth']['bearer_auth'] + assert bearer['distribution_groups'] == ['da5v832fadhs73aet620'] + + def test_passes_through_raw_ids(self): + services = [{ + 'domain': 'app.example.com', + 'auth': {'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['da5v832fadhs73aet620'], + }}, + }] + result = netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + bearer = result[0]['auth']['bearer_auth'] + assert bearer['distribution_groups'] == ['da5v832fadhs73aet620'] + + def test_raises_on_unknown_name(self): + services = [{ + 'domain': 'app.example.com', + 'auth': {'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['nonexistent-group'], + }}, + }] + with pytest.raises(AnsibleFilterError, match='nonexistent-group'): + netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + + def test_preserves_bearer_enabled_flag(self): + services = [{ + 'domain': 'app.example.com', + 'auth': {'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['developers'], + }}, + }] + result = netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + assert result[0]['auth']['bearer_auth']['enabled'] is True + + def test_preserves_other_auth_schemes(self): + services = [{ + 'domain': 'app.example.com', + 'auth': { + 'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['developers'], + }, + 'password_auth': {'enabled': True, 'password': 'secret'}, + }, + }] + result = netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + assert result[0]['auth']['password_auth'] == {'enabled': True, 'password': 'secret'} + + def test_no_auth_key_passes_through(self): + services = [{'domain': 'app.example.com'}] + result = netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + assert 'auth' not in result[0] + + def test_auth_without_bearer_passes_through(self): + services = [{ + 'domain': 'app.example.com', + 'auth': {'password_auth': {'enabled': True, 'password': 'pw'}}, + }] + result = netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + assert 'bearer_auth' not in result[0]['auth'] + + def test_bearer_without_distribution_groups_passes_through(self): + services = [{ + 'domain': 'app.example.com', + 'auth': {'bearer_auth': {'enabled': True}}, + }] + result = netbird_resolve_ids(services, 'service', group_ids=GROUP_IDS) + assert 'distribution_groups' not in result[0]['auth']['bearer_auth'] + + +class TestServiceMissingRefs: + + def test_collects_unknown_access_group(self): + services = [{'domain': 'app.example.com', 'access_groups': ['bad-group']}] + missing = netbird_missing_refs(services, 'service', group_ids=GROUP_IDS) + assert len(missing) == 1 + assert missing[0]['name'] == 'bad-group' + assert 'access_groups' in missing[0]['context'] + + def test_collects_unknown_bearer_distribution_group(self): + services = [{ + 'domain': 'app.example.com', + 'auth': {'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['bad-bearer-group'], + }}, + }] + missing = netbird_missing_refs(services, 'service', group_ids=GROUP_IDS) + assert len(missing) == 1 + assert missing[0]['name'] == 'bad-bearer-group' + assert 'bearer_auth' in missing[0]['context'] + + def test_collects_from_both_access_and_bearer(self): + services = [{ + 'domain': 'app.example.com', + 'access_groups': ['bad-access'], + 'auth': {'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['bad-bearer'], + }}, + }] + missing = netbird_missing_refs(services, 'service', group_ids=GROUP_IDS) + names = {m['name'] for m in missing} + assert names == {'bad-access', 'bad-bearer'} + + def test_valid_refs_produce_empty_missing(self): + services = [{ + 'domain': 'app.example.com', + 'access_groups': ['developers'], + 'auth': {'bearer_auth': { + 'enabled': True, + 'distribution_groups': ['ci-test-access-group'], + }}, + }] + missing = netbird_missing_refs(services, 'service', group_ids=GROUP_IDS) + assert missing == [] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/unit/plugins/modules/test_netbird_service_domain.py b/tests/unit/plugins/modules/test_netbird_service_domain.py new file mode 100644 index 0000000..e2bba39 --- /dev/null +++ b/tests/unit/plugins/modules/test_netbird_service_domain.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2026, Community +# GNU General Public License v3.0+ +"""Unit tests for the netbird_service_domain module. + +Run via: + ansible-test units --docker default + +Covers create, delete, idempotent no-change, target_cluster change, +rollback validation, and re-validate on existing unvalidated domains. +``NetBirdAPI`` is patched so no network request is made. +""" + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import pytest + +from ansible_collections.community.ansible_netbird.plugins.modules import netbird_service_domain + + +EXISTING_DOMAIN = { + 'id': 'dom-1', + 'domain': 'app.example.com', + 'validated': True, + 'type': 'custom', + 'target_cluster': 'eu.proxy.netbird.io', +} + + +class DummyModule: + """Minimal AnsibleModule stand-in.""" + + def __init__(self, params, check_mode=False): + self.params = params + self.check_mode = check_mode + self.exit_kwargs = None + self.fail_kwargs = None + + def exit_json(self, **kwargs): + self.exit_kwargs = kwargs + raise SystemExit(0) + + def fail_json(self, **kwargs): + self.fail_kwargs = kwargs + raise SystemExit(1) + + def warn(self, msg): + pass + + +def run_module(monkeypatch, params, existing_domains=None): + """Drive netbird_service_domain.main() with patched deps. + + Returns (module, recorded_calls) where recorded_calls tracks + which API methods were called and with what arguments. + """ + if existing_domains is None: + existing_domains = [] + + full = { + 'api_url': 'https://api.example.test', + 'api_token': 'token', + 'validate_certs': True, + 'timeout': 30, + 'state': 'present', + 'domain': None, + 'target_cluster': None, + 'validate': False, + } + full.update(params) + module = DummyModule(full) + recorded = {'calls': []} + + class FakeAPI: + def __init__(self, *args, **kwargs): + pass + + def list_service_domains(self): + recorded['calls'].append('list') + return list(existing_domains), {} + + def create_service_domain(self, data): + recorded['calls'].append(('create', data)) + new = dict(EXISTING_DOMAIN, **data) + new['id'] = 'dom-new' + return new, {} + + def delete_service_domain(self, domain_id): + recorded['calls'].append(('delete', domain_id)) + return None, {} + + def validate_service_domain(self, domain_id): + recorded['calls'].append(('validate', domain_id)) + return {}, {} + + monkeypatch.setattr( + netbird_service_domain, 'AnsibleModule', lambda **kw: module) + monkeypatch.setattr( + netbird_service_domain, 'NetBirdAPI', FakeAPI) + + with pytest.raises(SystemExit): + netbird_service_domain.main() + + return module, recorded + + +class TestCreateDomain: + + def test_create_new_domain(self, monkeypatch): + module, recorded = run_module(monkeypatch, { + 'domain': 'app.example.com', + 'target_cluster': 'eu.proxy.netbird.io', + }) + assert module.exit_kwargs['changed'] is True + assert ('create', { + 'domain': 'app.example.com', + 'target_cluster': 'eu.proxy.netbird.io', + }) in recorded['calls'] + + def test_create_with_validate(self, monkeypatch): + module, recorded = run_module(monkeypatch, { + 'domain': 'app.example.com', + 'target_cluster': 'eu.proxy.netbird.io', + 'validate': True, + }) + assert module.exit_kwargs['changed'] is True + assert any(c[0] == 'validate' for c in recorded['calls'] + if isinstance(c, tuple)) + + +class TestIdempotent: + + def test_existing_domain_same_cluster_no_change(self, monkeypatch): + module, recorded = run_module(monkeypatch, { + 'domain': 'app.example.com', + 'target_cluster': 'eu.proxy.netbird.io', + }, existing_domains=[EXISTING_DOMAIN]) + assert module.exit_kwargs['changed'] is False + assert not any( + isinstance(c, tuple) and c[0] == 'create' + for c in recorded['calls']) + + def test_different_cluster_triggers_recreate(self, monkeypatch): + module, recorded = run_module(monkeypatch, { + 'domain': 'app.example.com', + 'target_cluster': 'us.proxy.netbird.io', + }, existing_domains=[EXISTING_DOMAIN]) + assert module.exit_kwargs['changed'] is True + assert ('delete', 'dom-1') in recorded['calls'] + assert any( + isinstance(c, tuple) and c[0] == 'create' + for c in recorded['calls']) + + +class TestValidateExistingDomain: + + def test_validate_unvalidated_domain(self, monkeypatch): + unvalidated = dict(EXISTING_DOMAIN, validated=False) + module, recorded = run_module(monkeypatch, { + 'domain': 'app.example.com', + 'target_cluster': 'eu.proxy.netbird.io', + 'validate': True, + }, existing_domains=[unvalidated]) + assert module.exit_kwargs['changed'] is True + assert ('validate', 'dom-1') in recorded['calls'] + + def test_validate_already_validated_is_noop(self, monkeypatch): + module, recorded = run_module(monkeypatch, { + 'domain': 'app.example.com', + 'target_cluster': 'eu.proxy.netbird.io', + 'validate': True, + }, existing_domains=[EXISTING_DOMAIN]) + assert module.exit_kwargs['changed'] is False + assert not any( + isinstance(c, tuple) and c[0] == 'validate' + for c in recorded['calls']) + + def test_validate_unvalidated_check_mode(self, monkeypatch): + unvalidated = dict(EXISTING_DOMAIN, validated=False) + full = { + 'api_url': 'https://api.example.test', + 'api_token': 'token', + 'validate_certs': True, + 'timeout': 30, + 'state': 'present', + 'domain': 'app.example.com', + 'target_cluster': 'eu.proxy.netbird.io', + 'validate': True, + } + module = DummyModule(full, check_mode=True) + recorded = {'calls': []} + + class FakeAPI: + def __init__(self, *a, **kw): + pass + + def list_service_domains(self): + recorded['calls'].append('list') + return [unvalidated], {} + + def validate_service_domain(self, did): + recorded['calls'].append(('validate', did)) + return {}, {} + + monkeypatch.setattr( + netbird_service_domain, 'AnsibleModule', lambda **kw: module) + monkeypatch.setattr( + netbird_service_domain, 'NetBirdAPI', FakeAPI) + + with pytest.raises(SystemExit): + netbird_service_domain.main() + + assert module.exit_kwargs['changed'] is True + assert not any( + isinstance(c, tuple) and c[0] == 'validate' + for c in recorded['calls']) + + +class TestRollbackValidation: + + def test_rollback_triggers_validation(self, monkeypatch): + full = { + 'api_url': 'https://api.example.test', + 'api_token': 'token', + 'validate_certs': True, + 'timeout': 30, + 'state': 'present', + 'domain': 'app.example.com', + 'target_cluster': 'us.proxy.netbird.io', + 'validate': True, + } + module = DummyModule(full) + recorded = {'calls': [], 'create_count': 0} + + class FakeAPI: + def __init__(self, *a, **kw): + pass + + def list_service_domains(self): + recorded['calls'].append('list') + return [EXISTING_DOMAIN], {} + + def delete_service_domain(self, did): + recorded['calls'].append(('delete', did)) + return None, {} + + def create_service_domain(self, data): + recorded['create_count'] += 1 + if recorded['create_count'] == 1: + raise netbird_service_domain.NetBirdAPIError( + 'cluster unavailable', status_code=400) + recorded['calls'].append(('create', data)) + rb = dict(EXISTING_DOMAIN, id='dom-rb', **data) + return rb, {} + + def validate_service_domain(self, did): + recorded['calls'].append(('validate', did)) + return {}, {} + + monkeypatch.setattr( + netbird_service_domain, 'AnsibleModule', lambda **kw: module) + monkeypatch.setattr( + netbird_service_domain, 'NetBirdAPI', FakeAPI) + + with pytest.raises(SystemExit): + netbird_service_domain.main() + + assert module.fail_kwargs is not None + assert 'rolled back' in module.fail_kwargs['msg'] + assert module.fail_kwargs.get('domain_info', {}).get('id') == 'dom-rb' + assert ('validate', 'dom-rb') in recorded['calls'] + + +class TestDeleteDomain: + + def test_delete_existing(self, monkeypatch): + module, recorded = run_module(monkeypatch, { + 'domain': 'app.example.com', + 'state': 'absent', + }, existing_domains=[EXISTING_DOMAIN]) + assert module.exit_kwargs['changed'] is True + assert ('delete', 'dom-1') in recorded['calls'] + + def test_delete_nonexistent_is_noop(self, monkeypatch): + module, recorded = run_module(monkeypatch, { + 'domain': 'app.example.com', + 'state': 'absent', + }) + assert module.exit_kwargs['changed'] is False + assert not any( + isinstance(c, tuple) and c[0] == 'delete' + for c in recorded['calls'])