I am trying to automate a config entry modification. I have AIX servers which have a file login.cfg and there is a line on which available shells are configured. It is like this:
usw:
shells = /bin/sh,/bin/bsh,/bin/csh,/bin/ksh,/bin/tsh,/bin/ksh93,/usr/bin/sh,/usr/bin/bsh,/usr/bin/csh,/usr/bin/ksh,/usr/bin/tsh,/usr/bin/ksh93,/usr/bin/rksh,/usr/bin/rksh93,/usr/sbin/uucp/uucico,/usr/sbin/snappd,/usr/sbin/sliplogin
maxlogins = 32767
logintimeout = 60
My goal is to append ,/usr/bin/bash to the end of the shells line, when it is not already present. The order of shells is not uniform among the hosts, for a reason.
My attempt to achieve this modification is like this snippet below – for testing purposes I just edit login.cfg locally. It has two steps: first testing if bash is already present and then edit the line if not.
---
- name: test adding /bin/bash to the end of the line IF NOT THERE
hosts: localhost
tasks:
- name: Check if bash is already there
shell: "grep -E 'shells = .*/usr/bin/bash' {{ playbook_dir }}/login.cfg"
ignore_errors: yes
register: result
- name: add entry if not there
lineinfile:
backrefs: yes
line: 'g<list>,/usr/bin/bash'
path: "{{ playbook_dir }}/login.cfg"
regexp: "(?P<list> +shells =.*)"
when: result.rc != 0
My question is if there is a method to avoid the testing using shell: module. Is there any more Ansible-ish way to do the same?
2
Let’s parse the content of the file first. For example, if you replace the equal signs with colons you get YAML
_regex: 's*='
_replace: ':'
login_dict: "{{ lookup('file', 'login.cfg') |
regex_replace(_regex, _replace) |
from_yaml }}"
gives
login_dict:
usw:
logintimeout: 60
maxlogins: 32767
shells: /bin/sh,/bin/bsh,/bin/csh,/bin/ksh,/bin/tsh,/bin/ksh93,/usr/bin/sh,/usr/bin/bsh,/usr/bin/csh,/usr/bin/ksh,/usr/bin/tsh,/usr/bin/ksh93,/usr/bin/rksh,/usr/bin/rksh93,/usr/sbin/uucp/uucico,/usr/sbin/snappd,/usr/sbin/sliplogin
Split the shells
shells: "{{ login_dict.usw.shells | split(',') }}"
gives
shells:
- /bin/sh
- /bin/bsh
- /bin/csh
- /bin/ksh
- /bin/tsh
- /bin/ksh93
- /usr/bin/sh
- /usr/bin/bsh
- /usr/bin/csh
- /usr/bin/ksh
- /usr/bin/tsh
- /usr/bin/ksh93
- /usr/bin/rksh
- /usr/bin/rksh93
- /usr/sbin/uucp/uucico
- /usr/sbin/snappd
- /usr/sbin/sliplogin
and, as required, “append /usr/bin/bash to the end of the shells line, when it is not already present“
shells_append: /usr/bin/bash
shells_update: "{{ (shells_append in shells) |
ternary(shells, shells + [shells_append]) |
join(',') }}"
Use shells_update in the module lineinfile
- name: add entry if not there
lineinfile:
backrefs: true
path: login.cfg
regexp: '^(s*shells)s*=.*$'
line: '1 = {{ shells_update }}'
Optionally, you can update the dictionary
usw_update:
usw:
shells: "{{ shells_update }}"
login_update: "{{ login_dict | combine(usw_update, recursive='true') }}"
gives
login_update:
usw:
logintimeout: 60
maxlogins: 32767
shells: /bin/sh,/bin/bsh,/bin/csh,/bin/ksh,/bin/tsh,/bin/ksh93,/usr/bin/sh,/usr/bin/bsh,/usr/bin/csh,/usr/bin/ksh,/usr/bin/tsh,/usr/bin/ksh93,/usr/bin/rksh,/usr/bin/rksh93,/usr/sbin/uucp/uucico,/usr/sbin/snappd,/usr/sbin/sliplogin,/usr/bin/bash
and create a template
- debug:
msg: |
{% for section,conf in login_update.items() %}
{{ section }}:
{% for k,v in conf.items() %}
{{ k }} = {{ v }}
{% endfor %}
{% endfor %}
gives
msg: |-
usw:
shells = /bin/sh,/bin/bsh,/bin/csh,/bin/ksh,/bin/tsh,/bin/ksh93,/usr/bin/sh,/usr/bin/bsh,/usr/bin/csh,/usr/bin/ksh,/usr/bin/tsh,/usr/bin/ksh93,/usr/bin/rksh,/usr/bin/rksh93,/usr/sbin/uucp/uucico,/usr/sbin/snappd,/usr/sbin/sliplogin,/usr/bin/bash
maxlogins = 32767
logintimeout = 60
Copy the content to the file
- name: copy content
copy:
dest: login.cfg
content: |
{% for section,conf in login_update.items() %}
{{ section }}:
{% for k,v in conf.items() %}
{{ k }} = {{ v }}
{% endfor %}
{% endfor %}
Both tasks are idempotent and give the same result. There might be a systemic option on how to parse this format in AIX.
Example of a complete playbook for testing
- hosts: localhost
vars:
_regex: 's*='
_replace: ':'
login_dict: "{{ lookup('file', 'login.cfg') |
regex_replace(_regex, _replace) |
from_yaml }}"
shells: "{{ login_dict.usw.shells | split(',') }}"
shells_append: /usr/bin/bash
shells_update: "{{ (shells_append in shells) |
ternary(shells, shells + [shells_append]) |
join(',') }}"
usw_update:
usw:
shells: "{{ shells_update }}"
login_update: "{{ login_dict | combine(usw_update, recursive='true') }}"
tasks:
- debug:
var: login_dict
- debug:
var: shells
- debug:
var: shells_update
- name: add entry if not there
lineinfile:
backrefs: true
path: login.cfg
regexp: '^(s*shells)s*=.*$'
line: '1 = {{ shells_update }}'
when: lineinfile | d(false) | bool
- debug:
var: usw_update
- debug:
var: login_update
- debug:
msg: |
{% for section,conf in login_update.items() %}
{{ section }}:
{% for k,v in conf.items() %}
{{ k }} = {{ v }}
{% endfor %}
{% endfor %}
- name: copy content
copy:
dest: login.cfg
content: |
{% for section,conf in login_update.items() %}
{{ section }}:
{% for k,v in conf.items() %}
{{ k }} = {{ v }}
{% endfor %}
{% endfor %}
when: content | d(false) | bool
The optimized play below will skip the task if the shell is in the list
- hosts: localhost
vars:
_regex: 's*='
_replace: ':'
login_dict: "{{ lookup('file', 'login.cfg') |
regex_replace(_regex, _replace) |
from_yaml }}"
shells: "{{ login_dict.usw.shells | split(',') }}"
shells_append: /usr/bin/bash
tasks:
- name: add entry if not there
lineinfile:
backrefs: true
path: login.cfg
regexp: '^(s*shells)s*=.*$'
line: '1 = {{ (shells + [shells_append]) | join(",") }}'
when: shells_append not in shells
This can be further simplified
- hosts: localhost
vars:
_regex: 's*='
_replace: ':'
login_conf: "{{ lookup('file', 'login.cfg') |
regex_replace(_regex, _replace) |
from_yaml }}"
shells: "{{ login_conf.usw.shells }}"
shells_append: /usr/bin/bash
tasks:
- name: add entry if not there
lineinfile:
backrefs: true
path: login.cfg
regexp: '^(s*shells)s*=.*$'
line: '1 = {{ [shells, shells_append] | join(",") }}'
when: shells_append not in shells
1
One quick and cheap way to do it could be the following:
First, it would be necessary to gather facts about the Remote Nodes. Even if it is not the best way it could simply be done via a minimal example
---
- hosts: test
become: false
gather_facts: false
vars:
FACT: "shells"
SEARCH_STRING: "/usr/bin/bash"
SEARCH_FILE: "login.cfg"
tasks:
# grep string from Remote File
- name: Gathering Custom Facts
shell:
cmd: "grep {{ FACT }} {{ SEARCH_FILE }} | tr -d ' ' | cut -d '=' -f 2"
register: shells
# Since this is a reporting, gathering facts task
# it needs to deliver a result in any case
failed_when: shells.rc != 0 and shells.rc != 1
check_mode: false
changed_when: false
- name: Show available remote shells
debug:
msg: "{{ shells.stdout }}"
when: ansible_check_mode
resulting into an output of
TASK [Show available remote shells] *********************************************************************************************************************************
ok: [test.example.com] =>
msg: /bin/sh,/bin/bsh,/bin/csh,/bin/ksh,/bin/tsh,/bin/ksh93,/usr/bin/sh,/usr/bin/bsh,/usr/bin/csh,/usr/bin/ksh,/usr/bin/tsh,/usr/bin/ksh93,/usr/bin/rksh,/usr/bin/rksh93,/usr/sbin/uucp/uucico,/usr/sbin/snappd,/usr/sbin/sliplogin
Q: “Is there a method to avoid the testing using the shell
module?“
A better approach is shown under How to search for a string in a Remote File using Ansible?, Ansible: How to append an option? or just use Custom facts directly.
Second, append entry if it is not contained already
- name: Append entry if it is not contained already
lineinfile:
path: "{{ SEARCH_FILE }}"
regexp: ".*{{ FACT }} =.*"
line: " {{ FACT }} = {{ shells.stdout }},{{ SEARCH_STRING }}"
when: not shells.stdout is search(SEARCH_STRING)
My main question was how to find out if an entry is present in a config file, without using shell:
module. My answer is to use lineinfile:
with check_mode: yes
By the way, up to this moment I was thinking that quotation marks and apostrophes are interchangeable in Ansible playbooks. The experience shows that they are not. The line:
row, which contains regex bacrefs, gave me syntax error with quotation marks, but is working fine with apostrophes
---
- name: testing the behavior of check_mode option
hosts: localhost
vars:
searchstr: "/usr/bin/bash"
logincfgfile: "{{ playbook_dir }}/login.cfg"
become: no
tasks:
- name: check if line is present
lineinfile:
path: "{{ logincfgfile }}"
regex: "shells = .*{{ searchstr }}.*"
state: absent
check_mode: yes
register: entrypresent
- name: add shell entry if it was not present
lineinfile:
backrefs: yes
path: "{{ logincfgfile }}"
regex: "(?P<shellsline> *shells = .*)"
line: 'g<shellsline>,{{ searchstr }}'
# line: "g<shellsline>,{{ searchstr }}" # not working with quotation marks
when: entrypresent.found == 0
For the sake of simplicity I am using a local file to experiment on. This is defined in logincfgfile. I am trying do delete the line with the shell definitions, including the shell to be added, but I do it in check_mode, so there is no real change to the file, just collecting data – and registering into entrypresent variable. The output of lineinfile
has a field, called found
, which holds the number of matches. If it is zero, thus no matches, the next task adds the new shell entry.