Repository: ansible-collections/community.general

Issue:
interfaces_file regression. not idempotent with down/up option.
### Summary

Regression from #3328: The new version repeatedly adds new lines for up/down option. The previous version (ansible 3.0) only added the option if it was not yet present.


### Issue Type

Bug Report

### Component Name

interfaces_file

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.11.6] 
  config file = /home/pgassmann/git/aviloo/aviloo-ansible/ansible.cfg
  configured module search path = ['/home/pgassmann/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/pgassmann/.local/share/virtualenvs/aviloo-ansible-GSJhdB0r/lib/python3.8/site-packages/ansible
  ansible collection location = /home/pgassmann/git/aviloo/aviloo-ansible/collections
  executable location = /home/pgassmann/.local/share/virtualenvs/aviloo-ansible-GSJhdB0r/bin/ansible
  python version = 3.8.10 (default, Sep 28 2021, 16:10:42) [GCC 9.3.0]
  jinja version = 3.0.2
  libyaml = True

```


### Community.general Version

```console (paste below)
$ ansible-galaxy collection list community.general

# /home/pgassmann/.local/share/virtualenvs/ansible-GSJhdB0r/lib/python3.8/site-packages/ansible_collections
Collection        Version
----------------- -------
community.general 3.8.0  

```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed
ANSIBLE_NOCOWS(/home/pgassmann/git/ansible/ansible.cfg) = True
COLLECTIONS_PATHS(/home/pgassmann/git/ansible/ansible.cfg) = ['/home/pgassmann/git/ansible/collections']
CONDITIONAL_BARE_VARS(/home/pgassmann/git/ansible/ansible.cfg) = False
DEFAULT_FORKS(/home/pgassmann/git/ansible/ansible.cfg) = 20
DEFAULT_GATHERING(/home/pgassmann/git/ansible/ansible.cfg) = smart
DEFAULT_HOST_LIST(/home/pgassmann/git/ansible/ansible.cfg) = ['/home/pgassmann/git/ansible/inventory/testing.yml']
DEFAULT_LOAD_CALLBACK_PLUGINS(/home/pgassmann/git/ansible/ansible.cfg) = False
DEFAULT_MANAGED_STR(/home/pgassmann/git/ansible/ansible.cfg) = Managed by Ansible
DEFAULT_REMOTE_USER(/home/pgassmann/git/ansible/ansible.cfg) = root
DEFAULT_ROLES_PATH(env: ANSIBLE_ROLES_PATH) = ['/home/pgassmann/git/ansible/roles_galaxy', '/home/pgassmann/git/ansible/roles']
DEFAULT_STDOUT_CALLBACK(/home/pgassmann/git/ansible/ansible.cfg) = yaml
DEFAULT_VAULT_PASSWORD_FILE(/home/pgassmann/git/ansible/ansible.cfg) = /home/pgassmann/git/ansible/.vault_password
DISPLAY_SKIPPED_HOSTS(env: ANSIBLE_DISPLAY_SKIPPED_HOSTS) = False
INVENTORY_ENABLED(/home/pgassmann/git/ansible/ansible.cfg) = ['yaml']
RETRY_FILES_ENABLED(/home/pgassmann/git/ansible/ansible.cfg) = False

```


### OS / Environment

Ubuntu 20.04

### Steps to Reproduce

Apply repeatedly:

```yaml
- name: floating_ip_interface_up_ip 2a01:a:b:c::1/64 dev eth0
  interfaces_file:
    option: up
    iface: eth0
    dest: '/etc/network/interfaces.d/50-cloud-init.cfg'
    value: 'ip addr add 2a01:a:b:c::1/64 dev eth0'
    state: present
```



### Expected Results

no duplicate lines

### Actual Results

result: `ansible-playbook --diff --check -vvv`
```yaml
TASK [floating_ip : floating_ip_interface_down_ip 2a01:a:b:c::1/64 dev eth0] ***************************************************
task path: /../roles/floating_ip/tasks/floating_ip.yml:12
changed: [mail01p.example.org] => changed=true 
  dest: /etc/network/interfaces.d/50-cloud-init.cfg
  gid: 0
  group: root
  ifaces:
    eth0:
      address: 2a01:d:e:f::1/64
      address_family: inet6
      down:
      - ip addr del 88.1.2.332 dev eth0
      - ip addr del 49.1.2.3/32 dev eth0
      - ip addr del 2a01:a:b:c::1/64 dev eth0
      - ip addr del 2a01:a:b:c::1/64 dev eth0
      gateway: fe80::1
      method: static
      post-up: []
      pre-up: []
      up:
      - ip addr add 88.1.2.332 dev eth0
      - ip addr add 49.1.2.3/32 dev eth0
      - ip addr add 2a01:a:b:c::1/64 dev eth0
    lo:
      address_family: inet
      down: []
      method: loopback
      post-up: []
      pre-up: []
      up: []
  invocation:
    module_args:
      address_family: null
      attributes: null
      backup: false
      dest: /etc/network/interfaces.d/50-cloud-init.cfg
      group: null
      iface: eth0
      mode: null
      option: down
      owner: null
      selevel: null
      serole: null
      setype: null
      seuser: null
      state: present
      unsafe_writes: false
      value: ip addr del 2a01:a:b:c::1/64 dev eth0
  mode: '0644'
  owner: root
  size: 823
  state: file
  uid: 0
```

resulting interface file after multiple runs:

```
auto lo
iface lo inet loopback

auto eth0
iface eth0 inet dhcp
    dns-nameservers 213.133.99.99 213.133.98.98 213.133.100.100

# control-alias eth0
iface eth0 inet6 static
    address 2a01:d:e:f::1/64
    gateway fe80::1
    up ip addr add 88.1.2.3/32 dev eth0
    down ip addr del 88.1.2.3/32 dev eth0
    # ipv6
    up ip addr add 2a01:a:b:c::1/64 dev eth0
    up ip addr add 49.12.115.122/32 dev eth0
    up ip addr add 88.1.2.3/32 dev eth0
    up ip addr add 2a01:a:b:c::1/64 dev eth0
    up ip addr add 88.1.2.3/32 dev eth0
    up ip addr add 2a01:a:b:c::1/64 dev eth0
    up ip addr add 88.1.2.3/32 dev eth0
    up ip addr add 2a01:a:b:c::1/64 dev eth0
    down ip addr del 2a01:a:b:c::1/64 dev eth0
    down ip addr del 49.12.115.122/32 dev eth0
    down ip addr del 88.1.2.3/32 dev eth0
    down ip addr del 2a01:a:b:c::1/64 dev eth0
    down ip addr del 88.1.2.3/32 dev eth0
    down ip addr del 2a01:a:b:c::1/64 dev eth0
    down ip addr del 88.1.2.3/32 dev eth0
    down ip addr del 2a01:a:b:c::1/64 dev eth0
```

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/system/interfaces_file.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/system/interfaces_file.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @hryamzik @obourdon
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@russoz @hryamzik @felixfontein here's the separate ticket for https://github.com/ansible-collections/community.general/issues/3328#issuecomment-980469260

It would be optimal if the update would remove duplicates. At least state: absent should remove all items at once. (didn't test that)
Unfortunately I don't have any time to handle this.

@russoz it would be great if you could address this. I've also noticed you added some tests to integration dir while all other tests are done [here](https://github.com/ansible-collections/community.general/tree/main/tests/unit/plugins/modules/system/interfaces_file/fixtures). Original way looks a bit more clear to me.

@pgassmann it may make sense for you to create a branch with test cases covering this issue and `absence` for duplicates in input. PR with a fix for the issue is also welcomed!

P.S. there's actually a [test case](https://github.com/ansible-collections/community.general/blob/6b91c56c4e73fda9d10d11cecc9f938a64ccd710/tests/unit/plugins/modules/system/interfaces_file/test_interfaces_file.py#L90) for idempotention, it's wired it didn't catch this issue.
@pgassmann @hryamzik will be looking into that the next couple of days 
@pgassmann @hryamzik please review the PR ;-)
Did you run tests for [golden files](https://github.com/ansible-collections/community.general/tree/main/tests/unit/plugins/modules/system/interfaces_file/fixtures/golden_output)? I'd also expect to see cases covered here in these tests.

P.S.: I can see new tests in targets and they LGTM but I'm not confident about coverage and reliability – the only trusted thing in assertion seem to be the `change` state.

P.P.S. this project misses `make test` command so much! Ok, that's done with cloning to `ansible_collections/community/general` and then running `ansible-test units --docker -v --python 3.6 tests/unit/plugins/modules/system/interfaces_file/test_interfaces_file.py`, running without docker requires some pips: `pip3.7 install pytest-xdist pytest-mock mock`
Hi @hryamzik nop, I did not work on the unit tests files, only in the integration ones.

As of the PS, yes that test was enough for this particular bug, but we can definitely add more. I would suggest doing that in another PR.

About the PPS, I have this project to help running these tests:
https://pypi.org/project/andebox/

I  have managed to use it inside a VM, with vagrant, but that is not automated yet.
OK, I've created a new #3863 PR for tests. I've also verified that `absent` state removes duplicates.

Resolving patch:
diff --git a/plugins/modules/system/interfaces_file.py b/plugins/modules/system/interfaces_file.py
--- a/plugins/modules/system/interfaces_file.py
+++ b/plugins/modules/system/interfaces_file.py
@@ -253,7 +253,7 @@ def set_interface_option(module, lines, iface, option, raw_value, state, address
             last_line_dict = iface_lines[-1]
             return add_option_after_line(option, value, iface, lines, last_line_dict, iface_options, address_family)
 
-        if option in ["pre-up", "up", "down", "post-up"] and all(ito for ito in target_options if ito['value'] != value):
+        if option in ["pre-up", "up", "down", "post-up"] and all(ito['value'] != value for ito in target_options):
             return add_option_after_line(option, value, iface, lines, target_options[-1], iface_options, address_family)
 
         # if more than one option found edit the last one


Test patch:
diff --git a/tests/integration/targets/interfaces_file/files/interfaces_ff_3841 b/tests/integration/targets/interfaces_file/files/interfaces_ff_3841
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/interfaces_file/files/interfaces_ff_3841
@@ -0,0 +1,7 @@
+iface eth0 inet static
+  address   1.2.3.4
+  netmask   255.255.255.0
+  gateway   1.2.3.1
+  up route add -net 1.2.3.4 netmask 255.255.255.0 gw 1.2.3.1 eth0
+  up ip addr add 4.3.2.1/32 dev eth0
+  down ip addr add 4.3.2.1/32 dev eth0
diff --git a/tests/integration/targets/interfaces_file/tasks/main.yml b/tests/integration/targets/interfaces_file/tasks/main.yml
--- a/tests/integration/targets/interfaces_file/tasks/main.yml
+++ b/tests/integration/targets/interfaces_file/tasks/main.yml
@@ -2,6 +2,7 @@
 - name:
   set_fact:
     interfaces_testfile: '{{ remote_tmp_dir }}/interfaces'
+    interfaces_testfile_3841: '{{ remote_tmp_dir }}/interfaces_3841'
 
 - name: Copy interfaces file
   copy:
@@ -31,3 +32,32 @@
 - assert:
     that:
     - ifile_2 is not changed
+
+- name: 3841 - copy interfaces file
+  copy:
+    src: 'files/interfaces_ff_3841'
+    dest: '{{ interfaces_testfile_3841 }}'
+
+- name: 3841 - floating_ip_interface_up_ip 2a01:a:b:c::1/64 dev eth0
+  interfaces_file:
+    option: up
+    iface: eth0
+    dest: "{{ interfaces_testfile_3841 }}"
+    value: 'ip addr add 2a01:a:b:c::1/64 dev eth0'
+    state: present
+  register: ifile_3841_a
+
+- name: 3841 - floating_ip_interface_up_ip 2a01:a:b:c::1/64 dev eth0 (again)
+  interfaces_file:
+    option: up
+    iface: eth0
+    dest: "{{ interfaces_testfile_3841 }}"
+    value: 'ip addr add 2a01:a:b:c::1/64 dev eth0'
+    state: present
+  register: ifile_3841_b
+
+- name: 3841 - check assertions
+  assert:
+    that:
+      - ifile_3841_a is changed
+      - ifile_3841_b is not changed