diff --git a/README.md b/README.md index c453038b..a820c055 100644 --- a/README.md +++ b/README.md @@ -1,263 +1,264 @@ # VyOS Collection [![codecov](https://codecov.io/gh/vyos/vyos.vyos/graph/badge.svg?token=J217GFD69W)](https://codecov.io/gh/vyos/vyos.vyos) [![CI](https://github.com/vyos/vyos.vyos/actions/workflows/tests.yml/badge.svg?branch=main&event=schedule)](https://github.com/vyos/vyos.vyos/actions/workflows/tests.yml) The Ansible VyOS collection includes a variety of Ansible content to help automate the management of VyOS network appliances. This collection has been tested against VyOS 1.3.8, 1.4.1 and the current rolling release for 1.5. Where possible, compatibility with older versions of VyOS are maintained but not guaranteed. ## Communication * Join the VyOS forum: * [FAQ](https://forum.vyos.io/faq): find answers to frequently asked questions. * [Guides and How To](https://forum.vyos.io/c/howto-guies/27): find guides and how-to articles. * [News & Announcements](https://forum.vyos.io/c/announcements/6): track project-wide announcements . ## Ansible version compatibility This collection has been tested against the following Ansible versions: **>=2.15.0**. Plugins and modules within a collection may be tested with only specific Ansible versions. A collection may contain metadata that identifies these versions. PEP440 is the schema used to describe the versions of Ansible. ### Supported connections The VyOS collection supports ``network_cli`` connections. ## Included content ### Cliconf plugins Name | Description --- | --- [vyos.vyos.vyos](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_cliconf.rst)|Use vyos cliconf to run command on VyOS platform ### Modules Name | Description --- | --- [vyos.vyos.vyos_banner](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_banner_module.rst)|Manage multiline banners on VyOS devices [vyos.vyos.vyos_bgp_address_family](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_bgp_address_family_module.rst)|BGP Address Family resource module [vyos.vyos.vyos_bgp_global](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_bgp_global_module.rst)|BGP global resource module [vyos.vyos.vyos_command](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_command_module.rst)|Run one or more commands on VyOS devices [vyos.vyos.vyos_config](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_config_module.rst)|Manage VyOS configuration on remote device [vyos.vyos.vyos_facts](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_facts_module.rst)|Get facts about vyos devices. +[vyos.vyos.vyos_file](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_file_module.rst)|Manage files, directories, and their ownership on VyOS devices [vyos.vyos.vyos_firewall_global](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_firewall_global_module.rst)|Firewall global resource module [vyos.vyos.vyos_firewall_interfaces](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_firewall_interfaces_module.rst)|Firewall interfaces resource module [vyos.vyos.vyos_firewall_rules](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_firewall_rules_module.rst)|Firewall rules resource module [vyos.vyos.vyos_hostname](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_hostname_module.rst)|Manages hostname resource module [vyos.vyos.vyos_interfaces](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_interfaces_module.rst)|Manages interface attributes of VyOS network devices. [vyos.vyos.vyos_l3_interfaces](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_l3_interfaces_module.rst)|Layer 3 interfaces resource module. [vyos.vyos.vyos_lag_interfaces](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_lag_interfaces_module.rst)|LAG interfaces resource module [vyos.vyos.vyos_lldp_global](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_lldp_global_module.rst)|LLDP global resource module [vyos.vyos.vyos_lldp_interfaces](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_lldp_interfaces_module.rst)|LLDP interfaces resource module [vyos.vyos.vyos_logging_global](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_logging_global_module.rst)|Logging resource module [vyos.vyos.vyos_ntp_global](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_ntp_global_module.rst)|NTP global resource module [vyos.vyos.vyos_ospf_interfaces](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_ospf_interfaces_module.rst)|OSPF Interfaces Resource Module. [vyos.vyos.vyos_ospfv2](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_ospfv2_module.rst)|OSPFv2 resource module [vyos.vyos.vyos_ospfv3](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_ospfv3_module.rst)|OSPFv3 resource module [vyos.vyos.vyos_ping](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_ping_module.rst)|Tests reachability using ping from VyOS network devices [vyos.vyos.vyos_prefix_lists](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_prefix_lists_module.rst)|Prefix-Lists resource module for VyOS [vyos.vyos.vyos_route_maps](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_route_maps_module.rst)|Route Map resource module [vyos.vyos.vyos_snmp_server](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_snmp_server_module.rst)|Manages snmp_server resource module [vyos.vyos.vyos_static_routes](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_static_routes_module.rst)|Static routes resource module [vyos.vyos.vyos_system](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_system_module.rst)|Run `set system` commands on VyOS devices [vyos.vyos.vyos_user](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_user_module.rst)|Manage the collection of local users on VyOS device [vyos.vyos.vyos_vlan](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_vlan_module.rst)|Manage VLANs on VyOS network devices [vyos.vyos.vyos_vrf](https://github.com/vyos/vyos.vyos/blob/main/docs/vyos.vyos.vyos_vrf_module.rst)|VRF resource module Click the ``Content`` button to see the list of content included in this collection. ## Installing this collection You can install the VyOS collection with the Ansible Galaxy CLI: ansible-galaxy collection install vyos.vyos You can also include it in a `requirements.yml` file and install it with `ansible-galaxy collection install -r requirements.yml`, using the format: ```yaml --- collections: - name: vyos.vyos ``` ## Using this collection This collection includes [network resource modules](https://docs.ansible.com/ansible/latest/network/user_guide/network_resource_modules.html). ### Using modules from the VyOS collection in your playbooks You can call modules by their Fully Qualified Collection Namespace (FQCN), such as `vyos.vyos.vyos_static_routes`. The following example task replaces configuration changes in the existing configuration on a VyOS network device, using the FQCN: ```yaml --- - name: Replace device configurations of listed static routes with provided configurations register: result vyos.vyos.vyos_static_routes: &id001 config: - address_families: - afi: ipv4 routes: - dest: 192.0.2.32/28 blackhole_config: distance: 2 next_hops: - forward_router_address: 192.0.2.7 - forward_router_address: 192.0.2.8 - forward_router_address: 192.0.2.9 state: replaced ``` **NOTE**: For Ansible 2.9, you may not see deprecation warnings when you run your playbooks with this collection. Use this documentation to track when a module is deprecated. ### See Also: * [VyOS Platform Options](https://docs.ansible.com/ansible/latest/network/user_guide/platform_vyos.html) * [Ansible Using collections](https://docs.ansible.com/ansible/latest/user_guide/collections_using.html) for more details. ## Contributing to this collection We welcome community contributions to this collection. If you find problems, please open an issue or create a PR against the [VyOS collection repository](https://github.com/vyos/vyos.vyos). See [Contributing to VyOS](https://vyos.net/contribute/) for complete details. You can also join us on: - Forum - https://forum.vyos.io See the [Contributing to VyOS](https://vyos.net/contribute/) for details on contributing to Ansible. ### Code of Conduct This collection follows the Ansible project's [Code of Conduct](https://docs.ansible.com/ansible/devel/community/code_of_conduct.html). Please read and familiarize yourself with this document. ### Updating from resource module models Some of our modules were templated using `resource_module_builder`, but some use the newer [`cli_rm_builder`](https://github.com/ansible-network/cli_rm_builder) which tempaltes baed on in-place device information, but also uses a new network parsing engine designed to simplify and standardize the parsing of network configuration. #### Using older *resource_module_builder* modules Last build was with a slightly-modified version of resource_module_builder. This changes the calling parameters for the resources. To update the collection from the resource module models, run the following command: ```bash ansible-playbook -e rm_dest=`pwd` \ -e structure=collection \ -e collection_org=vyos \ -e collection_name=vyos \ -e model=../../../resource_module_models/models/vyos/firewall_rules/vyos_firewall_rules.yaml \ ../../../resource_module_builder/site.yml ``` #### Using *cli_rm_builder* modules The newer `cli_rm_builder` works similarly to the older `resource_module_builder`, but pulls the information directly from the `DOCUMENTATION`, `EXAMPLES` and `RETURN` blocks in the module itself. To update the collection from the `cli_rm_builder` models, run the following command: ```bash ansible-playbook -e rm_dest=`pwd` \ -e collection_org=vyos \ -e collection_name=vyos \ -e resource=bgp_address_family \ ../../../cli_rm_builder/run.yml ``` Unlike the `resource_module_builder`, the `cli_rm_builder` does not require the `model` parameter. Instead, it uses the `resource` parameter to specify the resource to build. ### Testing playbooks You can use `ANSIBLE_COLLECTIONS_PATH` to test the collection locally. For example: ``` ANSIBLE_COLLECTIONS_PATHS=~/my_dev_path ansible-playbook -i inventory.network test.yml ``` ### Integration Tests Integration tests are run using `ansible-test` and require that there be an inventory defined (you can pass this in with `--inventory `) and that the system be configured for access (recommended to use SSH keys). Additionally: - eth0 should be configured for `address dhcp` and should have an assigned address on the local network - eth1 and eth2 should be defined and uncofirgured (they'll be overwritten by the tests) - eth3 and beyond should not be present or interface-related tests will fail - when using VMs for testing, ensure that the interfaces don't use `virtio`, as it will supress some interface configurations. `e1000e` is a good choice for testing. - eth0 is also expected to show `duplex auto` and `speed auto` in the output of `show interfaces`, however others are not due to the fact that they are repeatedly deleted and recreated which causes the default values to be hidden. ## Changelogs Change logs are available [here](https://github.com/vyos/vyos.vyos/blob/main/CHANGELOG.rst). ## Release notes Release notes are available [here](https://github.com/vyos/vyos.vyos/blob/main/CHANGELOG.rst). ## Roadmap Major Version | Ansible Support | VyOS Support | Details --- | --- | --- | --- 4.1.0 | 2.15 | 1.1.2 | Final release for the 4.x series 5.0.0 | 2.16 | 1.1.2 | First relase under VyOS control as a separate collection 6.0.0 | 2.18 | 1.3.8 | *Planned* release for supporting VyOS 1.3.8+ 7.0.0 | x.xx | 1.4.x | *Prospective* release deprecating incompatible 1.3.x modules Note: - Unreleased versions are not guaranteed to be released as described. - Some modules may support a wider variety of versions depending upon the compatibility with prior versions of VyOS. - The roadmap is subject to change based on community feedback and contributions. ## More information VyOS resources - [Contributing to VyOS](https://vyos.net/contribute) - [VyOS documentation](https://docs.vyos.io/en/latest/) - [VyOS forum](https://forum.vyos.io) Ansible Resources - [Ansible network resources](https://docs.ansible.com/ansible/latest/network/getting_started/network_resources.html) - [Ansible Collection overview](https://github.com/ansible-collections/overview) - [Ansible User guide](https://docs.ansible.com/ansible/latest/user_guide/index.html) - [Ansible Developer guide](https://docs.ansible.com/ansible/latest/dev_guide/index.html) - [Ansible Community code of conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html) ## Licensing GNU General Public License v3.0 or later. See [LICENSE](https://www.gnu.org/licenses/gpl-3.0.txt) to see the full text. diff --git a/changelogs/fragments/t6830_vyos_file.yml b/changelogs/fragments/t6830_vyos_file.yml new file mode 100644 index 00000000..fb50f2d9 --- /dev/null +++ b/changelogs/fragments/t6830_vyos_file.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - vyos_file - Add support for file upload, management and templating. diff --git a/docs/vyos.vyos.vyos_file_module.rst b/docs/vyos.vyos.vyos_file_module.rst new file mode 100644 index 00000000..749edf5a --- /dev/null +++ b/docs/vyos.vyos.vyos_file_module.rst @@ -0,0 +1,249 @@ +.. _vyos.vyos.vyos_file_module: + + +******************* +vyos.vyos.vyos_file +******************* + +**Manage files, directories, and their ownership on VyOS devices** + + +Version added: 1.0.0 + +.. contents:: + :local: + :depth: 1 + + +Synopsis +-------- +- Creates, updates, or removes a file or directory on a VyOS device, optionally pushing content from a local file (*src*) or inline text (*content*), and setting owner/group/mode via sudo chown/chmod. +- This module does not touch the configuration tree (config.boot). It manages arbitrary filesystem paths such as certificates or auth files under /config/auth/, which are not tracked by commit/save/rollback. +- All logic runs inside this module's main(), using the standard get_connection()/run_commands() pattern shared with vyos_command — there is no dedicated action plugin; this module uses the shared generic vyos action plugin like every other module in the collection. + + + + +Parameters +---------- + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterChoices/DefaultsComments
+
+ become + +
+ boolean +
+
+
    Choices: +
  • no
  • +
  • yes ←
  • +
+
+
Whether to prefix remote commands with sudo.
+
+
+ content + +
+ string +
+
+ +
Inline text content to write to dest. Marked no_log, since this module is commonly used to push credential material. Mutually exclusive with src.
+
+
+ dest + +
+ path + / required +
+
+ +
Absolute path to the remote file or directory to manage.
+
+
+ group + +
+ string +
+
+ +
Name of the group that should own dest.
+
+
+ mode + +
+ string +
+
+ +
Permission bits for dest, as a string (e.g. '0600'). Compared against stat output after normalizing to 4 digits; '600' and '0600' are treated as equivalent.
+
+
+ owner + +
+ string +
+
+ +
Name of the user that should own dest.
+
+
+ src + +
+ path +
+
+ +
Path to a local file (on the Ansible controller) whose content should be pushed to dest. Read locally and pushed as base64 via a single CLI command, since network_cli has no SFTP/SCP channel available to this module. Mutually exclusive with content.
+
+
+ state + +
+ string +
+
+
    Choices: +
  • present ←
  • +
  • absent
  • +
+
+
Whether the path should exist (present) or be removed (absent).
+
+
+ + +Notes +----- + +.. note:: + - This module works with connection ``ansible.netcommon.network_cli``. + - File state managed by this module is independent of VyOS's config revision system. A rollback to a previous config revision will not revert changes made by this module. + - Paths under */config/auth* are deliberately setgid ``vyattacfg`` by VyOS's own config-management convention (see vyos.dev T2713). If *mode* is given with a leading digit of ``0`` (e.g. ``'0750'``), this module compares only the rwx bits and will not report a diff for VyOS's own setgid bit. To manage the setgid/setuid/sticky bit explicitly, pass a non-zero leading digit (e.g. ``'2750'``). + + + +Examples +-------- + +.. code-block:: yaml + + - name: ensure the auth directory exists with correct ownership + vyos.vyos.vyos_file: + dest: /config/auth/office-vpn + owner: openvpn + group: openvpn + mode: '0750' + + - name: push a client certificate with correct ownership + vyos.vyos.vyos_file: + dest: /config/auth/office-vpn/client.pem + src: files/office-vpn-client.pem + owner: openvpn + group: openvpn + mode: '0600' + + - name: remove a stale cert + vyos.vyos.vyos_file: + dest: /config/auth/old-vpn/client.pem + state: absent + + + +Return Values +------------- +Common return values are documented `here `_, the following are the fields unique to this module: + +.. raw:: html + + + + + + + + + + + + +
KeyReturnedDescription
+
+ diff_fields + +
+ list + / elements=string +
+
always +
Fields that differed between requested and actual state and were converged.
+
+
Sample:
+
['owner', 'mode', 'content']
+
+

+ + +Status +------ + + +Authors +~~~~~~~ + +- VyOS maintainers and contributors (@vyos) diff --git a/meta/runtime.yml b/meta/runtime.yml index f0a53ee7..fe819fdf 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -1,68 +1,70 @@ --- requires_ansible: ">=2.15.0" plugin_routing: modules: banner: redirect: vyos.vyos.vyos_banner bgp_global: redirect: vyos.vyos.vyos_bgp_global bgp_address_family: redirect: vyos.vyos.vyos_bgp_address_family command: redirect: vyos.vyos.vyos_command config: redirect: vyos.vyos.vyos_config facts: redirect: vyos.vyos.vyos_facts + file: + redirect: vyos.vyos.vyos_file firewall_global: redirect: vyos.vyos.vyos_firewall_global firewall_interfaces: redirect: vyos.vyos.vyos_firewall_interfaces firewall_rules: redirect: vyos.vyos.vyos_firewall_rules hostname: redirect: vyos.vyos.vyos_hostname interfaces: redirect: vyos.vyos.vyos_interfaces l3_interfaces: redirect: vyos.vyos.vyos_l3_interfaces lag_interfaces: redirect: vyos.vyos.vyos_lag_interfaces lldp_global: redirect: vyos.vyos.vyos_lldp_global lldp_interfaces: redirect: vyos.vyos.vyos_lldp_interfaces logging: tombstone: removal_version: 6.0.0 warning_text: use vyos_logging_global instead vyos_logging: tombstone: removal_version: 6.0.0 warning_text: use vyos_logging_global instead logging_global: redirect: vyos.vyos.vyos_logging_global ntp_global: redirect: vyos.vyos.vyos_ntp_global ospfv2: redirect: vyos.vyos.vyos_ospfv2 ospfv3: redirect: vyos.vyos.vyos_ospfv3 ospf_interfaces: redirect: vyos.vyos.vyos_ospf_interfaces ping: redirect: vyos.vyos.vyos_ping prefix_lists: redirect: vyos.vyos.vyos_prefix_lists snmp_server: redirect: vyos.vyos.vyos_snmp_server static_routes: redirect: vyos.vyos.vyos_static_routes system: redirect: vyos.vyos.vyos_system user: redirect: vyos.vyos.vyos_user vlan: redirect: vyos.vyos.vyos_vlan vrf: redirect: vyos.vyos.vyos_vrf diff --git a/plugins/action/file.py b/plugins/action/file.py new file mode 120000 index 00000000..331a791f --- /dev/null +++ b/plugins/action/file.py @@ -0,0 +1 @@ +vyos.py \ No newline at end of file diff --git a/plugins/module_utils/network/vyos/vyos_file.py b/plugins/module_utils/network/vyos/vyos_file.py new file mode 100644 index 00000000..2b946c20 --- /dev/null +++ b/plugins/module_utils/network/vyos/vyos_file.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2026, VyOS maintainers and contributors +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import hashlib +import re + + +STAT_RE = re.compile(r"^(?P\d+)\s+(?P\S+)\s+(?P\S+)\s+(?P\d+)$") + + +def parse_stat(output): + """Parse `stat --format='%a %U %G %s' ` output. + Returns None if the path doesn't exist (caller checks rc/stderr first). + """ + m = STAT_RE.match(output.strip()) + if not m: + return None + d = m.groupdict() + return { + "mode": d["mode"].zfill(4)[-4:], + "owner": d["owner"], + "group": d["group"], + "size": int(d["size"]), + } + + +def _normalize_mode(mode): + if mode is None: + return None + return str(mode).zfill(4)[-4:] + + +def build_want(params, local_content_hash=None): + return { + "dest": params["dest"], + "state": params.get("state", "present"), + "owner": params.get("owner"), + "group": params.get("group"), + "mode": _normalize_mode(params.get("mode")), + "content_hash": local_content_hash, + } + + +def diff_want_have(want, have): + """Returns dict of {field: (have_val, want_val)} for fields that differ. + Identity is `dest`, not a config-tree path — this compares a stat-shaped + dict, not config lines. + """ + diff = {} + if want["state"] == "absent": + if have is not None: + diff["state"] = (have, "absent") + return diff + + if have is None: + diff["state"] = (None, "present") + for f in ("owner", "group", "mode"): + if want.get(f) is not None: + diff[f] = (None, want[f]) + if want.get("content_hash"): + diff["content"] = (None, want["content_hash"]) + return diff + + for f in ("owner", "group"): + if want.get(f) is not None and want[f] != have.get(f): + diff[f] = (have.get(f), want[f]) + + if want.get("mode") is not None: + want_mode = want["mode"] + have_mode = have.get("mode") + if want_mode[0] == "0": + # Caller didn't request specific setuid/setgid/sticky bits — + # don't fight VyOS's own conventions (e.g. /config/auth is + # deliberately setgid vyattacfg; see vyos.dev T2713). Compare + # only the rwx digits unless the caller explicitly asked for a + # non-zero leading digit. + if want_mode[-3:] != have_mode[-3:]: + diff["mode"] = (have_mode, want_mode) + elif want_mode != have_mode: + diff["mode"] = (have_mode, want_mode) + + if want.get("content_hash") and want["content_hash"] != have.get("content_hash"): + diff["content"] = (have.get("content_hash"), want["content_hash"]) + + return diff + + +def local_sha256(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() diff --git a/plugins/modules/vyos_file.py b/plugins/modules/vyos_file.py new file mode 100644 index 00000000..c19156ed --- /dev/null +++ b/plugins/modules/vyos_file.py @@ -0,0 +1,322 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2026, VyOS maintainers and contributors +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = """ +module: vyos_file +short_description: Manage files, directories, and their ownership on VyOS devices +description: + - Creates, updates, or removes a file or directory on a VyOS device, optionally + pushing content from a local file (I(src)) or inline text (I(content)), and + setting owner/group/mode via sudo chown/chmod. + - This module does not touch the configuration tree (config.boot). It manages + arbitrary filesystem paths such as certificates or auth files under + /config/auth/, which are not tracked by commit/save/rollback. + - All logic runs inside this module's main(), using the standard + get_connection()/run_commands() pattern shared with vyos_command — there is + no dedicated action plugin; this module uses the shared generic vyos action + plugin like every other module in the collection. +version_added: "1.0.0" +author: + - VyOS maintainers and contributors (@vyos) +options: + dest: + description: Absolute path to the remote file or directory to manage. + type: path + required: true + state: + description: Whether the path should exist (present) or be removed (absent). + type: str + choices: [present, absent] + default: present + src: + description: + - Path to a local file (on the Ansible controller) whose content should be + pushed to I(dest). Read locally and pushed as base64 via a single CLI + command, since network_cli has no SFTP/SCP channel available to this + module. Mutually exclusive with I(content). + type: path + content: + description: + - Inline text content to write to I(dest). Marked no_log, since this module + is commonly used to push credential material. Mutually exclusive with + I(src). + type: str + owner: + description: Name of the user that should own I(dest). + type: str + group: + description: Name of the group that should own I(dest). + type: str + mode: + description: + - Permission bits for I(dest), as a string (e.g. '0600'). Compared against + stat output after normalizing to 4 digits; '600' and '0600' are treated + as equivalent. + type: str + become: + description: Whether to prefix remote commands with sudo. + type: bool + default: true +notes: + - This module works with connection C(ansible.netcommon.network_cli). + - File state managed by this module is independent of VyOS's config revision + system. A rollback to a previous config revision will not revert changes + made by this module. + - Paths under I(/config/auth) are deliberately setgid C(vyattacfg) by VyOS's + own config-management convention (see vyos.dev T2713). If I(mode) is given + with a leading digit of C(0) (e.g. C('0750')), this module compares only + the rwx bits and will not report a diff for VyOS's own setgid bit. To + manage the setgid/setuid/sticky bit explicitly, pass a non-zero leading + digit (e.g. C('2750')). +""" + +EXAMPLES = """ +- name: ensure the auth directory exists with correct ownership + vyos.vyos.vyos_file: + dest: /config/auth/office-vpn + owner: openvpn + group: openvpn + mode: '0750' + +- name: push a client certificate with correct ownership + vyos.vyos.vyos_file: + dest: /config/auth/office-vpn/client.pem + src: files/office-vpn-client.pem + owner: openvpn + group: openvpn + mode: '0600' + +- name: remove a stale cert + vyos.vyos.vyos_file: + dest: /config/auth/old-vpn/client.pem + state: absent +""" + +RETURN = """ +diff_fields: + description: Fields that differed between requested and actual state and were converged. + returned: always + type: list + elements: str + sample: ["owner", "mode", "content"] +""" + +import base64 +import hashlib +import shlex + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.vyos import ( + run_commands, +) +from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.vyos_file import ( + build_want, + diff_want_have, + parse_stat, +) + + +ARGUMENT_SPEC = dict( + dest=dict(type="path", required=True), + state=dict(type="str", choices=["present", "absent"], default="present"), + src=dict(type="path"), + content=dict(type="str", no_log=True), + owner=dict(type="str"), + group=dict(type="str"), + mode=dict(type="str"), + become=dict(type="bool", default=True), +) + + +def get_have(module, become, dest, need_content_hash=False): + quoted_dest = shlex.quote(dest) + # check_rc=False is required here: a missing path is a normal, expected + # outcome on first-run creation, not a failure. With the default + # check_rc=True, run_commands() would call module.fail_json() on every + # "file doesn't exist yet" case, which is exactly the case we need to + # handle gracefully to build `have`. + responses = run_commands( + module, + ["{0}stat --format='%a %U %G %s' {1}".format(become, quoted_dest)], + check_rc=False, + ) + out = responses[0] if responses else "" + + if not out: + return None + if "No such file" in out: + return None + + have = parse_stat(out) + if have is None: + # Anything that isn't the specific "doesn't exist" message and + # doesn't parse as valid stat output is a real problem — permission + # denied, I/O error, unexpected format, etc. Fail loudly rather than + # silently treating it as "create it", which could otherwise lead + # this module to attempt mkdir/chown/chmod against a path it + # actually has no real visibility into. + module.fail_json( + msg="vyos_file: unexpected stat output for {0}: {1}".format(dest, out.strip()), + ) + + if need_content_hash: + # Only hash when content comparison actually matters (src/content + # given) — no need to pay this cost for plain directory/ownership + # management. Without this, `have["content_hash"]` would always be + # None, so `content` would show as "different" forever, even right + # after a successful write. + hash_responses = run_commands( + module, + ["{0}sha256sum {1}".format(become, quoted_dest)], + check_rc=False, + ) + hash_out = hash_responses[0] if hash_responses else "" + # sha256sum output format: " " + parts = hash_out.strip().split() + if parts and len(parts[0]) == 64 and all(c in "0123456789abcdef" for c in parts[0].lower()): + have["content_hash"] = parts[0] + # else: leave content_hash unset — a malformed/errored sha256sum + # (e.g. the file vanished in a race between stat and sha256sum) + # should surface as a real diff on the next comparison, not get + # silently recorded as a bogus "hash". + + return have + + +def local_content_hash(params): + if params.get("src"): + h = hashlib.sha256() + with open(params["src"], "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + if params.get("content") is not None: + return hashlib.sha256(params["content"].encode()).hexdigest() + return None + + +def read_local_bytes(params): + if params.get("src"): + with open(params["src"], "rb") as f: + return f.read() + if params.get("content") is not None: + return params["content"].encode() + return None + + +def converge(module, become, dest, want, diff, params): + cmds = [] + quoted_dest = shlex.quote(dest) + + if want["state"] == "absent": + cmds.append("{0}rm -rf {1}".format(become, quoted_dest)) + run_commands(module, cmds) + post_have = get_have(module, become, dest) + if post_have is not None: + module.fail_json( + msg="vyos_file: removal of {0} did not take effect".format(dest), + ) + return + + if "content" in diff: + data = read_local_bytes(params) + b64 = base64.b64encode(data).decode() + # Quote the entire script as a single argument to sh -c (safe even if dest contains quotes). + script = "echo {0} | base64 -d > {1}".format(b64, shlex.quote(dest)) + cmds.append("{0}sh -c {1}".format(become, shlex.quote(script))) + elif "state" in diff and have_is_missing(diff): + cmds.append("{0}mkdir -p {1}".format(become, quoted_dest)) + + if "owner" in diff and "group" in diff: + cmds.append( + "{0}chown {1}:{2} {3}".format( + become, + shlex.quote(want["owner"]), + shlex.quote(want["group"]), + quoted_dest, + ), + ) + elif "owner" in diff: + cmds.append( + "{0}chown {1} {2}".format(become, shlex.quote(want["owner"]), quoted_dest), + ) + elif "group" in diff: + cmds.append( + "{0}chgrp {1} {2}".format(become, shlex.quote(want["group"]), quoted_dest), + ) + + if "mode" in diff: + cmds.append( + "{0}chmod {1} {2}".format(become, shlex.quote(want["mode"]), quoted_dest), + ) + + if cmds: + run_commands(module, cmds) + + # run_commands() only confirms the CLI accepted each command line + # syntactically — it does NOT confirm the underlying binary succeeded. + # A chown against a nonexistent group, for example, prints an error to + # stdout but the CLI wrapper still reports the line as "executed"; we + # would otherwise report changed=true for a write that silently did + # nothing. Re-stat and compare against `want` to catch this class of + # failure before returning success. + post_have = get_have( + module, + become, + dest, + need_content_hash=want.get("content_hash") is not None, + ) + post_diff = diff_want_have(want, post_have) + if post_diff: + module.fail_json( + msg=( + "vyos_file converged but post-check found remaining " + "differences — one or more commands likely failed silently " + "at the OS level (e.g. chown to a nonexistent user/group): " + "{0}".format(post_diff) + ), + ) + + +def have_is_missing(diff): + return diff.get("state") == (None, "present") + + +def main(): + module = AnsibleModule( + argument_spec=ARGUMENT_SPEC, + mutually_exclusive=[["src", "content"]], + supports_check_mode=True, + ) + + become = "sudo " if module.params.get("become", True) else "" + dest = module.params["dest"] + + want = build_want(module.params, local_content_hash(module.params)) + have = get_have( + module, + become, + dest, + need_content_hash=want.get("content_hash") is not None, + ) + diff = diff_want_have(want, have) + + result = {"changed": bool(diff), "diff_fields": list(diff.keys())} + + if module.check_mode or not diff: + module.exit_json(**result) + + converge(module, become, dest, want, diff, module.params) + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/vyos_file/aliases b/tests/integration/targets/vyos_file/aliases new file mode 100644 index 00000000..8071e1f7 --- /dev/null +++ b/tests/integration/targets/vyos_file/aliases @@ -0,0 +1 @@ +shippable/vyos/group1 diff --git a/tests/integration/targets/vyos_file/defaults/main.yaml b/tests/integration/targets/vyos_file/defaults/main.yaml new file mode 100644 index 00000000..164afead --- /dev/null +++ b/tests/integration/targets/vyos_file/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_file/tasks/cli.yaml b/tests/integration/targets/vyos_file/tasks/cli.yaml new file mode 100644 index 00000000..daccf720 --- /dev/null +++ b/tests/integration/targets/vyos_file/tasks/cli.yaml @@ -0,0 +1,20 @@ +--- +- name: Collect all cli test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/cli" + patterns: "{{ testcase }}.yaml" + use_regex: true + register: test_cases + delegate_to: localhost + +- name: Set test_items + ansible.builtin.set_fact: + test_items: "{{ test_cases.files | map(attribute='path') | list }}" + +- name: Run test case (connection=ansible.netcommon.network_cli) + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + vars: + ansible_connection: ansible.netcommon.network_cli + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/vyos_file/tasks/main.yaml b/tests/integration/targets/vyos_file/tasks/main.yaml new file mode 100644 index 00000000..e6378581 --- /dev/null +++ b/tests/integration/targets/vyos_file/tasks/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Run CLI tests + ansible.builtin.include_tasks: cli.yaml + tags: + - network_cli diff --git a/tests/integration/targets/vyos_file/tests/cli/_remove_files.yaml b/tests/integration/targets/vyos_file/tests/cli/_remove_files.yaml new file mode 100644 index 00000000..1c034aec --- /dev/null +++ b/tests/integration/targets/vyos_file/tests/cli/_remove_files.yaml @@ -0,0 +1,6 @@ +--- +- name: reset test directory to a known-absent baseline + vyos.vyos.vyos_command: + commands: + - "sudo rm -rf {{ test_dir }}" + ignore_errors: true diff --git a/tests/integration/targets/vyos_file/tests/cli/basic.yaml b/tests/integration/targets/vyos_file/tests/cli/basic.yaml new file mode 100644 index 00000000..65b71fd5 --- /dev/null +++ b/tests/integration/targets/vyos_file/tests/cli/basic.yaml @@ -0,0 +1,148 @@ +--- +- debug: + msg: START vyos_file basic integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_files.yaml + +- block: + - name: 1. create directory + vyos.vyos.vyos_file: + dest: "{{ test_dir }}" + state: present + owner: "{{ test_owner }}" + group: "{{ test_group }}" + mode: "0750" + register: t1 + + - assert: + that: + - t1.changed + - "'state' in t1.diff_fields" + - "'owner' in t1.diff_fields" + - "'group' in t1.diff_fields" + - "'mode' in t1.diff_fields" + + - name: 2. re-run same task — must be a no-op + vyos.vyos.vyos_file: + dest: "{{ test_dir }}" + state: present + owner: "{{ test_owner }}" + group: "{{ test_group }}" + mode: "0750" + register: t2 + + - assert: + that: + - not t2.changed + - t2.diff_fields == [] + + - name: 3. push inline content + vyos.vyos.vyos_file: + dest: "{{ test_dir }}/hello.txt" + content: "integration test content\n" + owner: "{{ test_owner }}" + group: "{{ test_group }}" + mode: "0600" + register: t3 + + - assert: + that: + - t3.changed + - "'content' in t3.diff_fields" + + - name: 3b. re-run identical content push — must be a no-op + vyos.vyos.vyos_file: + dest: "{{ test_dir }}/hello.txt" + content: "integration test content\n" + owner: "{{ test_owner }}" + group: "{{ test_group }}" + mode: "0600" + register: t3b + + - assert: + that: + - not t3b.changed + - t3b.diff_fields == [] + + - name: 4. change mode only, non-canonical string + vyos.vyos.vyos_file: + dest: "{{ test_dir }}/hello.txt" + mode: "0640" + register: t4 + + - assert: + that: + - t4.changed + - t4.diff_fields == ['mode'] + + - name: 5. re-assert same mode, non-zero-padded — mode string normalization + vyos.vyos.vyos_file: + dest: "{{ test_dir }}/hello.txt" + mode: "640" + register: t5 + + - assert: + that: + - not t5.changed + - t5.diff_fields == [] + + - name: 6. check_mode dry run must report a diff without converging + vyos.vyos.vyos_file: + dest: "{{ test_dir }}/hello.txt" + mode: "0777" + check_mode: true + register: t6 + + - assert: + that: + - t6.changed + - t6.diff_fields == ['mode'] + + - name: verify check_mode did not actually touch the file + vyos.vyos.vyos_command: + commands: + - "sudo stat --format='%a' {{ test_dir }}/hello.txt" + register: post_check_mode_stat + + - assert: + that: + - "'640' in post_check_mode_stat.stdout[0]" + fail_msg: "check_mode leaked a real converge — mode changed despite check_mode:true" + + - name: 7. remove file + vyos.vyos.vyos_file: + dest: "{{ test_dir }}/hello.txt" + state: absent + register: t7 + + - assert: + that: + - t7.changed + - t7.diff_fields == ['state'] + + - name: 8. remove again — must be a no-op + vyos.vyos.vyos_file: + dest: "{{ test_dir }}/hello.txt" + state: absent + register: t8 + + - assert: + that: + - not t8.changed + - t8.diff_fields == [] + + - name: 9. explicit setgid request is honored (not ignored like an implicit one) + vyos.vyos.vyos_file: + dest: "{{ test_dir }}" + mode: "2750" + register: t9 + + - assert: + that: + # mode was already 2750 in practice (VyOS's own setgid convention + # on /config/auth — vyos.dev T2713) so an EXPLICIT request for the + # same value is a no-op; this distinguishes "explicitly asked for + # setgid" from "didn't care" (which ignores the special-bits digit) + - not t9.changed + always: + - include_tasks: _remove_files.yaml diff --git a/tests/integration/targets/vyos_file/vars/main.yaml b/tests/integration/targets/vyos_file/vars/main.yaml new file mode 100644 index 00000000..7d47ca72 --- /dev/null +++ b/tests/integration/targets/vyos_file/vars/main.yaml @@ -0,0 +1,4 @@ +--- +test_dir: /config/auth/_vyos_file_test +test_group: vyattacfg +test_owner: vyos diff --git a/tests/sanity/ignore-2.15.txt b/tests/sanity/ignore-2.15.txt index a6ddbd51..bae35e38 100644 --- a/tests/sanity/ignore-2.15.txt +++ b/tests/sanity/ignore-2.15.txt @@ -1,36 +1,37 @@ plugins/action/vyos.py action-plugin-docs # base class for deprecated network platform modules using `connection: local` plugins/action/banner.py action-plugin-docs plugins/action/bgp_address_family.py action-plugin-docs plugins/action/bgp_global.py action-plugin-docs plugins/action/command.py action-plugin-docs plugins/action/config.py action-plugin-docs plugins/action/facts.py action-plugin-docs plugins/action/firewall_global.py action-plugin-docs plugins/action/firewall_interfaces.py action-plugin-docs plugins/action/firewall_rules.py action-plugin-docs plugins/action/hostname.py action-plugin-docs plugins/action/interface.py action-plugin-docs plugins/action/interfaces.py action-plugin-docs plugins/action/l3_interface.py action-plugin-docs plugins/action/l3_interfaces.py action-plugin-docs plugins/action/lag_interfaces.py action-plugin-docs plugins/action/linkagg.py action-plugin-docs plugins/action/lldp.py action-plugin-docs plugins/action/lldp_global.py action-plugin-docs plugins/action/lldp_interface.py action-plugin-docs plugins/action/lldp_interfaces.py action-plugin-docs plugins/action/logging_global.py action-plugin-docs plugins/action/ntp_global.py action-plugin-docs plugins/action/ospf_interfaces.py action-plugin-docs plugins/action/ospfv2.py action-plugin-docs plugins/action/ospfv3.py action-plugin-docs plugins/action/ping.py action-plugin-docs plugins/action/prefix_lists.py action-plugin-docs plugins/action/route_maps.py action-plugin-docs plugins/action/snmp_server.py action-plugin-docs plugins/action/static_route.py action-plugin-docs plugins/action/static_routes.py action-plugin-docs plugins/action/system.py action-plugin-docs plugins/action/user.py action-plugin-docs plugins/action/vlan.py action-plugin-docs plugins/action/vrf.py action-plugin-docs +plugins/action/file.py action-plugin-docs diff --git a/tests/sanity/ignore-2.16.txt b/tests/sanity/ignore-2.16.txt index a6ddbd51..bae35e38 100644 --- a/tests/sanity/ignore-2.16.txt +++ b/tests/sanity/ignore-2.16.txt @@ -1,36 +1,37 @@ plugins/action/vyos.py action-plugin-docs # base class for deprecated network platform modules using `connection: local` plugins/action/banner.py action-plugin-docs plugins/action/bgp_address_family.py action-plugin-docs plugins/action/bgp_global.py action-plugin-docs plugins/action/command.py action-plugin-docs plugins/action/config.py action-plugin-docs plugins/action/facts.py action-plugin-docs plugins/action/firewall_global.py action-plugin-docs plugins/action/firewall_interfaces.py action-plugin-docs plugins/action/firewall_rules.py action-plugin-docs plugins/action/hostname.py action-plugin-docs plugins/action/interface.py action-plugin-docs plugins/action/interfaces.py action-plugin-docs plugins/action/l3_interface.py action-plugin-docs plugins/action/l3_interfaces.py action-plugin-docs plugins/action/lag_interfaces.py action-plugin-docs plugins/action/linkagg.py action-plugin-docs plugins/action/lldp.py action-plugin-docs plugins/action/lldp_global.py action-plugin-docs plugins/action/lldp_interface.py action-plugin-docs plugins/action/lldp_interfaces.py action-plugin-docs plugins/action/logging_global.py action-plugin-docs plugins/action/ntp_global.py action-plugin-docs plugins/action/ospf_interfaces.py action-plugin-docs plugins/action/ospfv2.py action-plugin-docs plugins/action/ospfv3.py action-plugin-docs plugins/action/ping.py action-plugin-docs plugins/action/prefix_lists.py action-plugin-docs plugins/action/route_maps.py action-plugin-docs plugins/action/snmp_server.py action-plugin-docs plugins/action/static_route.py action-plugin-docs plugins/action/static_routes.py action-plugin-docs plugins/action/system.py action-plugin-docs plugins/action/user.py action-plugin-docs plugins/action/vlan.py action-plugin-docs plugins/action/vrf.py action-plugin-docs +plugins/action/file.py action-plugin-docs diff --git a/tests/sanity/ignore-2.17.txt b/tests/sanity/ignore-2.17.txt index a6ddbd51..bae35e38 100644 --- a/tests/sanity/ignore-2.17.txt +++ b/tests/sanity/ignore-2.17.txt @@ -1,36 +1,37 @@ plugins/action/vyos.py action-plugin-docs # base class for deprecated network platform modules using `connection: local` plugins/action/banner.py action-plugin-docs plugins/action/bgp_address_family.py action-plugin-docs plugins/action/bgp_global.py action-plugin-docs plugins/action/command.py action-plugin-docs plugins/action/config.py action-plugin-docs plugins/action/facts.py action-plugin-docs plugins/action/firewall_global.py action-plugin-docs plugins/action/firewall_interfaces.py action-plugin-docs plugins/action/firewall_rules.py action-plugin-docs plugins/action/hostname.py action-plugin-docs plugins/action/interface.py action-plugin-docs plugins/action/interfaces.py action-plugin-docs plugins/action/l3_interface.py action-plugin-docs plugins/action/l3_interfaces.py action-plugin-docs plugins/action/lag_interfaces.py action-plugin-docs plugins/action/linkagg.py action-plugin-docs plugins/action/lldp.py action-plugin-docs plugins/action/lldp_global.py action-plugin-docs plugins/action/lldp_interface.py action-plugin-docs plugins/action/lldp_interfaces.py action-plugin-docs plugins/action/logging_global.py action-plugin-docs plugins/action/ntp_global.py action-plugin-docs plugins/action/ospf_interfaces.py action-plugin-docs plugins/action/ospfv2.py action-plugin-docs plugins/action/ospfv3.py action-plugin-docs plugins/action/ping.py action-plugin-docs plugins/action/prefix_lists.py action-plugin-docs plugins/action/route_maps.py action-plugin-docs plugins/action/snmp_server.py action-plugin-docs plugins/action/static_route.py action-plugin-docs plugins/action/static_routes.py action-plugin-docs plugins/action/system.py action-plugin-docs plugins/action/user.py action-plugin-docs plugins/action/vlan.py action-plugin-docs plugins/action/vrf.py action-plugin-docs +plugins/action/file.py action-plugin-docs diff --git a/tests/sanity/ignore-2.18.txt b/tests/sanity/ignore-2.18.txt index a6ddbd51..bae35e38 100644 --- a/tests/sanity/ignore-2.18.txt +++ b/tests/sanity/ignore-2.18.txt @@ -1,36 +1,37 @@ plugins/action/vyos.py action-plugin-docs # base class for deprecated network platform modules using `connection: local` plugins/action/banner.py action-plugin-docs plugins/action/bgp_address_family.py action-plugin-docs plugins/action/bgp_global.py action-plugin-docs plugins/action/command.py action-plugin-docs plugins/action/config.py action-plugin-docs plugins/action/facts.py action-plugin-docs plugins/action/firewall_global.py action-plugin-docs plugins/action/firewall_interfaces.py action-plugin-docs plugins/action/firewall_rules.py action-plugin-docs plugins/action/hostname.py action-plugin-docs plugins/action/interface.py action-plugin-docs plugins/action/interfaces.py action-plugin-docs plugins/action/l3_interface.py action-plugin-docs plugins/action/l3_interfaces.py action-plugin-docs plugins/action/lag_interfaces.py action-plugin-docs plugins/action/linkagg.py action-plugin-docs plugins/action/lldp.py action-plugin-docs plugins/action/lldp_global.py action-plugin-docs plugins/action/lldp_interface.py action-plugin-docs plugins/action/lldp_interfaces.py action-plugin-docs plugins/action/logging_global.py action-plugin-docs plugins/action/ntp_global.py action-plugin-docs plugins/action/ospf_interfaces.py action-plugin-docs plugins/action/ospfv2.py action-plugin-docs plugins/action/ospfv3.py action-plugin-docs plugins/action/ping.py action-plugin-docs plugins/action/prefix_lists.py action-plugin-docs plugins/action/route_maps.py action-plugin-docs plugins/action/snmp_server.py action-plugin-docs plugins/action/static_route.py action-plugin-docs plugins/action/static_routes.py action-plugin-docs plugins/action/system.py action-plugin-docs plugins/action/user.py action-plugin-docs plugins/action/vlan.py action-plugin-docs plugins/action/vrf.py action-plugin-docs +plugins/action/file.py action-plugin-docs diff --git a/tests/sanity/ignore-2.19.txt b/tests/sanity/ignore-2.19.txt index a6ddbd51..bae35e38 100644 --- a/tests/sanity/ignore-2.19.txt +++ b/tests/sanity/ignore-2.19.txt @@ -1,36 +1,37 @@ plugins/action/vyos.py action-plugin-docs # base class for deprecated network platform modules using `connection: local` plugins/action/banner.py action-plugin-docs plugins/action/bgp_address_family.py action-plugin-docs plugins/action/bgp_global.py action-plugin-docs plugins/action/command.py action-plugin-docs plugins/action/config.py action-plugin-docs plugins/action/facts.py action-plugin-docs plugins/action/firewall_global.py action-plugin-docs plugins/action/firewall_interfaces.py action-plugin-docs plugins/action/firewall_rules.py action-plugin-docs plugins/action/hostname.py action-plugin-docs plugins/action/interface.py action-plugin-docs plugins/action/interfaces.py action-plugin-docs plugins/action/l3_interface.py action-plugin-docs plugins/action/l3_interfaces.py action-plugin-docs plugins/action/lag_interfaces.py action-plugin-docs plugins/action/linkagg.py action-plugin-docs plugins/action/lldp.py action-plugin-docs plugins/action/lldp_global.py action-plugin-docs plugins/action/lldp_interface.py action-plugin-docs plugins/action/lldp_interfaces.py action-plugin-docs plugins/action/logging_global.py action-plugin-docs plugins/action/ntp_global.py action-plugin-docs plugins/action/ospf_interfaces.py action-plugin-docs plugins/action/ospfv2.py action-plugin-docs plugins/action/ospfv3.py action-plugin-docs plugins/action/ping.py action-plugin-docs plugins/action/prefix_lists.py action-plugin-docs plugins/action/route_maps.py action-plugin-docs plugins/action/snmp_server.py action-plugin-docs plugins/action/static_route.py action-plugin-docs plugins/action/static_routes.py action-plugin-docs plugins/action/system.py action-plugin-docs plugins/action/user.py action-plugin-docs plugins/action/vlan.py action-plugin-docs plugins/action/vrf.py action-plugin-docs +plugins/action/file.py action-plugin-docs diff --git a/tests/sanity/ignore-2.20.txt b/tests/sanity/ignore-2.20.txt index a6ddbd51..bae35e38 100644 --- a/tests/sanity/ignore-2.20.txt +++ b/tests/sanity/ignore-2.20.txt @@ -1,36 +1,37 @@ plugins/action/vyos.py action-plugin-docs # base class for deprecated network platform modules using `connection: local` plugins/action/banner.py action-plugin-docs plugins/action/bgp_address_family.py action-plugin-docs plugins/action/bgp_global.py action-plugin-docs plugins/action/command.py action-plugin-docs plugins/action/config.py action-plugin-docs plugins/action/facts.py action-plugin-docs plugins/action/firewall_global.py action-plugin-docs plugins/action/firewall_interfaces.py action-plugin-docs plugins/action/firewall_rules.py action-plugin-docs plugins/action/hostname.py action-plugin-docs plugins/action/interface.py action-plugin-docs plugins/action/interfaces.py action-plugin-docs plugins/action/l3_interface.py action-plugin-docs plugins/action/l3_interfaces.py action-plugin-docs plugins/action/lag_interfaces.py action-plugin-docs plugins/action/linkagg.py action-plugin-docs plugins/action/lldp.py action-plugin-docs plugins/action/lldp_global.py action-plugin-docs plugins/action/lldp_interface.py action-plugin-docs plugins/action/lldp_interfaces.py action-plugin-docs plugins/action/logging_global.py action-plugin-docs plugins/action/ntp_global.py action-plugin-docs plugins/action/ospf_interfaces.py action-plugin-docs plugins/action/ospfv2.py action-plugin-docs plugins/action/ospfv3.py action-plugin-docs plugins/action/ping.py action-plugin-docs plugins/action/prefix_lists.py action-plugin-docs plugins/action/route_maps.py action-plugin-docs plugins/action/snmp_server.py action-plugin-docs plugins/action/static_route.py action-plugin-docs plugins/action/static_routes.py action-plugin-docs plugins/action/system.py action-plugin-docs plugins/action/user.py action-plugin-docs plugins/action/vlan.py action-plugin-docs plugins/action/vrf.py action-plugin-docs +plugins/action/file.py action-plugin-docs diff --git a/tests/sanity/ignore-2.21.txt b/tests/sanity/ignore-2.21.txt index a6ddbd51..bae35e38 100644 --- a/tests/sanity/ignore-2.21.txt +++ b/tests/sanity/ignore-2.21.txt @@ -1,36 +1,37 @@ plugins/action/vyos.py action-plugin-docs # base class for deprecated network platform modules using `connection: local` plugins/action/banner.py action-plugin-docs plugins/action/bgp_address_family.py action-plugin-docs plugins/action/bgp_global.py action-plugin-docs plugins/action/command.py action-plugin-docs plugins/action/config.py action-plugin-docs plugins/action/facts.py action-plugin-docs plugins/action/firewall_global.py action-plugin-docs plugins/action/firewall_interfaces.py action-plugin-docs plugins/action/firewall_rules.py action-plugin-docs plugins/action/hostname.py action-plugin-docs plugins/action/interface.py action-plugin-docs plugins/action/interfaces.py action-plugin-docs plugins/action/l3_interface.py action-plugin-docs plugins/action/l3_interfaces.py action-plugin-docs plugins/action/lag_interfaces.py action-plugin-docs plugins/action/linkagg.py action-plugin-docs plugins/action/lldp.py action-plugin-docs plugins/action/lldp_global.py action-plugin-docs plugins/action/lldp_interface.py action-plugin-docs plugins/action/lldp_interfaces.py action-plugin-docs plugins/action/logging_global.py action-plugin-docs plugins/action/ntp_global.py action-plugin-docs plugins/action/ospf_interfaces.py action-plugin-docs plugins/action/ospfv2.py action-plugin-docs plugins/action/ospfv3.py action-plugin-docs plugins/action/ping.py action-plugin-docs plugins/action/prefix_lists.py action-plugin-docs plugins/action/route_maps.py action-plugin-docs plugins/action/snmp_server.py action-plugin-docs plugins/action/static_route.py action-plugin-docs plugins/action/static_routes.py action-plugin-docs plugins/action/system.py action-plugin-docs plugins/action/user.py action-plugin-docs plugins/action/vlan.py action-plugin-docs plugins/action/vrf.py action-plugin-docs +plugins/action/file.py action-plugin-docs diff --git a/tests/sanity/ignore-2.22.txt b/tests/sanity/ignore-2.22.txt index a6ddbd51..bae35e38 100644 --- a/tests/sanity/ignore-2.22.txt +++ b/tests/sanity/ignore-2.22.txt @@ -1,36 +1,37 @@ plugins/action/vyos.py action-plugin-docs # base class for deprecated network platform modules using `connection: local` plugins/action/banner.py action-plugin-docs plugins/action/bgp_address_family.py action-plugin-docs plugins/action/bgp_global.py action-plugin-docs plugins/action/command.py action-plugin-docs plugins/action/config.py action-plugin-docs plugins/action/facts.py action-plugin-docs plugins/action/firewall_global.py action-plugin-docs plugins/action/firewall_interfaces.py action-plugin-docs plugins/action/firewall_rules.py action-plugin-docs plugins/action/hostname.py action-plugin-docs plugins/action/interface.py action-plugin-docs plugins/action/interfaces.py action-plugin-docs plugins/action/l3_interface.py action-plugin-docs plugins/action/l3_interfaces.py action-plugin-docs plugins/action/lag_interfaces.py action-plugin-docs plugins/action/linkagg.py action-plugin-docs plugins/action/lldp.py action-plugin-docs plugins/action/lldp_global.py action-plugin-docs plugins/action/lldp_interface.py action-plugin-docs plugins/action/lldp_interfaces.py action-plugin-docs plugins/action/logging_global.py action-plugin-docs plugins/action/ntp_global.py action-plugin-docs plugins/action/ospf_interfaces.py action-plugin-docs plugins/action/ospfv2.py action-plugin-docs plugins/action/ospfv3.py action-plugin-docs plugins/action/ping.py action-plugin-docs plugins/action/prefix_lists.py action-plugin-docs plugins/action/route_maps.py action-plugin-docs plugins/action/snmp_server.py action-plugin-docs plugins/action/static_route.py action-plugin-docs plugins/action/static_routes.py action-plugin-docs plugins/action/system.py action-plugin-docs plugins/action/user.py action-plugin-docs plugins/action/vlan.py action-plugin-docs plugins/action/vrf.py action-plugin-docs +plugins/action/file.py action-plugin-docs diff --git a/tests/unit/modules/network/vyos/test_vyos_file.py b/tests/unit/modules/network/vyos/test_vyos_file.py new file mode 100644 index 00000000..71480971 --- /dev/null +++ b/tests/unit/modules/network/vyos/test_vyos_file.py @@ -0,0 +1,248 @@ +# tests/unit/modules/network/vyos/test_vyos_file.py +# +# Mocks run_commands() directly — the real call path this module uses via +# get_connection()/run_commands() in module_utils/network/vyos/vyos.py. +# This replaces an earlier draft that mocked a bespoke ActionModule; that +# design was abandoned once it turned out every module in this collection +# (vyos_command, vyos_config, etc.) shares one generic action plugin and +# puts real logic inside main() instead. + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import json + +from unittest.mock import patch + +from ansible_collections.vyos.vyos.plugins.modules import vyos_file +from ansible_collections.vyos.vyos.tests.unit.modules.network.vyos.vyos_module import ( + TestVyosModule, +) +from ansible_collections.vyos.vyos.tests.unit.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + set_module_args, +) + + +class TestVyosFileModule(TestVyosModule): + + module = vyos_file + + def setUp(self): + super(TestVyosFileModule, self).setUp() + self.mock_run_commands = patch( + "ansible_collections.vyos.vyos.plugins.modules.vyos_file.run_commands", + ) + self.run_commands = self.mock_run_commands.start() + + def tearDown(self): + super(TestVyosFileModule, self).tearDown() + self.mock_run_commands.stop() + + # ---- helpers ----------------------------------------------------- + + def _queue(self, *responses): + """Queue successive return values, one per run_commands() call.""" + self.run_commands.side_effect = list(responses) + + def _run(self, args, expect_fail=False): + set_module_args(args) + exc = AnsibleFailJson if expect_fail else AnsibleExitJson + with self.assertRaises(exc) as ctx: + vyos_file.main() + return ctx.exception.args[0] + + # ---- idempotency core --------------------------------------------- + + def test_creates_when_absent(self): + # get_have() issues ONE stat call; converge() batches mkdir+chown+ + # chmod into a SINGLE run_commands() call (not one call per + # command); the post-check issues one more stat call. Three total + # run_commands() invocations, matching the module's actual batching. + self._queue( + ["stat: cannot statx '/config/auth/x': No such file or directory"], + ["", "", ""], # mkdir, chown, chmod — one batched call + ["750 vyos vyattacfg 4096"], # post-check stat + ) + result = self._run( + {"dest": "/config/auth/x", "owner": "vyos", "group": "vyattacfg", "mode": "0750"}, + ) + self.assertTrue(result["changed"]) + self.assertIn("state", result["diff_fields"]) + self.assertIn("owner", result["diff_fields"]) + + def test_noop_when_converged(self): + self._queue(["750 vyos vyattacfg 4096"]) + result = self._run( + {"dest": "/config/auth/x", "owner": "vyos", "group": "vyattacfg", "mode": "0750"}, + ) + self.assertFalse(result["changed"]) + self.assertEqual(result["diff_fields"], []) + + def test_setgid_ignored_when_mode_leading_digit_is_zero(self): + # /config/auth is deliberately setgid vyattacfg (vyos.dev T2713). + # Requesting mode '0750' (leading digit 0) must NOT be reported as + # different from an actual mode of 2750. + self._queue(["2750 vyos vyattacfg 4096"]) + result = self._run({"dest": "/config/auth/x", "mode": "0750"}) + self.assertFalse(result["changed"], result.get("diff_fields")) + + def test_setgid_respected_when_explicitly_requested(self): + # Explicit non-zero leading digit means the caller does care about + # the special bits — since 2750 is requested and 2750 is already + # there, this should be a no-op (only the initial stat call fires). + self._queue(["2750 vyos vyattacfg 4096"]) + result = self._run({"dest": "/config/auth/x", "mode": "2750"}) + self.assertFalse(result["changed"]) + + def test_mode_change_detected(self): + # Only mode differs, so converge() batches a single chmod command + # (one run_commands() call), then the post-check stat is a second. + self._queue( + ["600 vyos vyattacfg 10"], + [""], # chmod — the only mutating command needed + ["640 vyos vyattacfg 10"], + ) + result = self._run({"dest": "/config/auth/x/hello.txt", "mode": "0640"}) + self.assertTrue(result["changed"]) + self.assertEqual(result["diff_fields"], ["mode"]) + + def test_mode_string_normalization(self): + for requested in ("640", "0640", "00640"): + with self.subTest(requested=requested): + self._queue(["640 vyos vyattacfg 10"]) + result = self._run({"dest": "/config/auth/x/hello.txt", "mode": requested}) + self.assertFalse( + result["changed"], + "mode {0!r} incorrectly compared unequal to stat's '640'".format(requested), + ) + + # ---- content --------------------------------------------------------- + + def test_content_push_detected_and_verified(self): + # need_content_hash is True (content was given), but get_have() + # only actually hashes when the path already exists — on the + # initial (absent) lookup it's skipped, so that first call is a + # single stat. converge() batches base64-write + chown into one + # call. The post-check, now that the file exists, issues stat AND + # sha256sum as two separate calls. The queued hash must be the + # REAL sha256("hi\n") hex digest, or the module's own post-check + # will (correctly) call fail_json on a genuine mismatch. + real_hash = "98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4" + self._queue( + ["stat: cannot statx '/config/auth/x/hello.txt': No such file or directory"], + ["", "", ""], # base64 write, chown, chmod — batched (mode was also requested) + ["600 vyos vyattacfg 10"], # post-check stat + ["{0} /config/auth/x/hello.txt".format(real_hash)], # post-check sha256sum + ) + result = self._run( + { + "dest": "/config/auth/x/hello.txt", + "content": "hi\n", + "owner": "vyos", + "mode": "0600", + }, + ) + self.assertTrue(result["changed"]) + self.assertIn("content", result["diff_fields"]) + + def test_content_hash_looked_up_only_when_relevant(self): + # plain ownership/mode management on an existing path should never + # trigger a sha256sum call — that's the whole point of the + # need_content_hash gate. + self._queue(["750 vyos vyattacfg 4096"]) + self._run({"dest": "/config/auth/x", "mode": "0750"}) + called_commands = [c.args[1] for c in self.run_commands.call_args_list] + joined = " ".join(str(c) for c in called_commands) + self.assertNotIn("sha256sum", joined) + + # ---- absent state ---------------------------------------------------- + + def test_absent_on_existing_removes(self): + self._queue( + ["600 vyos vyattacfg 10"], + [""], # rm -rf + ["stat: cannot statx '/config/auth/x/hello.txt': No such file or directory"], + ) + result = self._run({"dest": "/config/auth/x/hello.txt", "state": "absent"}) + self.assertTrue(result["changed"]) + self.assertEqual(result["diff_fields"], ["state"]) + + def test_absent_noop_when_already_gone(self): + self._queue(["stat: cannot statx '/x': No such file or directory"]) + result = self._run({"dest": "/x", "state": "absent"}) + self.assertFalse(result["changed"]) + + def test_real_stat_error_fails_loudly_instead_of_treated_as_missing(self): + # Permission denied (or any other real stat failure) must NOT be + # silently treated the same as "doesn't exist" — that could lead + # the module to attempt mkdir/chown/chmod against a path it + # actually has no real visibility into. + self._queue(["stat: cannot statx '/x': Permission denied"]) + result = self._run({"dest": "/x", "mode": "0750"}, expect_fail=True) + self.assertIn("unexpected stat output", result["msg"]) + + def test_malformed_sha256sum_output_does_not_get_recorded_as_a_hash(self): + # If sha256sum itself errors (e.g. a race where the file vanished + # between stat and sha256sum), the garbage output must not be + # silently trusted as a real content hash — that would corrupt the + # comparison instead of surfacing as a real, visible diff. + # check_mode=True keeps this isolated to have/diff computation only, + # without needing to model a full converge cycle. + self._queue( + ["600 vyos vyattacfg 10"], + ["sha256sum: /x: No such file or directory"], + ) + set_module_args({"dest": "/x", "content": "hi\n", "_ansible_check_mode": True}) + with self.assertRaises(AnsibleExitJson) as ctx: + vyos_file.main() + result = ctx.exception.args[0] + # have.content_hash stays unset -> compared against a real want hash + # -> reported as a genuine diff, not silently accepted as converged. + self.assertIn("content", result.get("diff_fields", [])) + + # ---- silent-failure detection (the real bug this caught on hardware) -- + + def test_post_check_fails_module_when_chown_silently_no_ops(self): + # Reproduces the real failure found on hardware: chown to a + # nonexistent group prints an error but the CLI still reports the + # line as "executed" with rc 0 — have must be re-verified. + # converge() batches mkdir+chown into one call (mode wasn't + # requested, so no chmod); the second queued item represents that + # single batched call's two responses. + self._queue( + ["stat: cannot statx '/x': No such file or directory"], + ["", "chown: invalid group: 'x:bogus'"], # mkdir ok, chown failed + ["644 root nogroup 4096"], # post-check: neither owner nor group took + ) + result = self._run({"dest": "/x", "owner": "vyos", "group": "bogus"}, expect_fail=True) + self.assertIn("post-check", result["msg"]) + + # ---- check_mode -------------------------------------------------------- + + def test_check_mode_reports_diff_without_converging(self): + self._queue(["600 vyos vyattacfg 10"]) + set_module_args({"dest": "/x", "mode": "0640", "_ansible_check_mode": True}) + with self.assertRaises(AnsibleExitJson) as ctx: + vyos_file.main() + result = ctx.exception.args[0] + self.assertTrue(result["changed"]) + self.assertEqual(result["diff_fields"], ["mode"]) + # only the initial stat call should have happened — no chmod + self.assertEqual(self.run_commands.call_count, 1) + + # ---- secrets discipline ------------------------------------------------ + + def test_content_not_echoed_in_result(self): + real_hash = "03767fbe485736bb40cc5d85e4c9bb10b12a415674b46faf005aa22188a39a10" + self._queue( + ["stat: cannot statx '/x': No such file or directory"], + [""], # single batched command: base64 write only (no owner/mode given) + ["600 root root 4"], # post-check stat + ["{0} /x".format(real_hash)], # post-check sha256sum + ) + result = self._run({"dest": "/x", "content": "super-secret-value"}) + self.assertNotIn("super-secret-value", json.dumps(result))