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 

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