diff --git a/labs/ex294/1-1-inventories-and-modules/README.md b/labs/ex294/1-1-inventories-and-modules/README.md new file mode 100644 index 0000000..76754a7 --- /dev/null +++ b/labs/ex294/1-1-inventories-and-modules/README.md @@ -0,0 +1,41 @@ +# EX294 1.1: Inventories, modules and ad hoc commands + +Write an inventory with two groups, reach every host with the ping module, +install a package with an ad hoc command, and pick hosts with a pattern. + +## Before you start + +This VM is the control node. The lab installs `ansible-core` and creates +the user `greg` (password `Lab-Pass-1`), who has an SSH key. Work as greg +(`su - greg` as root), in `/home/greg/ansible`: its `ansible.cfg` reads the +inventory file `inventory`, which doesn't exist yet, and logs in as +`ansible`. Answers go in `/root/lab-answers/`, which the lab creates; +write them as root. + +The managed nodes `node1`, `node2` and `node3` (10.99.1.11 to .13) are +AlmaLinux 10 containers with their own users and packages, reached over +SSH by name. On each, the user `ansible` accepts greg's key and may use +sudo without a password. For a root shell on a node, +`sudo podman exec -it node1 bash`. + +## Tasks + +1. Write the inventory `/home/greg/ansible/inventory` with `node1` and + `node2` in the group `webservers` and `node3` in `dbservers`, and show it + as a tree. +2. Run the ping module against every host; each must answer `pong`. +3. Install the `tree` package on the webservers with an ad hoc command, then + run the same command again: the first run says CHANGED, the second + SUCCESS. `ansible webservers -a 'rpm -q tree'` finds it. +4. List the hosts matched by `all:!dbservers` without running anything, and + write the hosts it shows to `/root/lab-answers/4`. + +## Hints + +- `ansible-inventory -i inventory --graph`. +- `ansible all -m ansible.builtin.ping`. +- `ansible webservers -m ansible.builtin.dnf -a 'name=tree state=present'` + needs `-b` to become root. +- `--list-hosts`; quote the pattern so the shell leaves the `!` alone. + Root can run Ansible from greg's project too: + `cd /home/greg/ansible && ansible ... >/root/lab-answers/4`. diff --git a/labs/ex294/1-1-inventories-and-modules/check.sh b/labs/ex294/1-1-inventories-and-modules/check.sh new file mode 100644 index 0000000..847c05e --- /dev/null +++ b/labs/ex294/1-1-inventories-and-modules/check.sh @@ -0,0 +1,35 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 180 runuser -l greg -c "cd ~/ansible && $*" /dev/null | python3 -c 'import json, sys +d = json.load(sys.stdin) +def hosts(g): + v = d.get(g, {}) + return set(v.get("hosts", [])).union(*[hosts(c) for c in v.get("children", [])]) +print(" ".join(sorted(hosts(sys.argv[1]))))' "$1" +} +under_all() { ans ansible-inventory -i inventory --list 2>/dev/null | python3 -c 'import json, sys +sys.exit(0 if {"webservers", "dbservers"} <= set(json.load(sys.stdin)["all"].get("children", [])) else 1)'; } +answer_has() { grep -qw "$2" "/root/lab-answers/$1" 2>/dev/null; } + +task 1 "An inventory with node1 and node2 in webservers and node3 in dbservers" +need "/home/greg/ansible/inventory exists" '[ -e /home/greg/ansible/inventory ]' +need "webservers holds node1 and node2, and only those" '[ "$(members webservers)" = "node1 node2" ]' +need "dbservers holds node3, and only it" '[ "$(members dbservers)" = node3 ]' +need "both groups are directly under @all" under_all + +task 2 "Every host answers the ping module" +need "ansible all -m ping gets pong from node1, node2 and node3" 'ans ansible all -o -m ansible.builtin.ping >"$OUT"; for n in node1 node2 node3; do grep -Eq "^$n \| SUCCESS.*pong" "$OUT" || exit 1; done' + +task 3 "tree on the webservers, and only there" +need "tree is installed on node1" 'in_node node1 rpm -q tree' +need "tree is installed on node2" 'in_node node2 rpm -q tree' +need "tree is not installed on node3, which isn't a webserver" '! in_node node3 rpm -q tree' + +task 4 "The hosts matched by all:!dbservers, in /root/lab-answers/4" +need "/root/lab-answers/4 names node1 and node2" 'answer_has 4 node1 && answer_has 4 node2' +need "/root/lab-answers/4 doesn't name node3" '! answer_has 4 node3' +rm -f "$OUT" diff --git a/labs/ex294/1-1-inventories-and-modules/lab.conf b/labs/ex294/1-1-inventories-and-modules/lab.conf new file mode 100644 index 0000000..3369756 --- /dev/null +++ b/labs/ex294/1-1-inventories-and-modules/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.1 Inventories, modules and ad hoc commands" +CHAPTER=/ex294/1-core-components/1-1-inventories-and-modules/ +SYSTEMS="rhel" diff --git a/labs/ex294/1-1-inventories-and-modules/reset.sh b/labs/ex294/1-1-inventories-and-modules/reset.sh new file mode 100644 index 0000000..1d6ad79 --- /dev/null +++ b/labs/ex294/1-1-inventories-and-modules/reset.sh @@ -0,0 +1,5 @@ +# The nodes go with nodes_down, greg (with his project and key) with +# users_remove, and /root/lab-answers with le-lab; ansible-core if setup +# installed it. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/1-1-inventories-and-modules/setup.sh b/labs/ex294/1-1-inventories-and-modules/setup.sh new file mode 100644 index 0000000..4e4e019 --- /dev/null +++ b/labs/ex294/1-1-inventories-and-modules/setup.sh @@ -0,0 +1,25 @@ +# The control node: ansible-core and greg's project, with no inventory yet +# (task 1). +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'EOF' +[defaults] +inventory = inventory +remote_user = ansible +host_key_checking = false +EOF +chown greg: /home/greg/ansible/ansible.cfg + +# The managed nodes. +nodes_up 3 +# greg's key goes to ansible on every node, and ansible may use sudo without +# a password. +for n in $(nodes_names); do + in_node -i "$n" 'install -d -m 700 -o ansible -g ansible /home/ansible/.ssh && + install -m 600 -o ansible -g ansible /dev/stdin /home/ansible/.ssh/authorized_keys && + echo "ansible ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/ansible && + chmod 440 /etc/sudoers.d/ansible' "$T/cb/le_lab_record.py" <<'X' +import json +import os + +from ansible.plugins.callback import CallbackBase + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'aggregate' + CALLBACK_NAME = 'le_lab_record' + CALLBACK_NEEDS_ENABLED = True + + def __init__(self): + super().__init__() + self.events = [] + + def _add(self, status, result): + t = result._task + r = {k: v for k, v in result._result.items() if k != 'ansible_facts'} + self.events.append({'host': result._host.get_name(), 'task': t.get_name(), 'action': t.action, + 'role': t._role.get_name() if t._role else '', 'status': status, 'result': r}) + + def v2_runner_on_ok(self, result): + self._add('changed' if result._result.get('changed') else 'ok', result) + + def v2_runner_on_failed(self, result, ignore_errors=False): + self._add('ignored' if ignore_errors else 'failed', result) + + def v2_runner_on_skipped(self, result): + self._add('skipped', result) + + def v2_runner_on_unreachable(self, result): + self._add('unreachable', result) + + def v2_playbook_on_stats(self, stats): + out = {'events': self.events, 'stats': {h: stats.summarize(h) for h in stats.processed}} + with open(os.environ['LE_LAB_JSON'], 'w') as f: + json.dump(out, f, default=str) +X +cat >"$T/q.py" <<'X' +import json, re, sys +try: + d = json.load(open(sys.argv[1])) +except Exception: + sys.exit(1) +E, S = d['events'], d['stats'] +def ev(host=None, status=None): + return [e for e in E if host in (None, e['host']) and status in (None, e['status'])] +def text(e): + return json.dumps({k: v for k, v in e['result'].items() if not k.startswith('_')}) +def debug(e): + return e['action'].split('.')[-1] == 'debug' +def has(e, word): + return re.search(r'(?"$T/out.log" 2>&1 +} +js() { python3 -c "exec(open('$T/q.py').read()); sys.exit(0 if ($2) else 1)" "$T/out/$1.json"; } +# hostvar HOST: HOST's http_port as the inventory sees it. +hostvar() { ans ansible-inventory --host "$1" 2>/dev/null | python3 -c 'import json, sys; print(json.load(sys.stdin).get("http_port", ""))'; } +# A host the lab adds to webservers for a moment, to see what the group gives. +printf '[webservers]\nlab-probe\n' >"$T/probe.ini" +probevar() { ans ansible-inventory -i inventory -i "$T/probe.ini" --host lab-probe 2>/dev/null | python3 -c 'import json, sys; print(json.load(sys.stdin).get("http_port", ""))'; } +# fact HOST NAME: a fact as the setup module reports it (JSON for a dictionary). +fact() { ans ansible "$1" -m ansible.builtin.setup -a "filter=ansible_$2" 2>/dev/null | sed '1s/^[^{]*//' | python3 -c 'import json, sys +v = json.load(sys.stdin)["ansible_facts"]["ansible_" + sys.argv[1]] +print(v if isinstance(v, (str, int)) else json.dumps(v))' "$2"; } +# shows NAME HOST VALUE: a debug task of the play printed VALUE on HOST. +shows() { [ -n "$3" ] && js "$1" "any(debug(e) and has(e, '$3') for e in ev('$2'))"; } +# facts_shown HOST: facts.yml printed HOST's distribution, version and memory. +facts_shown() { + local f + for f in distribution distribution_version memtotal_mb; do shows facts "$1" "$(fact "$1" $f)" || return 1; done +} +role_fact() { fact node1 local | python3 -c 'import json, sys; sys.exit(json.load(sys.stdin)["role"]["general"]["role"] != "web")'; } + +task 1 "http_port: 80 for webservers, 8080 for node2, printed by port.yml" +need "webservers hosts get http_port 80 from the group" '[ "$(probevar)" = 80 ]' +need "node1's http_port is 80" '[ "$(hostvar node1)" = 80 ]' +need "node2's http_port is 8080" '[ "$(hostvar node2)" = 8080 ]' +need "/home/greg/ansible/port.yml exists" '[ -f /home/greg/ansible/port.yml ]' +need "ansible-playbook port.yml runs without errors" 'play port port.yml' +need "port.yml prints 80 on node1 and 8080 on node2" 'shows port node1 80 && shows port node2 8080' +need "with -e http_port=9000, port.yml prints 9000 on both" 'play port9 port.yml -e http_port=9000; shows port9 node1 9000 && shows port9 node2 9000' + +task 2 "facts.yml prints each node's distribution, version and memory" +need "/home/greg/ansible/facts.yml exists" '[ -f /home/greg/ansible/facts.yml ]' +need "ansible-playbook facts.yml runs without errors" 'play facts facts.yml' +for n in $(nodes_names); do + need "facts.yml prints the distribution, its version and the memory on $n" "facts_shown $n" +done + +task 3 "A custom fact role=web in [general] on node1" +need "node1 has /etc/ansible/facts.d/role.fact" 'in_node node1 test -f /etc/ansible/facts.d/role.fact' +need "the setup module shows role: web under general in ansible_local on node1" role_fact diff --git a/labs/ex294/1-2-variables-and-facts/lab.conf b/labs/ex294/1-2-variables-and-facts/lab.conf new file mode 100644 index 0000000..ce225e9 --- /dev/null +++ b/labs/ex294/1-2-variables-and-facts/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.2 Variables and facts" +CHAPTER=/ex294/1-core-components/1-2-variables-and-facts/ +SYSTEMS="rhel" diff --git a/labs/ex294/1-2-variables-and-facts/reset.sh b/labs/ex294/1-2-variables-and-facts/reset.sh new file mode 100644 index 0000000..1d6ad79 --- /dev/null +++ b/labs/ex294/1-2-variables-and-facts/reset.sh @@ -0,0 +1,5 @@ +# The nodes go with nodes_down, greg (with his project and key) with +# users_remove, and /root/lab-answers with le-lab; ansible-core if setup +# installed it. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/1-2-variables-and-facts/setup.sh b/labs/ex294/1-2-variables-and-facts/setup.sh new file mode 100644 index 0000000..a110320 --- /dev/null +++ b/labs/ex294/1-2-variables-and-facts/setup.sh @@ -0,0 +1,29 @@ +# The control node: ansible-core and greg's project, with an inventory but +# no variables (task 1). +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'EOF' +[defaults] +inventory = inventory +remote_user = ansible +host_key_checking = false +EOF +printf '[webservers] +node1 +node2 +' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, with no custom facts (task 3). +nodes_up 2 +# greg's key goes to ansible on every node, and ansible may use sudo without +# a password. +for n in $(nodes_names); do + in_node -i "$n" 'install -d -m 700 -o ansible -g ansible /home/ansible/.ssh && + install -m 600 -o ansible -g ansible /dev/stdin /home/ansible/.ssh/authorized_keys && + echo "ansible ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/ansible && + chmod 440 /etc/sudoers.d/ansible' `, which the lab creates; write them as root. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers with their own users and packages, reached over +SSH by name. On each, the user `ansible` accepts greg's key and may use +sudo without a password. They run no firewall. For a root shell on a node, +`sudo podman exec -it node1 bash`. + +## Tasks + +1. Write `/home/greg/ansible/site.yml` with one play that installs, starts + and enables httpd and writes a home page, + `/var/www/html/index.html`, on the webservers, and another play that + installs `mariadb-server` on the dbservers. Run it twice: the second run + shows `changed=0` for every host, and `curl http://node1` from the + control node prints the page (open the firewall if one is running). +2. Change the page's text in `site.yml` and preview the change on node1 + only, without making it: `--check --diff --limit node1` shows the old and + the new line, and `curl http://node1` still prints the old text. Leave + it that way. +3. Copy `site.yml` to `/home/greg/ansible/broken.yml`, break the + indentation of one task there, and find the error with `--syntax-check`. + Write the line number it names to `/root/lab-answers/3`. + +## Hints + +- The plays are a YAML list: each starts with `- name:` and has `hosts:`, + `become: true` and `tasks:`. +- `ansible.builtin.dnf`, `ansible.builtin.copy` with `content:` and + `dest:`, and `ansible.builtin.service` with `state: started` and + `enabled: true`. +- `ansible-playbook site.yml --check --diff --limit node1`. +- `ansible-playbook broken.yml --syntax-check`; look for "line N" in the + error. diff --git a/labs/ex294/1-3-plays-and-playbooks/check.sh b/labs/ex294/1-3-plays-and-playbooks/check.sh new file mode 100644 index 0000000..f66e53e --- /dev/null +++ b/labs/ex294/1-3-plays-and-playbooks/check.sh @@ -0,0 +1,109 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" "$T/cb/le_lab_record.py" <<'X' +import json +import os + +from ansible.plugins.callback import CallbackBase + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'aggregate' + CALLBACK_NAME = 'le_lab_record' + CALLBACK_NEEDS_ENABLED = True + + def __init__(self): + super().__init__() + self.events = [] + + def _add(self, status, result): + t = result._task + r = {k: v for k, v in result._result.items() if k != 'ansible_facts'} + self.events.append({'host': result._host.get_name(), 'task': t.get_name(), 'action': t.action, + 'role': t._role.get_name() if t._role else '', 'status': status, 'result': r}) + + def v2_runner_on_ok(self, result): + self._add('changed' if result._result.get('changed') else 'ok', result) + + def v2_runner_on_failed(self, result, ignore_errors=False): + self._add('ignored' if ignore_errors else 'failed', result) + + def v2_runner_on_skipped(self, result): + self._add('skipped', result) + + def v2_runner_on_unreachable(self, result): + self._add('unreachable', result) + + def v2_playbook_on_stats(self, stats): + out = {'events': self.events, 'stats': {h: stats.summarize(h) for h in stats.processed}} + with open(os.environ['LE_LAB_JSON'], 'w') as f: + json.dump(out, f, default=str) +X +cat >"$T/q.py" <<'X' +import json, re, sys +try: + d = json.load(open(sys.argv[1])) +except Exception: + sys.exit(1) +E, S = d['events'], d['stats'] +def ev(host=None, status=None): + return [e for e in E if host in (None, e['host']) and status in (None, e['status'])] +def text(e): + return json.dumps({k: v for k, v in e['result'].items() if not k.startswith('_')}) +def debug(e): + return e['action'].split('.')[-1] == 'debug' +def has(e, word): + return re.search(r'(?"$T/out.log" 2>&1 +} +js() { python3 -c "exec(open('$T/q.py').read()); sys.exit(0 if ($2) else 1)" "$T/out/$1.json"; } +cat >>"$T/q.py" <<'X' +def diffs(e): + x = e['result'].get('diff', []) + return x if isinstance(x, list) else [x] +def page(x): + return '/var/www/html/' in str(x.get('after_header', '')) + str(x.get('before_header', '')) +def page_diffs(e): + return [x for x in diffs(e) if page(x) and x.get('before') != x.get('after')] +X +# jp NAME EXPR: print a Python expression on the play's results. +jp() { python3 -c "exec(open('$T/q.py').read()); print($2)" "$T/out/$1.json" 2>/dev/null; } +# page: what node1 serves, retrying for up to 15 seconds. +page() { + local i + for i in $(seq 15); do curl -fsS --max-time 5 http://node1/ 2>/dev/null && return 0; sleep 1; done + return 1 +} +P=/home/greg/ansible +# One run of site.yml in check mode, for tasks 1 and 2. +[ -f $P/site.yml ] && play site site.yml --check --diff + +task 1 "site.yml: httpd with a home page on the webservers, mariadb-server on the dbservers" +need "/home/greg/ansible/site.yml exists" "[ -f $P/site.yml ]" +need "httpd is installed on node1" 'in_node node1 rpm -q httpd' +need "httpd is enabled on node1" 'in_node node1 systemctl is-enabled httpd' +need "httpd is running on node1" 'in_node node1 systemctl is-active httpd' +need "curl http://node1 from the control node prints a page" 'page | grep -q .' +need "mariadb-server is installed on node2" 'in_node node2 rpm -q mariadb-server' +need "site.yml --check runs on node1 and node2 with no failures" 'js site "ev(\"node1\") and ev(\"node2\") and not ev(status=\"failed\") and not ev(status=\"unreachable\")"' +need "another run of site.yml would change nothing but the page (--check)" 'js site "all(page_diffs(e) for e in ev(status=\"changed\"))"' + +task 2 "A new page text, previewed on node1 but not made" +need "site.yml --check --diff would change the page on node1" 'js site "any(page_diffs(e) for e in ev(\"node1\", \"changed\"))"' +need "curl http://node1 still prints the old page" '[ "$(page)" = "$(jp site "next(x for e in ev(\"node1\", \"changed\") for x in page_diffs(e))[\"before\"].rstrip()")" ]' + +task 3 "broken.yml: a task with bad indentation, the line in /root/lab-answers/3" +need "/home/greg/ansible/broken.yml exists" "[ -f $P/broken.yml ]" +need "ansible-playbook --syntax-check broken.yml fails, naming the file and a line" "! ans ansible-playbook broken.yml --syntax-check >$T/syntax.out 2>&1 && grep -Eq 'broken.yml.: line [0-9]+' $T/syntax.out" +need "/root/lab-answers/3 holds the line number it names" 'grep -qw "$(grep -Eo "broken.yml.: line [0-9]+" $T/syntax.out | grep -Eo "[0-9]+$" | head -n1)" /root/lab-answers/3' diff --git a/labs/ex294/1-3-plays-and-playbooks/lab.conf b/labs/ex294/1-3-plays-and-playbooks/lab.conf new file mode 100644 index 0000000..13fd98d --- /dev/null +++ b/labs/ex294/1-3-plays-and-playbooks/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.3 Plays and playbooks" +CHAPTER=/ex294/1-core-components/1-3-plays-and-playbooks/ +SYSTEMS="rhel" diff --git a/labs/ex294/1-3-plays-and-playbooks/reset.sh b/labs/ex294/1-3-plays-and-playbooks/reset.sh new file mode 100644 index 0000000..1d6ad79 --- /dev/null +++ b/labs/ex294/1-3-plays-and-playbooks/reset.sh @@ -0,0 +1,5 @@ +# The nodes go with nodes_down, greg (with his project and key) with +# users_remove, and /root/lab-answers with le-lab; ansible-core if setup +# installed it. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/1-3-plays-and-playbooks/setup.sh b/labs/ex294/1-3-plays-and-playbooks/setup.sh new file mode 100644 index 0000000..34edee4 --- /dev/null +++ b/labs/ex294/1-3-plays-and-playbooks/setup.sh @@ -0,0 +1,30 @@ +# The control node: ansible-core and greg's project, with an inventory. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'EOF' +[defaults] +inventory = inventory +remote_user = ansible +host_key_checking = false +EOF +printf '[webservers] +node1 + +[dbservers] +node2 +' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, without httpd or mariadb-server (task 1). +nodes_up 2 +# greg's key goes to ansible on every node, and ansible may use sudo without +# a password. +for n in $(nodes_names); do + in_node -i "$n" 'install -d -m 700 -o ansible -g ansible /home/ansible/.ssh && + install -m 600 -o ansible -g ansible /dev/stdin /home/ansible/.ssh/authorized_keys && + echo "ansible ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/ansible && + chmod 440 /etc/sudoers.d/ansible' 2048`, and `<= 2048` for the other + task. All the hosts here have the same memory, so they all print the + same message. +- `loop: "{{ ports | dict2items }}"` with + `msg: "{{ item.key }} {{ item.value }}"`. +- `ansible.builtin.command: /bin/false` with + `when: inventory_hostname == 'node1'`, then any task after it. Don't use + `ignore_errors`. diff --git a/labs/ex294/1-4-loops-conditions-and-failures/check.sh b/labs/ex294/1-4-loops-conditions-and-failures/check.sh new file mode 100644 index 0000000..11f956b --- /dev/null +++ b/labs/ex294/1-4-loops-conditions-and-failures/check.sh @@ -0,0 +1,126 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" "$T/cb/le_lab_record.py" <<'X' +import json +import os + +from ansible.plugins.callback import CallbackBase + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'aggregate' + CALLBACK_NAME = 'le_lab_record' + CALLBACK_NEEDS_ENABLED = True + + def __init__(self): + super().__init__() + self.events = [] + + def _add(self, status, result): + t = result._task + r = {k: v for k, v in result._result.items() if k != 'ansible_facts'} + self.events.append({'host': result._host.get_name(), 'task': t.get_name(), 'action': t.action, + 'role': t._role.get_name() if t._role else '', 'status': status, 'result': r}) + + def v2_runner_on_ok(self, result): + self._add('changed' if result._result.get('changed') else 'ok', result) + + def v2_runner_on_failed(self, result, ignore_errors=False): + self._add('ignored' if ignore_errors else 'failed', result) + + def v2_runner_on_skipped(self, result): + self._add('skipped', result) + + def v2_runner_on_unreachable(self, result): + self._add('unreachable', result) + + def v2_playbook_on_stats(self, stats): + out = {'events': self.events, 'stats': {h: stats.summarize(h) for h in stats.processed}} + with open(os.environ['LE_LAB_JSON'], 'w') as f: + json.dump(out, f, default=str) +X +cat >"$T/q.py" <<'X' +import json, re, sys +try: + d = json.load(open(sys.argv[1])) +except Exception: + sys.exit(1) +E, S = d['events'], d['stats'] +def ev(host=None, status=None): + return [e for e in E if host in (None, e['host']) and status in (None, e['status'])] +def text(e): + return json.dumps({k: v for k, v in e['result'].items() if not k.startswith('_')}) +def debug(e): + return e['action'].split('.')[-1] == 'debug' +def has(e, word): + return re.search(r'(?"$T/out.log" 2>&1 +} +js() { python3 -c "exec(open('$T/q.py').read()); sys.exit(0 if ($2) else 1)" "$T/out/$1.json"; } +cat >>"$T/q.py" <<'X' +def items(e): + return [r.get('item') for r in e['result'].get('results', [])] +def users_loop(e): + i = items(e) + return (len(i) >= 2 and all(isinstance(x, dict) for x in i) + and any('alice' in x.values() for x in i) and any('bob' in x.values() for x in i)) +# What a looped result shows, whatever its key (msg, a var's name); with +# var: item, debug's key is the loop's own "item". +LOOP_KEYS = {'item', 'ansible_loop_var', 'ansible_index_var', 'changed', 'failed', 'skipped', 'skip_reason', 'false_condition'} +def shown(e, r): + s = {k: v for k, v in r.items() if not k.startswith('_') and k not in LOOP_KEYS} + return s if s or not debug(e) else {'item': r.get('item')} +def pairs_shown(e): + rs = e['result'].get('results', []) + return (len(rs) >= 2 and all(isinstance(r.get('item'), dict) and set(r['item']) == {'key', 'value'} for r in rs) + and all(has({'result': shown(e, r)}, r['item']['key']) and has({'result': shown(e, r)}, r['item']['value']) for r in rs)) +def mem_split(h): + d = [e for e in ev(h) if debug(e)] + return (any(e['status'] == 'ok' for e in d) + and any(e['status'] == 'skipped' and 'mem' in str(e['result'].get('false_condition', '')) for e in d)) +def failed_after(h): + # h failed a task, every other host ran a task after that, and only h failed. + i = next((n for n, e in enumerate(E) if e['host'] == h and e['status'] == 'failed'), None) + others = [o for o in S if o != h] + return (i is not None and others and S[h]['failures'] == 1 + and all(S[o]['failures'] == 0 and S[o]['unreachable'] == 0 for o in others) + and all(any(e['host'] == o and e['status'] in ('ok', 'changed') for e in E[i + 1:]) for o in others)) +X +P=/home/greg/ansible + +task 1 "alice (in wheel) and bob on node1, from a list of dictionaries in one task" +need "alice is on node1, in wheel" 'in_node node1 id -nG alice | grep -qw wheel' +need "bob is on node1" 'in_node node1 id bob' +need "/home/greg/ansible/users.yml exists" "[ -f $P/users.yml ]" +need "ansible-playbook users.yml runs without errors" 'play users users.yml' +need "one task of users.yml loops over dictionaries for alice and bob on node1" 'js users "any(users_loop(e) for e in ev(\"node1\"))"' +need "running users.yml again changes nothing on node1 (both items ok)" 'js users "S[\"node1\"][\"changed\"] == 0"' + +task 2 "memory.yml: one message on hosts with over 2 GiB, another on the rest" +need "/home/greg/ansible/memory.yml exists" "[ -f $P/memory.yml ]" +need "ansible-playbook memory.yml runs without errors" 'play memory memory.yml' +for n in $(nodes_names); do + need "on $n, one message shows and the other is skipped by a condition on memory" "js memory 'mem_split(\"$n\")'" +done + +task 3 "services.yml: print each name and port of a dictionary, with dict2items" +need "/home/greg/ansible/services.yml exists" "[ -f $P/services.yml ]" +need "ansible-playbook services.yml runs without errors" 'play services services.yml' +need "a looped task prints each item.key and item.value of dict2items" 'js services "any(pairs_shown(e) for e in E)"' +need "services.yml uses dict2items" "grep -q dict2items $P/services.yml" + +task 4 "fail.yml: a task fails on one host and the others carry on" +need "/home/greg/ansible/fail.yml exists" "[ -f $P/fail.yml ]" +need "ansible-playbook fail.yml reaches every node" 'play fail fail.yml; js fail "set(S) >= {\"node1\", \"node2\"}"' +need "the recap shows failed=1 for one host only, and the others ran tasks after it" 'js fail "any(failed_after(h) for h in S)"' diff --git a/labs/ex294/1-4-loops-conditions-and-failures/lab.conf b/labs/ex294/1-4-loops-conditions-and-failures/lab.conf new file mode 100644 index 0000000..709f77b --- /dev/null +++ b/labs/ex294/1-4-loops-conditions-and-failures/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.4 Loops, conditions and task failure" +CHAPTER=/ex294/1-core-components/1-4-loops-conditions-and-failures/ +SYSTEMS="rhel" diff --git a/labs/ex294/1-4-loops-conditions-and-failures/reset.sh b/labs/ex294/1-4-loops-conditions-and-failures/reset.sh new file mode 100644 index 0000000..1d6ad79 --- /dev/null +++ b/labs/ex294/1-4-loops-conditions-and-failures/reset.sh @@ -0,0 +1,5 @@ +# The nodes go with nodes_down, greg (with his project and key) with +# users_remove, and /root/lab-answers with le-lab; ansible-core if setup +# installed it. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/1-4-loops-conditions-and-failures/setup.sh b/labs/ex294/1-4-loops-conditions-and-failures/setup.sh new file mode 100644 index 0000000..09e979a --- /dev/null +++ b/labs/ex294/1-4-loops-conditions-and-failures/setup.sh @@ -0,0 +1,28 @@ +# The control node: ansible-core and greg's project, with an inventory. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'EOF' +[defaults] +inventory = inventory +remote_user = ansible +host_key_checking = false +EOF +printf '[nodes] +node1 +node2 +' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, without alice or bob (task 1). +nodes_up 2 +# greg's key goes to ansible on every node, and ansible may use sudo without +# a password. +for n in $(nodes_names); do + in_node -i "$n" 'install -d -m 700 -o ansible -g ansible /home/ansible/.ssh && + install -m 600 -o ansible -g ansible /dev/stdin /home/ansible/.ssh/authorized_keys && + echo "ansible ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/ansible && + chmod 440 /etc/sudoers.d/ansible' `, which +the lab creates; write them as root. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers with their own users and packages, reached over +SSH by name. On each, the user `ansible` accepts greg's key and may use +sudo without a password. For a root shell on a node, +`sudo podman exec -it node1 bash`. + +## Tasks + +1. As greg, find which `ansible.cfg` Ansible uses in your project directory + and in `/tmp`. Write the two paths to `/root/lab-answers/1`, the + project directory's on line 1 and `/tmp`'s on line 2. +2. List the settings your project's `ansible.cfg` changes, each with your + file's path, and write the list to `/root/lab-answers/2`. +3. Create a role `motd` in `/home/greg/ansible/roles/motd` that writes + `/etc/motd` from a default variable, `motd_text`, and use it on every + node with the playbook `/home/greg/ansible/motd.yml`. + `ansible all -a 'cat /etc/motd'` prints the text, and running the + playbook with `-e motd_text=Hello` changes it. Leave `/etc/motd` with + the default text. + +## Hints + +- `ansible --version | grep 'config file'`. +- `ansible-config dump --only-changed`; root can run it from greg's + project with `cd /home/greg/ansible && ... >/root/lab-answers/2`. +- `ansible-galaxy role init roles/motd`; the task goes in `tasks/main.yml` + and the variable in `defaults/main.yml`. +- A play with `hosts: all`, `become: true` and `roles: [motd]`. diff --git a/labs/ex294/1-5-configuration-files-and-roles/check.sh b/labs/ex294/1-5-configuration-files-and-roles/check.sh new file mode 100644 index 0000000..fb5ca51 --- /dev/null +++ b/labs/ex294/1-5-configuration-files-and-roles/check.sh @@ -0,0 +1,103 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" "$T/cb/le_lab_record.py" <<'X' +import json +import os + +from ansible.plugins.callback import CallbackBase + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'aggregate' + CALLBACK_NAME = 'le_lab_record' + CALLBACK_NEEDS_ENABLED = True + + def __init__(self): + super().__init__() + self.events = [] + + def _add(self, status, result): + t = result._task + r = {k: v for k, v in result._result.items() if k != 'ansible_facts'} + self.events.append({'host': result._host.get_name(), 'task': t.get_name(), 'action': t.action, + 'role': t._role.get_name() if t._role else '', 'status': status, 'result': r}) + + def v2_runner_on_ok(self, result): + self._add('changed' if result._result.get('changed') else 'ok', result) + + def v2_runner_on_failed(self, result, ignore_errors=False): + self._add('ignored' if ignore_errors else 'failed', result) + + def v2_runner_on_skipped(self, result): + self._add('skipped', result) + + def v2_runner_on_unreachable(self, result): + self._add('unreachable', result) + + def v2_playbook_on_stats(self, stats): + out = {'events': self.events, 'stats': {h: stats.summarize(h) for h in stats.processed}} + with open(os.environ['LE_LAB_JSON'], 'w') as f: + json.dump(out, f, default=str) +X +cat >"$T/q.py" <<'X' +import json, re, sys +try: + d = json.load(open(sys.argv[1])) +except Exception: + sys.exit(1) +E, S = d['events'], d['stats'] +def ev(host=None, status=None): + return [e for e in E if host in (None, e['host']) and status in (None, e['status'])] +def text(e): + return json.dumps({k: v for k, v in e['result'].items() if not k.startswith('_')}) +def debug(e): + return e['action'].split('.')[-1] == 'debug' +def has(e, word): + return re.search(r'(?"$T/out.log" 2>&1 +} +js() { python3 -c "exec(open('$T/q.py').read()); sys.exit(0 if ($2) else 1)" "$T/out/$1.json"; } +P=/home/greg/ansible +# cfg_in DIR: the config file ansible --version names, run as greg in DIR. +cfg_in() { timeout 120 runuser -l greg -c "cd $1 && ansible --version" /dev/null | sed -n 's/^ config file = //p'; } +# line N: line N of /root/lab-answers/1, with ~ spelled out. +line() { sed -n "${1}p" /root/lab-answers/1 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s|^~/|/home/greg/|'; } +# changed: the settings greg's ansible.cfg changes, by ansible-config's names. +changed() { ans ansible-config dump --only-changed 2>/dev/null | sed -n 's|^\([A-Z_0-9]*\)(/home/greg/ansible/ansible.cfg) = .*|\1|p'; } +all_named() { + local s n=0 + for s in $(changed); do grep -qw "$s" /root/lab-answers/2 || return 1; n=$((n + 1)); done + [ "$n" -gt 0 ] +} +has_default() { python3 -c 'import sys, yaml; d = yaml.safe_load(open(sys.argv[1])); sys.exit(not (isinstance(d, dict) and "motd_text" in d))' $P/roles/motd/defaults/main.yml; } +W=Hello-from-check-$RANDOM +cat >>"$T/q.py" <<'X' +def motd(h): + return [e for e in ev(h) if e['role'] == 'motd'] +X + +task 1 "The ansible.cfg in use in ~/ansible and in /tmp, in /root/lab-answers/1" +need "line 1 of /root/lab-answers/1 is the file Ansible uses in /home/greg/ansible" '[ -n "$(line 1)" ] && [ "$(line 1)" = "$(cfg_in /home/greg/ansible)" ]' +need "line 2 of /root/lab-answers/1 is the file Ansible uses in /tmp" '[ -n "$(line 2)" ] && [ "$(line 2)" = "$(cfg_in /tmp)" ]' + +task 2 "The settings greg's ansible.cfg changes, in /root/lab-answers/2" +need "/root/lab-answers/2 names every setting ansible-config dump --only-changed shows from greg's file" all_named + +task 3 "A role motd writes /etc/motd from a default variable, on every node" +need "/home/greg/ansible/roles/motd/tasks/main.yml exists" "[ -f $P/roles/motd/tasks/main.yml ]" +need "the role's defaults/main.yml sets motd_text" has_default +need "/home/greg/ansible/motd.yml exists" "[ -f $P/motd.yml ]" +need "ansible-playbook motd.yml --check runs the role on every node, with nothing left to change" 'play motd motd.yml --check --diff; js motd "set(S) >= {\"node1\", \"node2\"} and all(motd(h) for h in S) and all(S[h][\"changed\"] == 0 and S[h][\"failures\"] == 0 for h in S)"' +need "/etc/motd isn't empty on node1 and node2" 'in_node node1 test -s /etc/motd && in_node node2 test -s /etc/motd' +need "with -e motd_text=TEXT, the role would write TEXT to /etc/motd on every node" 'play motdx motd.yml --check --diff -e motd_text=$W; js motdx "all(any(e[\"status\"] == \"changed\" and \"$W\" in text(e) for e in motd(h)) for h in S)"' diff --git a/labs/ex294/1-5-configuration-files-and-roles/lab.conf b/labs/ex294/1-5-configuration-files-and-roles/lab.conf new file mode 100644 index 0000000..6a543e8 --- /dev/null +++ b/labs/ex294/1-5-configuration-files-and-roles/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.5 Configuration files and roles" +CHAPTER=/ex294/1-core-components/1-5-configuration-files-and-roles/ +SYSTEMS="rhel" diff --git a/labs/ex294/1-5-configuration-files-and-roles/reset.sh b/labs/ex294/1-5-configuration-files-and-roles/reset.sh new file mode 100644 index 0000000..1d6ad79 --- /dev/null +++ b/labs/ex294/1-5-configuration-files-and-roles/reset.sh @@ -0,0 +1,5 @@ +# The nodes go with nodes_down, greg (with his project and key) with +# users_remove, and /root/lab-answers with le-lab; ansible-core if setup +# installed it. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/1-5-configuration-files-and-roles/setup.sh b/labs/ex294/1-5-configuration-files-and-roles/setup.sh new file mode 100644 index 0000000..9e5317e --- /dev/null +++ b/labs/ex294/1-5-configuration-files-and-roles/setup.sh @@ -0,0 +1,29 @@ +# The control node: ansible-core and greg's project, with an inventory. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'EOF' +[defaults] +inventory = inventory +remote_user = ansible +host_key_checking = false +EOF +printf '[nodes] +node1 +node2 +' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, with an empty /etc/motd (task 3). +nodes_up 2 +# greg's key goes to ansible on every node, and ansible may use sudo without +# a password. +for n in $(nodes_names); do + in_node -i "$n" 'install -d -m 700 -o ansible -g ansible /home/ansible/.ssh && + install -m 600 -o ansible -g ansible /dev/stdin /home/ansible/.ssh/authorized_keys && + echo "ansible ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/ansible && + chmod 440 /etc/sudoers.d/ansible' /etc/motd'; done diff --git a/labs/ex294/1-6-documentation/README.md b/labs/ex294/1-6-documentation/README.md new file mode 100644 index 0000000..0d4ca1b --- /dev/null +++ b/labs/ex294/1-6-documentation/README.md @@ -0,0 +1,44 @@ +# EX294 1.6: Using the documentation + +Find modules, options, examples and filters with `ansible-doc`, and turn +an example from a module's page into a working task. + +## Before you start + +This VM is the control node. The lab installs `ansible-core` and creates +the user `greg` (password `Lab-Pass-1`), who has an SSH key. Work as greg +(`su - greg` as root), in `/home/greg/ansible`: its `ansible.cfg` reads the +inventory `inventory`, which has `node1` in the group `nodes`, and logs in +as `ansible`. Answers go in `/root/lab-answers/`, which the +lab creates; write them as root. + +The managed node `node1` (10.99.1.11) is an AlmaLinux 10 container with its +own users and packages, reached over SSH by name. The user `ansible` +accepts greg's key and may use sudo without a password. For a root shell +on it, `sudo podman exec -it node1 bash`. + +## Tasks + +1. As greg, install the `ansible.posix` collection from Ansible Galaxy. + Then, using only `ansible-doc`, find the module that manages SELinux + booleans and the option that makes the change persistent. Write the + module's full name and the option's name to `/root/lab-answers/1`. +2. Find an example in `ansible.builtin.lineinfile`'s page that changes a + line matched by a regular expression, and adapt it in + `/home/greg/ansible/sshd.yml` to set `PermitRootLogin no` in node1's + `/etc/ssh/sshd_config`. The task runs, and a second run reports `ok`. +3. List the filters available on the control node and find the one that + hashes passwords. Write its name to `/root/lab-answers/3`. + +## Hints + +- `ansible-galaxy collection install ansible.posix` (it needs the + internet); `ansible-doc -l | grep -i selinux`, then `ansible-doc MODULE`. + For a module that isn't installed, `ansible-doc` only warns. +- In the pager, `/EXAMPLES` jumps to the examples. The task needs + `become: true`, and sshd reads the change only after a reload (a + handler). +- The node's sshd also reads `/etc/ssh/sshd_config.d/01-le-lab.conf`, + which lets root log in for the lab and wins over the main file; leave it. + `sshd -T | grep -i permitroot` shows what sshd really uses. +- `ansible-doc -t filter -l | grep -i password`. diff --git a/labs/ex294/1-6-documentation/check.sh b/labs/ex294/1-6-documentation/check.sh new file mode 100644 index 0000000..c6f23bb --- /dev/null +++ b/labs/ex294/1-6-documentation/check.sh @@ -0,0 +1,91 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" "$T/cb/le_lab_record.py" <<'X' +import json +import os + +from ansible.plugins.callback import CallbackBase + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'aggregate' + CALLBACK_NAME = 'le_lab_record' + CALLBACK_NEEDS_ENABLED = True + + def __init__(self): + super().__init__() + self.events = [] + + def _add(self, status, result): + t = result._task + r = {k: v for k, v in result._result.items() if k != 'ansible_facts'} + self.events.append({'host': result._host.get_name(), 'task': t.get_name(), 'action': t.action, + 'role': t._role.get_name() if t._role else '', 'status': status, 'result': r}) + + def v2_runner_on_ok(self, result): + self._add('changed' if result._result.get('changed') else 'ok', result) + + def v2_runner_on_failed(self, result, ignore_errors=False): + self._add('ignored' if ignore_errors else 'failed', result) + + def v2_runner_on_skipped(self, result): + self._add('skipped', result) + + def v2_runner_on_unreachable(self, result): + self._add('unreachable', result) + + def v2_playbook_on_stats(self, stats): + out = {'events': self.events, 'stats': {h: stats.summarize(h) for h in stats.processed}} + with open(os.environ['LE_LAB_JSON'], 'w') as f: + json.dump(out, f, default=str) +X +cat >"$T/q.py" <<'X' +import json, re, sys +try: + d = json.load(open(sys.argv[1])) +except Exception: + sys.exit(1) +E, S = d['events'], d['stats'] +def ev(host=None, status=None): + return [e for e in E if host in (None, e['host']) and status in (None, e['status'])] +def text(e): + return json.dumps({k: v for k, v in e['result'].items() if not k.startswith('_')}) +def debug(e): + return e['action'].split('.')[-1] == 'debug' +def has(e, word): + return re.search(r'(?"$T/out.log" 2>&1 +} +js() { python3 -c "exec(open('$T/q.py').read()); sys.exit(0 if ($2) else 1)" "$T/out/$1.json"; } +P=/home/greg/ansible +F=/etc/ssh/sshd_config +# The uncommented PermitRootLogin lines of node1's sshd_config. +prl() { in_node node1 grep -Ei '^[[:space:]]*PermitRootLogin[[:space:]]' $F; } +has() { grep -Fqi -- "$2" "/root/lab-answers/$1" 2>/dev/null; } + +task 1 "ansible.posix installed; the module for SELinux booleans and its persistence option in /root/lab-answers/1" +need "the ansible.posix collection is installed: ansible-doc finds ansible.posix.seboolean as greg" "timeout 120 runuser -l greg -c 'ansible-doc -l ansible.posix' /dev/null | grep -q '^ansible.posix.seboolean '" +need "/root/lab-answers/1 names the module, by its full name" 'has 1 ansible.posix.seboolean' +need "/root/lab-answers/1 names the option that makes the change persistent" 'grep -qw persistent /root/lab-answers/1' + +task 2 "sshd.yml sets PermitRootLogin no with lineinfile, and a second run is ok" +need "node1's $F has the line PermitRootLogin no" 'prl | grep -Eqi "^[[:space:]]*PermitRootLogin[[:space:]]+no[[:space:]]*$"' +need "node1's $F has no other PermitRootLogin line" '[ "$(prl | wc -l)" = 1 ]' +need "sshd accepts node1's configuration" 'in_node node1 sshd -t' +need "/home/greg/ansible/sshd.yml exists" "[ -f $P/sshd.yml ]" +need "ansible-playbook sshd.yml runs a lineinfile task on node1 without errors" 'play sshd sshd.yml; js sshd "any(e[\"action\"].split(\".\")[-1] == \"lineinfile\" for e in ev(\"node1\")) and S[\"node1\"][\"failures\"] == 0"' +need "running sshd.yml again changes nothing (ok)" 'js sshd "S[\"node1\"][\"changed\"] == 0"' + +task 3 "The filter that hashes passwords, in /root/lab-answers/3" +need "/root/lab-answers/3 names the filter" 'has 3 password_hash' diff --git a/labs/ex294/1-6-documentation/lab.conf b/labs/ex294/1-6-documentation/lab.conf new file mode 100644 index 0000000..dd0243a --- /dev/null +++ b/labs/ex294/1-6-documentation/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.6 Using the documentation" +CHAPTER=/ex294/1-core-components/1-6-documentation/ +SYSTEMS="rhel" diff --git a/labs/ex294/1-6-documentation/reset.sh b/labs/ex294/1-6-documentation/reset.sh new file mode 100644 index 0000000..4fb06cd --- /dev/null +++ b/labs/ex294/1-6-documentation/reset.sh @@ -0,0 +1,9 @@ +# The nodes go with nodes_down, greg (with his project and key) with +# users_remove, and /root/lab-answers with le-lab; ansible-core if setup +# installed it. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +# The collection task 1 installs: greg's copy (it goes with greg too) and a +# system-wide one setup didn't find. +rm -rf /home/greg/.ansible/collections +while read -r d; do rm -rf "$d"; done <"$LAB_STATE/new-dirs" 2>/dev/null +true diff --git a/labs/ex294/1-6-documentation/setup.sh b/labs/ex294/1-6-documentation/setup.sh new file mode 100644 index 0000000..dd7dc23 --- /dev/null +++ b/labs/ex294/1-6-documentation/setup.sh @@ -0,0 +1,32 @@ +# The control node: ansible-core and greg's project, with an inventory but +# not the ansible.posix collection (task 1). +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'EOF' +[defaults] +inventory = inventory +remote_user = ansible +host_key_checking = false +EOF +printf '[nodes] +node1 +' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory +# Task 1 installs ansible.posix; note whether there was a system-wide copy, +# so reset can take away one the student adds there. +[ -d /usr/share/ansible/collections/ansible_collections/ansible/posix ] || + echo /usr/share/ansible/collections/ansible_collections/ansible/posix >>"$LAB_STATE/new-dirs" + +# The managed node; its sshd_config leaves PermitRootLogin commented out (task 2). +nodes_up 1 +# greg's key goes to ansible on every node, and ansible may use sudo without +# a password. +for n in $(nodes_names); do + in_node -i "$n" 'install -d -m 700 -o ansible -g ansible /home/ansible/.ssh && + install -m 600 -o ansible -g ansible /dev/stdin /home/ansible/.ssh/authorized_keys && + echo "ansible ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/ansible && + chmod 440 /etc/sudoers.d/ansible' + BIOS= + DISK_SIZE_VDB= + DISK_SIZE_VDD= + + A missing disk shows as `NONE`. +3. Write `/home/greg/ansible/sshd.yml`, which templates the sshd drop-in + `/etc/ssh/sshd_config.d/50-lab.conf` on all nodes with the line + `PermitRootLogin {{ sshd_root_login }}` (the variable is `no` by + default) and checks it with `validate` before installing it. Then try + `-e sshd_root_login=maybe`: the task fails with "failed to validate", + and the old file is unchanged. + +## Hints + +- `hostvars[host]['ansible_facts']` has another host's facts, but only + if the play gathered them: target `all` and limit the task with `when`. +- `ansible_facts['default_ipv4']['address']`, `['fqdn']`, `['hostname']`. +- `ansible_facts['memtotal_mb']`, `['bios_version']`, and + `ansible_facts['devices']['vdb']['size'] | default('NONE')`. +- `validate: /usr/sbin/sshd -t -f %s`. diff --git a/labs/ex294/10-1-templates/check.sh b/labs/ex294/10-1-templates/check.sh new file mode 100644 index 0000000..ba300a6 --- /dev/null +++ b/labs/ex294/10-1-templates/check.sh @@ -0,0 +1,42 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" /dev/null | sed -n 's/^ *"ansible_bios_version": "\(.*\)",\{0,1\}$/\1/p') +# line NODE FILE TEXT: FILE on NODE has the line TEXT (spaces at the end aside). +line() { in_node "$1" cat "$2" | sed 's/[[:space:]]*$//' | grep -qxF "$3"; } + +task 1 "/etc/myhosts on the database servers: every node's address, FQDN and name" +need "node3 has /etc/myhosts" 'in_node node3 test -f /etc/myhosts' +for n in node1 node2 node3; do + need "node3's /etc/myhosts has \"$(node_addr $n) $n.lab.example $n\"" "in_node node3 cat /etc/myhosts | awk -v a=$(node_addr $n) -v n=$n '\$1 == a && \$2 == n \".lab.example\" && \$3 == n && NF == 3 { ok = 1 } END { exit !ok }'" +done +need "it has one line per node" '[ "$(in_node node3 grep -c "^10\.99\.1\." /etc/myhosts)" = 3 ]' +need "node1 and node2, the web servers, have no /etc/myhosts" '! in_node node1 test -e /etc/myhosts && ! in_node node2 test -e /etc/myhosts' + +task 2 "/root/hwreport.txt on every node, with NONE for a missing disk" +need "Ansible reports the BIOS version" '[ -n "$BIOS" ]' +for n in node1 node2 node3; do + need "$n has /root/hwreport.txt" "in_node $n test -f /root/hwreport.txt" + need "$n's report says HOST=$n" "line $n /root/hwreport.txt HOST=$n" + need "$n's report says MEMORY=${MEM[$n]}" "line $n /root/hwreport.txt MEMORY=${MEM[$n]}" + need "$n's report says BIOS=$BIOS" 'line '$n' /root/hwreport.txt "BIOS=$BIOS"' + need "$n's report says DISK_SIZE_VDB=${VDB[$n]}" "line $n /root/hwreport.txt 'DISK_SIZE_VDB=${VDB[$n]}'" + need "$n's report says DISK_SIZE_VDD=${VDD[$n]}" "line $n /root/hwreport.txt 'DISK_SIZE_VDD=${VDD[$n]}'" +done + +task 3 "sshd.yml: a validated sshd drop-in that keeps the old file on a bad value" +need "/home/greg/ansible/sshd.yml exists" '[ -f /home/greg/ansible/sshd.yml ]' +need "ansible-playbook sshd.yml runs without failures" 'ans ansible-playbook sshd.yml >"$OUT"; grep -Eq "^node[0-9]+ +: ok=" "$OUT" && ! grep -E " : ok=" "$OUT" | grep -Eq "(unreachable|failed)=[1-9]"' +for n in node1 node2 node3; do + need "$n's $D says PermitRootLogin no" "line $n $D 'PermitRootLogin no'" +done +need "with -e sshd_root_login=maybe, the task fails with \"failed to validate\"" 'in_node node1 md5sum $D >"$OUT.sum"; ans ansible-playbook sshd.yml -e sshd_root_login=maybe >"$OUT"; grep -q "failed to validate" "$OUT" && grep -Eq "^node1 +: .*failed=1" "$OUT"' +need "and node1's $D is unchanged" '[ "$(in_node node1 md5sum $D)" = "$(cat "$OUT.sum")" ]' +rm -f "$OUT" "$OUT.sum" diff --git a/labs/ex294/10-1-templates/lab.conf b/labs/ex294/10-1-templates/lab.conf new file mode 100644 index 0000000..818836f --- /dev/null +++ b/labs/ex294/10-1-templates/lab.conf @@ -0,0 +1,3 @@ +TITLE="10.1 Templates" +CHAPTER=/ex294/10-managing-content/10-1-templates/ +SYSTEMS="rhel" diff --git a/labs/ex294/10-1-templates/reset.sh b/labs/ex294/10-1-templates/reset.sh new file mode 100644 index 0000000..79f2f4b --- /dev/null +++ b/labs/ex294/10-1-templates/reset.sh @@ -0,0 +1,9 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove; here the hardware unit and what setup installed. +if [ -f /etc/systemd/system/le-lab-node-hw.service ]; then + systemctl disable --quiet --now le-lab-node-hw.service 2>/dev/null + rm -f /etc/systemd/system/le-lab-node-hw.service + systemctl daemon-reload +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/10-1-templates/setup.sh b/labs/ex294/10-1-templates/setup.sh new file mode 100644 index 0000000..371c528 --- /dev/null +++ b/labs/ex294/10-1-templates/setup.sh @@ -0,0 +1,80 @@ +# The control node: ansible-core, and greg with an SSH key and his project. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nnode1\nnode2\n\n[dbservers]\nnode3\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, each with devops: greg's key and passwordless sudo. +nodes_up 3 +for n in $(nodes_names); do + in_node $n 'useradd -m devops && echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i $n 'install -m 600 -o devops -g devops /dev/stdin /home/devops/.ssh/authorized_keys' "$LAB_STATE/hardware" <<'X' +node1 4096 vda:20 vdb:2 vdd:1 +node2 2048 vda:20 vdb:2 +node3 1024 vda:20 +X +cat >"$LAB_STATE/hardware.sh" <<'X' +#!/bin/bash +cd "$(dirname "$0")" || exit 1 +while read -r name mem disks; do + pid=$(podman inspect -f '{{.State.Pid}}' "$name" 2>/dev/null) + [ "${pid:-0}" -gt 0 ] || continue + for m in /proc/meminfo /sys/block; do + nsenter -t "$pid" -m findmnt -n --mountpoint "$m" >/dev/null && nsenter -t "$pid" -m umount "$m" + done + podman exec -i "$name" bash -s -- "$mem" $disks <<'Y' +d=/var/lib/le-lab-hw mem=$1; shift +rm -rf $d; mkdir -p $d/block +awk -v kb=$((mem * 1024)) ' + /^MemTotal:/ { printf "MemTotal: %8d kB\n", kb; next } + /^(MemFree|MemAvailable):/ { printf "%-16s%8d kB\n", $1, kb / 2; next } + { print }' /proc/meminfo >$d/meminfo +for x; do + b=$d/block/${x%%:*} + mkdir -p $b/device $b/queue + echo $((${x#*:} * 2097152)) >$b/size + echo 0 >$b/removable; echo 1 >$b/queue/rotational + echo 512 >$b/queue/logical_block_size; echo 512 >$b/queue/physical_block_size + echo "Virtual disk" >$b/device/model +done +Y + nsenter -t "$pid" -m mount --bind /var/lib/le-lab-hw/meminfo /proc/meminfo + nsenter -t "$pid" -m mount --bind /var/lib/le-lab-hw/block /sys/block +done /etc/systemd/system/le-lab-node-hw.service </dev/null | python3 -c 'import sys, yaml +d = yaml.safe_load(sys.stdin) +print(d.get("user_password", "") if isinstance(d, dict) else "")' 2>/dev/null) +# pw_ok: vaultuser's password on node1 is $PW. +pw_ok() { + local h; h=$(in_node node1 getent shadow vaultuser | cut -d: -f2) + [ -n "$PW" ] && [ -n "$h" ] && PW=$PW H=$h python3 -W ignore -c 'import crypt, os, sys +sys.exit(crypt.crypt(os.environ["PW"], os.environ["H"]) != os.environ["H"])' +} +# A terminal for --ask-vault-pass: askpass FILE CMD... answers the prompt +# once with the password in FILE and exits with CMD's status (99 if no +# prompt came). +cat >"$LAB_STATE/askpass.py" <<'P' +import os, pty, select, sys, time +pw, cmd = open(sys.argv[1]).read().strip(), sys.argv[2:] +pid, fd = pty.fork() +if pid == 0: + os.execvp(cmd[0], cmd) +buf, sent, end = b"", False, time.time() + 60 +while time.time() < end: + if not select.select([fd], [], [], 1)[0]: + continue + try: + d = os.read(fd, 4096) + except OSError: + break + if not d: + break + buf += d + if not sent and b"assword:" in buf: + os.write(fd, pw.encode() + b"\n") + sent = True +sys.stdout.write(buf.decode(errors="replace")) +if time.time() >= end: + os.kill(pid, 9) +_, st = os.waitpid(pid, 0) +sys.exit(os.waitstatus_to_exitcode(st) if sent else 99) +P +chmod 644 "$LAB_STATE/askpass.py" +navcfg() { + python3 -c 'import sys, yaml +n = (yaml.safe_load(open(sys.argv[1])) or {}).get("ansible-navigator", {}) +ok = n.get("mode") == "stdout" and (n.get("playbook-artifact") or {}).get("enable") in (False, "false") +sys.exit(0 if ok else 1)' $A/ansible-navigator.yml +} + +task 1 "An encrypted secret.yml, and vaultuser on node1 with its password" +need "$A/vault-pass.txt exists with mode 600" "[ \"\$(stat -c %a $A/vault-pass.txt)\" = 600 ]" +need "$A/secret.yml is encrypted (starts with \$ANSIBLE_VAULT)" "head -1 $A/secret.yml | grep -q '^\\\$ANSIBLE_VAULT'" +need "vault-pass.txt opens secret.yml, which sets user_password" '[ -n "$PW" ]' +need "$A/vault.yml exists and uses secret.yml" "grep -q secret.yml $A/vault.yml" +need "vaultuser exists on node1" 'in_node node1 id vaultuser' +need "vaultuser's password on node1 is user_password from the vault" pw_ok + +task 2 "A new vault password, in vault-pass.txt" +need "vault-pass.txt opens secret.yml" '[ -n "$PW" ]' +need "vault-pass.txt doesn't hold RedhatVault any more" "! grep -qx RedhatVault $A/vault-pass.txt" +need "RedhatVault no longer opens secret.yml" '! view RedhatVault' + +task 3 "vault.yml runs in ansible-navigator, with the password file and the prompt" +need "ansible-navigator.yml sets mode: stdout and playbook artifacts off" navcfg +need "ansible-navigator run vault.yml --vault-password-file vault-pass.txt works" 'ans ansible-navigator run vault.yml --vault-password-file vault-pass.txt >"$OUT" && grep -q "PLAY RECAP" "$OUT"' +need "ansible-navigator run vault.yml --ask-vault-pass asks and works" "[ -n \"\$PW\" ] && ans python3 -I $LAB_STATE/askpass.py vault-pass.txt ansible-navigator run vault.yml --ask-vault-pass >\"\$OUT\" && grep -q 'PLAY RECAP' \"\$OUT\"" +rm -f "$OUT" diff --git a/labs/ex294/10-2-ansible-vault/lab.conf b/labs/ex294/10-2-ansible-vault/lab.conf new file mode 100644 index 0000000..215dd00 --- /dev/null +++ b/labs/ex294/10-2-ansible-vault/lab.conf @@ -0,0 +1,3 @@ +TITLE="10.2 Ansible Vault" +CHAPTER=/ex294/10-managing-content/10-2-ansible-vault/ +SYSTEMS="rhel" diff --git a/labs/ex294/10-2-ansible-vault/reset.sh b/labs/ex294/10-2-ansible-vault/reset.sh new file mode 100644 index 0000000..6620eb7 --- /dev/null +++ b/labs/ex294/10-2-ansible-vault/reset.sh @@ -0,0 +1,8 @@ +# The node goes with nodes_down and greg (with his project) with +# users_remove; ansible-navigator and the packages the lab installed here. +if [ "$(state_get navigator)" = /opt/le-lab-navigator ]; then + rm -f /usr/local/bin/ansible-navigator + rm -rf /opt/le-lab-navigator +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/10-2-ansible-vault/setup.sh b/labs/ex294/10-2-ansible-vault/setup.sh new file mode 100644 index 0000000..64a1b4e --- /dev/null +++ b/labs/ex294/10-2-ansible-vault/setup.sh @@ -0,0 +1,45 @@ +# The control node: ansible-core, ansible-navigator (in its own Python +# environment) and greg's project, with his key. +for p in ansible-core; do pkg_installed $p || echo $p >>"$LAB_STATE/missing"; done +pkg_installed ansible-core || pkg_install ansible-core +if ! command -v ansible-navigator >/dev/null; then + say "${C_DIM}Installing ansible-navigator...${C_0}" + state_set navigator /opt/le-lab-navigator + python3 -m venv /opt/le-lab-navigator + /opt/le-lab-navigator/bin/pip install -q --disable-pip-version-check ansible-navigator >/dev/null || + die "could not install ansible-navigator from PyPI" + ln -sf /opt/le-lab-navigator/bin/ansible-navigator /usr/local/bin/ansible-navigator +fi +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'C' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +C +printf '[nodes]\nnode1\n' >/home/greg/ansible/inventory +cat >/home/greg/ansible/ansible-navigator.yml <<'C' +--- +ansible-navigator: + execution-environment: + enabled: false +C +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory /home/greg/ansible/ansible-navigator.yml + +# The managed node: devops with greg's key and sudo, and no vaultuser. +nodes_up 1 +in_node -i node1 'id devops >/dev/null 2>&1 || useradd -m devops + echo devops:Lab-Pass-1 | chpasswd + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops + chmod 440 /etc/sudoers.d/devops + install -d -m 700 -o devops -g devops /home/devops/.ssh + cat >/home/devops/.ssh/authorized_keys + chown devops: /home/devops/.ssh/authorized_keys + chmod 600 /home/devops/.ssh/authorized_keys + userdel -r vaultuser 2>/dev/null; true' /dev/null) +OUT=$LAB_STATE/check.out Q="'" + +task 1 "ansible.cfg: inventory, devops, roles, collections, sudo without a password" +need "$C exists" '[ -f "$C" ]' +need "ansible --version in $P names $C" 'ans ansible ansible --version | grep -qx " config file = $C"' +need "the dump shows inventory $P/inventory from $C" 'grep -Fqx "DEFAULT_HOST_LIST($C) = [$Q$P/inventory$Q]" <<<"$D"' +need "the dump shows remote_user devops from $C" 'grep -Fqx "DEFAULT_REMOTE_USER($C) = devops" <<<"$D"' +need "the dump shows roles_path with $P/roles from $C" 'grep -F "DEFAULT_ROLES_PATH($C) = " <<<"$D" | grep -Fq "$Q$P/roles$Q"' +need "the dump shows collections_path with $P/collections from $C" 'grep -F "COLLECTIONS_PATHS($C) = " <<<"$D" | grep -Fq "$Q$P/collections$Q"' +need "the dump shows become True from $C" 'grep -Fqx "DEFAULT_BECOME($C) = True" <<<"$D"' +need "the become method is sudo" 'ans ansible ansible-config dump | grep -Eq "^DEFAULT_BECOME_METHOD\(.*\) = sudo$"' +need "the become user is root" 'ans ansible ansible-config dump | grep -Eq "^DEFAULT_BECOME_USER\(.*\) = root$"' +need "Ansible doesn't ask for the sudo password" '! grep -Eq "^DEFAULT_BECOME_ASK_PASS\(.*\) = True$" <<<"$D"' +need "ansible all -a whoami prints root for node1 and node2" 'ans ansible ansible all -o -a whoami >"$OUT"; for n in node1 node2; do grep -Eq "^$n .*\(stdout\) root$" "$OUT" || exit 1; done' + +task 2 "forks = 10" +need "ansible-config dump in $P shows DEFAULT_FORKS = 10 from $C" 'ans ansible ansible-config dump | grep -Fqx "DEFAULT_FORKS($C) = 10"' + +task 3 "Every setting in proj2's ansible.cfg takes effect" +C2=/home/greg/proj2/ansible.cfg +D2=$(ans proj2 ansible-config dump --only-changed 2>/dev/null) +need "the dump in proj2 shows remote_user devops from $C2" 'grep -Fqx "DEFAULT_REMOTE_USER($C2) = devops" <<<"$D2"' +need "the dump in proj2 still shows inventory, host_key_checking and forks 20 from $C2" 'grep -Fq "DEFAULT_HOST_LIST($C2) = " <<<"$D2" && grep -Fqx "HOST_KEY_CHECKING($C2) = False" <<<"$D2" && grep -Fqx "DEFAULT_FORKS($C2) = 20" <<<"$D2"' +need "$C2 has no misspelled remote_usr line left" '! grep -Eq "^[[:space:]]*remote_usr[[:space:]]*=" "$C2"' +rm -f "$OUT" diff --git a/labs/ex294/2-1-ansible-cfg/lab.conf b/labs/ex294/2-1-ansible-cfg/lab.conf new file mode 100644 index 0000000..49dd968 --- /dev/null +++ b/labs/ex294/2-1-ansible-cfg/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.1 ansible.cfg" +CHAPTER=/ex294/2-configuring-ansible/2-1-ansible-cfg/ +SYSTEMS="rhel" diff --git a/labs/ex294/2-1-ansible-cfg/reset.sh b/labs/ex294/2-1-ansible-cfg/reset.sh new file mode 100644 index 0000000..72e1dd8 --- /dev/null +++ b/labs/ex294/2-1-ansible-cfg/reset.sh @@ -0,0 +1,4 @@ +# The nodes go with nodes_down and greg (with both projects) with +# users_remove; ansible-core goes if the lab installed it. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/2-1-ansible-cfg/setup.sh b/labs/ex294/2-1-ansible-cfg/setup.sh new file mode 100644 index 0000000..e055ae6 --- /dev/null +++ b/labs/ex294/2-1-ansible-cfg/setup.sh @@ -0,0 +1,25 @@ +# The control node: ansible-core, greg with a key, his project without an +# ansible.cfg (tasks 1 and 2), and proj2 with a misspelled setting (task 3). +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +for d in ansible proj2; do + install -d -m 755 -o greg -g greg /home/greg/$d + printf '[nodes]\nnode1\nnode2\n' >/home/greg/$d/inventory +done +cat >/home/greg/proj2/ansible.cfg <<'X' +[defaults] +inventory = ./inventory +remote_usr = devops +host_key_checking = false +forks = 20 +X +chown -R greg: /home/greg/ansible /home/greg/proj2 + +# The managed nodes, with devops: greg's key and passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node "$n" 'useradd -m devops && printf "devops ALL=(ALL) NOPASSWD: ALL\n" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i "$n" 'cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && chmod 600 /home/devops/.ssh/authorized_keys' "$OUT.eff" 2>/dev/null +ans ansible-navigator settings --effective --sources -m stdout >"$OUT.src" 2>/dev/null +src() { grep -Fqx "ansible-navigator.$1: Settings file" "$OUT.src"; } + +task 1 "ansible-navigator.yml: lab-ee:2.16, never pull, stdout, no artifacts" +need "/home/greg/ansible/ansible-navigator.yml exists" 'ls /home/greg/ansible/ansible-navigator.y*ml /home/greg/ansible/ansible-navigator.json 2>/dev/null | grep -q .' +need "the image is localhost/lab-ee:2.16, from the settings file" '[ "$(eff execution-environment image)" = localhost/lab-ee:2.16 ] && src execution-environment.image' +need "the pull policy is never, from the settings file" '[ "$(eff execution-environment pull policy)" = never ] && src execution-environment.pull.policy' +need "the mode is stdout, from the settings file" '[ "$(eff mode)" = stdout ] && grep -Fqx "ansible-navigator.mode: Settings file" <(ans ansible-navigator settings --effective --sources)' +need "playbook artifacts are off, from the settings file" '[ "$(eff playbook-artifact enable)" = False ] && src playbook-artifact.enable' +need "ansible-navigator run site.yml works on node1 and node2" 'ans ansible-navigator run site.yml >"$OUT"; grep -Eq "^node1 +: ok=[1-9].* failed=0" "$OUT" && grep -Eq "^node2 +: ok=[1-9].* failed=0" "$OUT"' + +task 2 "The error a pull policy of always gives for a local-only image" +need "/root/lab-answers/2 has navigator's error" 'grep -qi "execution environment pull failed" /root/lab-answers/2' + +task 3 "The module that fails without the execution environment" +need "/root/lab-answers/3 names the module, in full" '[ "$(answer 3)" = community.general.ini_file ]' +rm -f "$OUT" "$OUT.eff" "$OUT.src" diff --git a/labs/ex294/2-2-ansible-navigator-yml/lab.conf b/labs/ex294/2-2-ansible-navigator-yml/lab.conf new file mode 100644 index 0000000..e99d9f8 --- /dev/null +++ b/labs/ex294/2-2-ansible-navigator-yml/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.2 ansible-navigator.yml" +CHAPTER=/ex294/2-configuring-ansible/2-2-ansible-navigator-yml/ +SYSTEMS="rhel" diff --git a/labs/ex294/2-2-ansible-navigator-yml/reset.sh b/labs/ex294/2-2-ansible-navigator-yml/reset.sh new file mode 100644 index 0000000..1199f8d --- /dev/null +++ b/labs/ex294/2-2-ansible-navigator-yml/reset.sh @@ -0,0 +1,11 @@ +# Navigator, if the lab installed it. greg (with his project and his +# podman images) goes with users_remove, the nodes with nodes_down. +if [ "$(state_get navigator)" = installed ]; then + rm -f /usr/local/bin/ansible-navigator + rm -rf /opt/le-lab-navigator +fi +if id greg >/dev/null 2>&1; then + runuser -l greg -c 'podman system reset -f' >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/2-2-ansible-navigator-yml/setup.sh b/labs/ex294/2-2-ansible-navigator-yml/setup.sh new file mode 100644 index 0000000..f67097f --- /dev/null +++ b/labs/ex294/2-2-ansible-navigator-yml/setup.sh @@ -0,0 +1,89 @@ +# The control node: ansible-core, python3-pip (for ansible-navigator's +# virtual environment) and greg with a key and quiet rootless podman. +for p in ansible-core python3-pip; do + pkg_installed $p || { echo $p >>"$LAB_STATE/missing"; pkg_install $p; } +done +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +# Without a systemd user session (su, sudo -i), podman would warn on every +# run; the cgroupfs manager works the same in any login. +install -d -m 755 -o greg -g greg /home/greg/.config /home/greg/.config/containers +printf '[engine]\ncgroup_manager = "cgroupfs"\nevents_logger = "file"\n' >/home/greg/.config/containers/containers.conf +chown greg: /home/greg/.config/containers/containers.conf + +# greg's project: ansible.cfg, inventory and a playbook that needs +# community.general, which only the image has (task 3). No +# ansible-navigator.yml yet (task 1). +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = ./inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[nodes]\nnode1\nnode2\n' >/home/greg/ansible/inventory +cat >/home/greg/ansible/site.yml <<'X' +--- +- name: Configure the managed nodes + hosts: all + tasks: + - name: Make sure the node answers + ansible.builtin.ping: + + - name: Record that Ansible manages the node + community.general.ini_file: + path: /etc/lab.ini + section: lab + option: managed_by + value: ansible + mode: "0644" +X +chown greg: /home/greg/ansible/* + +# The managed nodes, with devops: greg's key and passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node "$n" 'useradd -m devops && printf "devops ALL=(ALL) NOPASSWD: ALL\n" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i "$n" 'cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && chmod 600 /home/devops/.ssh/authorized_keys' /dev/null && break + sleep 5 + done + ln -s /opt/le-lab-navigator/bin/ansible-navigator /usr/local/bin/ansible-navigator + /usr/local/bin/ansible-navigator --version >/dev/null + state_set navigator installed +fi + +# The execution environment localhost/lab-ee:2.16: the node image with +# ansible-core and ansible.posix and community.general from Ansible Galaxy. +# Built as root, given to greg's rootless podman, and removed from root's. +say "${C_DIM}Building the execution environment image...${C_0}" +d=$(mktemp -d) +cat >"$d/Containerfile" <=2,<3' 'community.general:>=11,<12' && break; sleep 5; \\ + done \\ + && ansible-doc community.general.lvg >/dev/null && ansible-doc ansible.posix.seboolean >/dev/null \\ + && dnf clean all +X +podman build -q --layers=false -t localhost/lab-ee:2.16 "$d" >/dev/null +rm -rf "$d" +podman save localhost/lab-ee:2.16 | runuser -l greg -c 'podman load -q' >/dev/null 2>&1 +podman rmi localhost/lab-ee:2.16 >/dev/null +runuser -l greg -c 'podman image exists localhost/lab-ee:2.16' diff --git a/labs/ex294/2-3-static-inventories/README.md b/labs/ex294/2-3-static-inventories/README.md new file mode 100644 index 0000000..b9af54f --- /dev/null +++ b/labs/ex294/2-3-static-inventories/README.md @@ -0,0 +1,40 @@ +# EX294 2.3: Static inventories and groups + +Write an inventory in INI and in YAML, with a group of groups and a group +variable, and add the control node so its tasks run locally. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core`. Work as greg, +in `/home/greg/ansible`: its `ansible.cfg` reads the inventory +`./inventory`, which doesn't exist yet, and logs in as `devops`. + +The managed nodes `node1` (10.99.1.11), `node2` (10.99.1.12) and `node3` +(10.99.1.13) are AlmaLinux 10 containers reached over SSH by name. On +each, `devops` accepts greg's key. For a root shell on a node, +`sudo podman exec -it node1 bash`. + +## Tasks + +1. Write `/home/greg/ansible/inventory` in INI format, with node1 in group + `dev`, node2 in `test`, node3 in `prod`, and a group `webservers` that + contains the group `prod`. `ansible-inventory --graph` shows node3 under + both `prod` and `webservers`, and `ansible webservers --list-hosts` + lists node3. +2. Write the same inventory in YAML as `/home/greg/ansible/inventory.yml`, + with a variable set on the `dev` group. + `ansible-inventory -i inventory.yml --graph --vars` shows the same tree, + with the variable on node1. +3. Add the control node to `/home/greg/ansible/inventory` as `control`, so + tasks for it run locally: `ansible control -a hostname` prints this + VM's name without SSH. + +## Hints + +- `[group:children]` lists groups; `[group:vars]` sets variables. +- In YAML, `hosts:` and `children:` map names to their contents, even when + empty: `node1:` with a trailing colon. Group variables go under `vars:`. +- `ansible-inventory -y --list` prints an INI inventory as YAML. +- `ansible_connection=local` on the host's line. +- `ansible all -m ping` checks every node answers. diff --git a/labs/ex294/2-3-static-inventories/check.sh b/labs/ex294/2-3-static-inventories/check.sh new file mode 100644 index 0000000..3eda4be --- /dev/null +++ b/labs/ex294/2-3-static-inventories/check.sh @@ -0,0 +1,36 @@ +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 120 runuser -l greg -c "cd ~/ansible && $*" "$OUT" && grep -q . "$OUT"' +need "node1 is in dev, node2 in test, node3 in prod, and prod in webservers" 'tree <"$OUT"' +need "ansible webservers --list-hosts lists node3" 'ans ansible webservers --list-hosts | grep -qx " *node3"' + +task 2 "The same inventory in YAML, with a variable on dev" +need "$Y exists" '[ -f "$Y" ]' +need "$Y is a YAML inventory" 'inv inventory.yml yaml >"$OUT" && grep -q . "$OUT"' +need "inventory.yml has node1 in dev, node2 in test, node3 in prod, and prod in webservers" 'tree <"$OUT"' +need "inventory.yml sets a variable on the dev group" 'python3 -c "import json, sys; sys.exit(not json.load(open(sys.argv[1])).get(\"dev\", {}).get(\"vars\"))" "$OUT"' +need "node1 gets dev's variables" 'ans ansible-inventory -i inventory.yml --host node1 >"$OUT.host" && python3 -c "import json, sys +v = json.load(open(sys.argv[1]))[\"dev\"][\"vars\"]; h = json.load(open(sys.argv[2])) +sys.exit(not all(h.get(k) == x for k, x in v.items()))" "$OUT" "$OUT.host"' + +task 3 "control in the inventory, run locally" +need "the inventory has the host control" 'ans ansible-inventory --host control' +need "control has ansible_connection local" 'ans ansible-inventory --host control | python3 -c "import json, sys; sys.exit(json.load(sys.stdin).get(\"ansible_connection\") != \"local\")"' +need "ansible control -a hostname prints $(hostname)" 'ans ansible control -o -a hostname | grep -Fq "(stdout) $(hostname)"' +rm -f "$OUT" "$OUT.host" diff --git a/labs/ex294/2-3-static-inventories/lab.conf b/labs/ex294/2-3-static-inventories/lab.conf new file mode 100644 index 0000000..b02b8a8 --- /dev/null +++ b/labs/ex294/2-3-static-inventories/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.3 Static inventories and groups" +CHAPTER=/ex294/2-configuring-ansible/2-3-static-inventories/ +SYSTEMS="rhel" diff --git a/labs/ex294/2-3-static-inventories/reset.sh b/labs/ex294/2-3-static-inventories/reset.sh new file mode 100644 index 0000000..b8a2526 --- /dev/null +++ b/labs/ex294/2-3-static-inventories/reset.sh @@ -0,0 +1,4 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove; ansible-core goes if the lab installed it. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/2-3-static-inventories/setup.sh b/labs/ex294/2-3-static-inventories/setup.sh new file mode 100644 index 0000000..ee1cb6a --- /dev/null +++ b/labs/ex294/2-3-static-inventories/setup.sh @@ -0,0 +1,21 @@ +# The control node: ansible-core, greg with a key, and his project with an +# ansible.cfg but no inventory (tasks 1 to 3). +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = ./inventory +remote_user = devops +host_key_checking = false +X +chown greg: /home/greg/ansible/ansible.cfg + +# The managed nodes, with devops: greg's key and passwordless sudo. +nodes_up 3 +for n in $(nodes_names); do + in_node "$n" 'useradd -m devops && printf "devops ALL=(ALL) NOPASSWD: ALL\n" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i "$n" 'cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && chmod 600 /home/devops/.ssh/authorized_keys' > ~/.ssh/known_hosts`; the names in + `known_hosts` must be the ones in the inventory. diff --git a/labs/ex294/3-2-ssh-keys/check.sh b/labs/ex294/3-2-ssh-keys/check.sh new file mode 100644 index 0000000..3ba9204 --- /dev/null +++ b/labs/ex294/3-2-ssh-keys/check.sh @@ -0,0 +1,34 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { setsid -w timeout 180 runuser -l greg -c "cd ~/ansible && $*" "$OUT"; recap0' + +task 3 "Host key checking on, with the nodes' keys in known_hosts" +need "ansible.cfg doesn't turn host key checking off" 'ans ansible-config dump | grep -E "^HOST_KEY_CHECKING" | grep -q "= True"' +for n in $(nodes_names); do + need "greg's ~/.ssh/known_hosts has a host key for $n" "runuser -u greg -- ssh-keygen -F $n -f /home/greg/.ssh/known_hosts" +done +need "ansible all -m ping works with no prompt (as greg, in ~/ansible)" 'ans ansible all -m ping' +rm -f "$OUT" diff --git a/labs/ex294/3-2-ssh-keys/lab.conf b/labs/ex294/3-2-ssh-keys/lab.conf new file mode 100644 index 0000000..151c0de --- /dev/null +++ b/labs/ex294/3-2-ssh-keys/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.2 SSH keys for managed nodes" +CHAPTER=/ex294/3-managed-nodes/3-2-ssh-keys/ +SYSTEMS="rhel" diff --git a/labs/ex294/3-2-ssh-keys/reset.sh b/labs/ex294/3-2-ssh-keys/reset.sh new file mode 100644 index 0000000..bd11003 --- /dev/null +++ b/labs/ex294/3-2-ssh-keys/reset.sh @@ -0,0 +1,4 @@ +# The nodes go with nodes_down and greg (with his key and project) with +# users_remove; the packages the lab installed here. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/3-2-ssh-keys/setup.sh b/labs/ex294/3-2-ssh-keys/setup.sh new file mode 100644 index 0000000..ee949f4 --- /dev/null +++ b/labs/ex294/3-2-ssh-keys/setup.sh @@ -0,0 +1,28 @@ +# The control node: ansible-core, sshpass (for -k) and greg's project, but +# no key for greg (task 1). +for p in ansible-core sshpass; do pkg_installed $p || echo $p >>"$LAB_STATE/missing"; done +pkg_installed ansible-core || pkg_install ansible-core +pkg_installed sshpass || pkg_install sshpass +user_add greg +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'C' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +C +printf '[nodes]\nnode1\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, with devops (password and sudo, but no keys). +nodes_up 2 +for n in $(nodes_names); do + in_node "$n" 'id devops >/dev/null 2>&1 || useradd -m devops + echo devops:Lab-Pass-1 | chpasswd + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops + chmod 440 /etc/sudoers.d/devops + rm -rf /home/devops/.ssh' +done diff --git a/labs/ex294/3-3-privilege-escalation/README.md b/labs/ex294/3-3-privilege-escalation/README.md new file mode 100644 index 0000000..77d54e4 --- /dev/null +++ b/labs/ex294/3-3-privilege-escalation/README.md @@ -0,0 +1,42 @@ +# EX294 3.3: Privilege escalation + +Give the automation user passwordless sudo with a validated sudoers +drop-in, see `validate` stop a broken one, and run one task as another +user in a play that becomes root. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core` and `sshpass`. +Work as greg, in `/home/greg/ansible`: its `ansible.cfg` reads the +inventory `inventory`, logs in as `devops` and has `become = true`. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers reached over SSH by name. On each, `devops` +accepts greg's key but has no sudo rights yet, so `ansible all -a id` +fails for now. `root` and `ansible` (in `wheel`, so sudo asks for the +password) have the password `Lab-Pass-1`, as does `devops`. For a root +shell on a node, `sudo podman exec -it node1 bash`. + +## Tasks + +1. Write `/home/greg/ansible/sudo.yml`, which gives `devops` passwordless + sudo on every managed node with a validated drop-in, + `/etc/sudoers.d/devops`, mode 0440, and run it. Then + `ansible all -a id` shows `uid=0(root)` on every node. +2. Copy it to `/home/greg/ansible/typo.yml` and put a typo in the rule + (for example, leave out the colon after `NOPASSWD`). Running + `typo.yml` must fail at `validate`, and sudo must still work on the + nodes. +3. Write `/home/greg/ansible/whoami.yml`: a play with `become: true` whose + `command: whoami` task has `become_user: devops` and prints `devops` + for every node. + +## Hints + +- Until devops has sudo, log in as someone who has it: `remote_user: root` + with `-k`, or `remote_user: ansible` with `-k -K`. +- `ansible.builtin.copy` with `content`, `mode: "0440"` and + `validate: /usr/sbin/visudo -cf %s`; the rule needs its trailing newline. +- `register` the result and print `.stdout` with `debug`, or run the + playbook with `-v`. diff --git a/labs/ex294/3-3-privilege-escalation/check.sh b/labs/ex294/3-3-privilege-escalation/check.sh new file mode 100644 index 0000000..5264782 --- /dev/null +++ b/labs/ex294/3-3-privilege-escalation/check.sh @@ -0,0 +1,36 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { setsid -w timeout 180 runuser -l greg -c "cd ~/ansible && $*" "$OUT" && grep -q . "$OUT" && ! grep -v "uid=0(root)" "$OUT"; } + +task 1 "Passwordless sudo for devops from a validated drop-in, mode 0440" +need "/home/greg/ansible/sudo.yml exists" "[ -f $A/sudo.yml ]" +need "sudo.yml validates the file with visudo" "grep -Eq 'validate:.*visudo' $A/sudo.yml" +for n in $(nodes_names); do + need "/etc/sudoers.d/devops on $n is valid, mode 0440, and gives devops NOPASSWD: ALL" "sudo_ok $n" +done +need "ansible all -a id shows uid=0(root) on every node (as greg, in ~/ansible)" idroot + +task 2 "typo.yml fails at validate, and sudo still works" +need "/home/greg/ansible/typo.yml exists" "[ -f $A/typo.yml ]" +need "typo.yml validates the file with visudo" "grep -Eq 'validate:.*visudo' $A/typo.yml" +need "running typo.yml fails with \"failed to validate\"" "! ans ansible-playbook typo.yml $P >\"\$OUT\" && grep -q 'failed to validate' \"\$OUT\"" +for n in $(nodes_names); do + need "devops still has passwordless sudo on $n" "sudo_ok $n" +done +need "ansible all -a id still shows uid=0(root) on every node" idroot + +task 3 "whoami.yml runs whoami as devops in a play that becomes root" +need "/home/greg/ansible/whoami.yml exists" "[ -f $A/whoami.yml ]" +need "whoami.yml has become: true and become_user: devops" "grep -Eq '^ *become: *(true|yes|True)' $A/whoami.yml && grep -Eq '^ *become_user: *[\"'\'']?devops' $A/whoami.yml" +need "whoami.yml runs whoami" "grep -q whoami $A/whoami.yml" +need "running whoami.yml -v shows whoami printing devops on every node, with no failures" 'ans ansible-playbook whoami.yml -v >"$OUT" && for n in $(nodes_names); do grep -E "^(ok|changed): \[$n\] => \{.*\"stdout\": \"devops\"" "$OUT" >/dev/null || exit 1; done' +rm -f "$OUT" diff --git a/labs/ex294/3-3-privilege-escalation/lab.conf b/labs/ex294/3-3-privilege-escalation/lab.conf new file mode 100644 index 0000000..16d7a44 --- /dev/null +++ b/labs/ex294/3-3-privilege-escalation/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.3 Privilege escalation" +CHAPTER=/ex294/3-managed-nodes/3-3-privilege-escalation/ +SYSTEMS="rhel" diff --git a/labs/ex294/3-3-privilege-escalation/reset.sh b/labs/ex294/3-3-privilege-escalation/reset.sh new file mode 100644 index 0000000..bd11003 --- /dev/null +++ b/labs/ex294/3-3-privilege-escalation/reset.sh @@ -0,0 +1,4 @@ +# The nodes go with nodes_down and greg (with his key and project) with +# users_remove; the packages the lab installed here. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/3-3-privilege-escalation/setup.sh b/labs/ex294/3-3-privilege-escalation/setup.sh new file mode 100644 index 0000000..49c654e --- /dev/null +++ b/labs/ex294/3-3-privilege-escalation/setup.sh @@ -0,0 +1,32 @@ +# The control node: ansible-core, sshpass (for -k) and greg's project, with +# his key. +for p in ansible-core sshpass; do pkg_installed $p || echo $p >>"$LAB_STATE/missing"; done +pkg_installed ansible-core || pkg_install ansible-core +pkg_installed sshpass || pkg_install sshpass +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'C' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +C +printf '[nodes]\nnode1\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, with devops (password and greg's key, but no sudo). +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'id devops >/dev/null 2>&1 || useradd -m devops + echo devops:Lab-Pass-1 | chpasswd + rm -f /etc/sudoers.d/devops + install -d -m 700 -o devops -g devops /home/devops/.ssh + cat >/home/devops/.ssh/authorized_keys + chown devops: /home/devops/.ssh/authorized_keys + chmod 600 /home/devops/.ssh/authorized_keys' /dev/null | xargs -0r sha256sum | grep -q "^$1 "; } +sum() { in_node "$1" sha256sum /etc/issue.net | cut -d' ' -f1; } + +task 1 "/etc/issue.net from a project file, root, 0644, with backups" +for n in $(nodes_names); do + need "/etc/issue.net on $n is owned by root:root with mode 644" "[ \"\$(in_node $n stat -c '%U:%G %a' /etc/issue.net)\" = 'root:root 644' ]" + need "/etc/issue.net on $n is no longer the old banner" "! in_node $n grep -q '^Old banner' /etc/issue.net" + need "/etc/issue.net on $n is the same as a file in /home/greg/ansible" "inproject \"\$(sum $n)\"" + need "$n has two backups of /etc/issue.net (/etc/issue.net.*~): one old, one from the change" "[ \"\$(in_node $n 'ls -d /etc/issue.net.*~' | wc -l)\" -ge 2 ]" + need "a backup on $n holds the old banner" "in_node $n 'grep -l \"^Old banner\" /etc/issue.net.*~'" +done + +task 2 "/srv/app/conf owned by devops, mode 0750, with app.conf" +for n in $(nodes_names); do + need "/srv/app/conf on $n is a directory owned by devops with mode 750" "[ \"\$(in_node $n stat -c '%F %U %a' /srv/app/conf)\" = 'directory devops 750' ]" + need "/srv/app/conf/app.conf on $n has some content" "in_node $n test -s /srv/app/conf/app.conf" +done + +task 3 "/etc/hostname fetched from every webserver" +for n in $(nodes_names); do + need "$A/fetched/$n/etc/hostname holds $n" "[ \"\$(cat $A/fetched/$n/etc/hostname)\" = $n ]" +done diff --git a/labs/ex294/3-4-deploying-files/lab.conf b/labs/ex294/3-4-deploying-files/lab.conf new file mode 100644 index 0000000..c6742d2 --- /dev/null +++ b/labs/ex294/3-4-deploying-files/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.4 Deploying files to managed nodes" +CHAPTER=/ex294/3-managed-nodes/3-4-deploying-files/ +SYSTEMS="rhel" diff --git a/labs/ex294/3-4-deploying-files/reset.sh b/labs/ex294/3-4-deploying-files/reset.sh new file mode 100644 index 0000000..bd11003 --- /dev/null +++ b/labs/ex294/3-4-deploying-files/reset.sh @@ -0,0 +1,4 @@ +# The nodes go with nodes_down and greg (with his key and project) with +# users_remove; the packages the lab installed here. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/3-4-deploying-files/setup.sh b/labs/ex294/3-4-deploying-files/setup.sh new file mode 100644 index 0000000..f6f9a60 --- /dev/null +++ b/labs/ex294/3-4-deploying-files/setup.sh @@ -0,0 +1,37 @@ +# The control node: ansible-core and greg's project, with his key. +for p in ansible-core; do pkg_installed $p || echo $p >>"$LAB_STATE/missing"; done +pkg_installed ansible-core || pkg_install ansible-core +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'C' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +C +printf '[webservers]\nnode1\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes: devops with greg's key and sudo, an old issue.net with +# the wrong owner and mode, and no /srv/app. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'id devops >/dev/null 2>&1 || useradd -m devops + echo devops:Lab-Pass-1 | chpasswd + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops + chmod 440 /etc/sudoers.d/devops + install -d -m 700 -o devops -g devops /home/devops/.ssh + cat >/home/devops/.ssh/authorized_keys + chown devops: /home/devops/.ssh/authorized_keys + chmod 600 /home/devops/.ssh/authorized_keys + printf "Old banner: authorized use only.\n" >/etc/issue.net + chown ansible:ansible /etc/issue.net + chmod 600 /etc/issue.net + rm -f /etc/issue.net.*~ + rm -rf /srv/app' /dev/null; done >"$OUT.runs" +N=$(state_get note) + +task 1 "site.yml run with ansible-playbook, and with navigator limited to one node" +need "a node logged a run of site.yml by ansible-playbook" 'grep -q " ansible-playbook in the control node, " "$OUT.runs"' +need "a node logged a run of site.yml by ansible-navigator in the execution environment, with --limit" 'grep " ansible-navigator in an execution environment, limit " "$OUT.runs" | grep -vq "limit none$"' + +task 2 "Open a task's result in navigator's interactive mode" self + +task 3 "note.yml prints the note with a file lookup, with both commands" +P=/home/greg/ansible/note.yml +need "$P exists" '[ -f "$P" ]' +need "note.yml uses a file lookup" 'grep -Eq "(lookup|query|q)\( *[\"$Q](ansible\.builtin\.)?file[\"$Q]" "$P"' +need "a copy of the note is in /home/greg/ansible" 'grep -rlqF "$N" /home/greg/ansible --exclude="*.log" --exclude="*.json"' +need "ansible-playbook note.yml prints the note" 'ans ansible-playbook note.yml | grep -qF "$N"' +need "ansible-navigator run note.yml -m stdout prints the note" 'ans ansible-navigator run note.yml -m stdout | grep -qF "$N"' +rm -f "$OUT" "$OUT.runs" diff --git a/labs/ex294/4-1-ansible-playbook-and-navigator/lab.conf b/labs/ex294/4-1-ansible-playbook-and-navigator/lab.conf new file mode 100644 index 0000000..ddbca82 --- /dev/null +++ b/labs/ex294/4-1-ansible-playbook-and-navigator/lab.conf @@ -0,0 +1,3 @@ +TITLE="4.1 ansible-playbook and ansible-navigator" +CHAPTER=/ex294/4-running-playbooks/4-1-ansible-playbook-and-navigator/ +SYSTEMS="rhel" diff --git a/labs/ex294/4-1-ansible-playbook-and-navigator/reset.sh b/labs/ex294/4-1-ansible-playbook-and-navigator/reset.sh new file mode 100644 index 0000000..d3c009a --- /dev/null +++ b/labs/ex294/4-1-ansible-playbook-and-navigator/reset.sh @@ -0,0 +1,12 @@ +# Navigator, if the lab installed it. greg (with his project and his +# podman images) goes with users_remove, the nodes with nodes_down. +if [ "$(state_get navigator)" = installed ]; then + rm -f /usr/local/bin/ansible-navigator + rm -rf /opt/le-lab-navigator +fi +if id greg >/dev/null 2>&1; then + runuser -l greg -c 'podman system reset -f' >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +rm -f /tmp/lab-note.txt +true diff --git a/labs/ex294/4-1-ansible-playbook-and-navigator/setup.sh b/labs/ex294/4-1-ansible-playbook-and-navigator/setup.sh new file mode 100644 index 0000000..6cb0468 --- /dev/null +++ b/labs/ex294/4-1-ansible-playbook-and-navigator/setup.sh @@ -0,0 +1,107 @@ +# The control node: ansible-core, python3-pip (for ansible-navigator's +# virtual environment) and greg with a key and quiet rootless podman. +for p in ansible-core python3-pip; do + pkg_installed $p || { echo $p >>"$LAB_STATE/missing"; pkg_install $p; } +done +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +# Without a systemd user session (su, sudo -i), podman would warn on every +# run; the cgroupfs manager works the same in any login. +install -d -m 755 -o greg -g greg /home/greg/.config /home/greg/.config/containers +printf '[engine]\ncgroup_manager = "cgroupfs"\nevents_logger = "file"\n' >/home/greg/.config/containers/containers.conf +chown greg: /home/greg/.config/containers/containers.conf + +# greg's project: ansible.cfg, inventory, ansible-navigator.yml without a +# mode, and site.yml, which records each run on the node (task 1). +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = ./inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[nodes]\nnode1\nnode2\n' >/home/greg/ansible/inventory +cat >/home/greg/ansible/ansible-navigator.yml <<'X' +--- +ansible-navigator: + execution-environment: + image: localhost/lab-ee:2.16 + pull: + policy: never + playbook-artifact: + enable: false +X +cat >/home/greg/ansible/site.yml <<'X' +--- +- name: Check the managed nodes + hosts: all + vars: + run_by: "{{ 'ansible-navigator' if lookup('ansible.builtin.env', 'ANSIBLE_STDOUT_CALLBACK') == 'awx_display' else 'ansible-playbook' }}" + run_in: "{{ 'an execution environment' if lookup('ansible.builtin.env', 'container') else 'the control node' }}" + tasks: + - name: Make sure the node answers + ansible.builtin.ping: + + - name: Record this run in /var/log/lab-runs.log + ansible.builtin.lineinfile: + path: /var/log/lab-runs.log + line: "{{ now(utc=true, fmt='%Y-%m-%d %H:%M:%S') }} {{ run_by }} in {{ run_in }}, limit {{ ansible_limit | default('none') }}" + create: true + mode: "0644" + changed_when: false +X +chown greg: /home/greg/ansible/* + +# The note for task 3, outside the project. +N=$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n') +state_set note "$N" +printf 'The note says: %s\n' "$N" >/tmp/lab-note.txt +chown greg: /tmp/lab-note.txt + +# The managed nodes, with devops: greg's key and passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node "$n" 'useradd -m devops && printf "devops ALL=(ALL) NOPASSWD: ALL\n" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i "$n" 'cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && chmod 600 /home/devops/.ssh/authorized_keys' /dev/null && break + sleep 5 + done + ln -s /opt/le-lab-navigator/bin/ansible-navigator /usr/local/bin/ansible-navigator + /usr/local/bin/ansible-navigator --version >/dev/null + state_set navigator installed +fi + +# The execution environment localhost/lab-ee:2.16: the node image with +# ansible-core and ansible.posix and community.general from Ansible Galaxy. +# Built as root, given to greg's rootless podman, and removed from root's. +say "${C_DIM}Building the execution environment image...${C_0}" +d=$(mktemp -d) +cat >"$d/Containerfile" <=2,<3' 'community.general:>=11,<12' && break; sleep 5; \\ + done \\ + && ansible-doc community.general.lvg >/dev/null && ansible-doc ansible.posix.seboolean >/dev/null \\ + && dnf clean all +X +podman build -q --layers=false -t localhost/lab-ee:2.16 "$d" >/dev/null +rm -rf "$d" +podman save localhost/lab-ee:2.16 | runuser -l greg -c 'podman load -q' >/dev/null 2>&1 +podman rmi localhost/lab-ee:2.16 >/dev/null +runuser -l greg -c 'podman image exists localhost/lab-ee:2.16' diff --git a/labs/ex294/4-2-finding-modules/README.md b/labs/ex294/4-2-finding-modules/README.md new file mode 100644 index 0000000..50ced59 --- /dev/null +++ b/labs/ex294/4-2-finding-modules/README.md @@ -0,0 +1,50 @@ +# EX294 4.2: Finding modules with ansible-navigator + +Browse the collections in an execution environment, use a module from its +documentation, and see a module that only the image has. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core` and +`ansible-navigator` (from PyPI, in `/opt/le-lab-navigator`). Work as +greg, in `/home/greg/ansible`: its `ansible.cfg` reads the inventory +`inventory`, logs in as `devops` and becomes root, and its +`ansible-navigator.yml` uses the execution environment image +`localhost/lab-ee:2.16` in stdout mode. The image is built on this VM +from AlmaLinux 10's ansible-core with the `ansible.posix` and +`community.general` collections from Ansible Galaxy; the control node +itself has only ansible-core's own modules. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers reached over SSH by name; on both, `devops` +accepts greg's key and may use sudo without a password. For a root shell +on a node, `sudo podman exec -it node1 bash`. + +The nodes have no disks of their own, so storage work goes to this VM: +the inventory group `storage` holds `control`, which is this VM reached +over SSH at 10.99.1.1 (inside the execution environment, `localhost` is +the container, not this VM). There too `devops` accepts greg's key and +may use sudo without a password. The VM has a spare disk, `/dev/loop41`. + +## Tasks + +1. List the collections in the execution environment and find the module + that manages SELinux booleans. Write its full name to + `/root/lab-answers/1` (as root). +2. Read `community.general.lvg`'s documentation from the image, and write + a playbook in `/home/greg/ansible` with a task that creates the volume + group `data` on `/dev/loop41` for the `storage` group. Run it with + `ansible-navigator run`; `vgs` on this VM shows `data`. +3. Find a module that's in the image but not on the control node, and + write `/home/greg/ansible/both.yml`, a playbook that uses it. Run it + both ways: navigator succeeds, ansible-playbook fails with "couldn't + resolve module/action". + +## Hints + +- `ansible-navigator collections -m interactive` browses them; in stdout + mode it prints every plugin as YAML, so pipe it through `grep`. + `ansible-navigator doc -l`, `ansible-navigator doc NAME`. +- Use modules by their full name, `namespace.collection.module`. +- `ansible-doc -l` lists what the control node has. diff --git a/labs/ex294/4-2-finding-modules/check.sh b/labs/ex294/4-2-finding-modules/check.sh new file mode 100644 index 0000000..d5ef292 --- /dev/null +++ b/labs/ex294/4-2-finding-modules/check.sh @@ -0,0 +1,19 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" "$OUT" 2>&1; grep -q "couldn.t resolve module/action" "$OUT"' +rm -f "$OUT" diff --git a/labs/ex294/4-2-finding-modules/lab.conf b/labs/ex294/4-2-finding-modules/lab.conf new file mode 100644 index 0000000..2c737b2 --- /dev/null +++ b/labs/ex294/4-2-finding-modules/lab.conf @@ -0,0 +1,3 @@ +TITLE="4.2 Finding modules with ansible-navigator" +CHAPTER=/ex294/4-running-playbooks/4-2-finding-modules/ +SYSTEMS="rhel" diff --git a/labs/ex294/4-2-finding-modules/reset.sh b/labs/ex294/4-2-finding-modules/reset.sh new file mode 100644 index 0000000..f3bb979 --- /dev/null +++ b/labs/ex294/4-2-finding-modules/reset.sh @@ -0,0 +1,13 @@ +# Navigator, if the lab installed it. greg (with his project and his +# podman images) goes with users_remove, the nodes with nodes_down. +if [ "$(state_get navigator)" = installed ]; then + rm -f /usr/local/bin/ansible-navigator + rm -rf /opt/le-lab-navigator +fi +if id greg >/dev/null 2>&1; then + runuser -l greg -c 'podman system reset -f' >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +# devops and the spare disk go with users_remove and disks_remove. +rm -f /etc/sudoers.d/le-lab-devops +true diff --git a/labs/ex294/4-2-finding-modules/setup.sh b/labs/ex294/4-2-finding-modules/setup.sh new file mode 100644 index 0000000..4254f2e --- /dev/null +++ b/labs/ex294/4-2-finding-modules/setup.sh @@ -0,0 +1,97 @@ +# Task 2 needs LVM on this VM. +command -v vgs >/dev/null || refuse "lvm2 isn't installed; install it (dnf install lvm2) and start again" +vgs data >/dev/null 2>&1 && refuse "this VM already has a volume group data; remove or rename it and start again" + +# The control node: ansible-core, python3-pip (for ansible-navigator's +# virtual environment) and greg with a key and quiet rootless podman. +for p in ansible-core python3-pip; do + pkg_installed $p || { echo $p >>"$LAB_STATE/missing"; pkg_install $p; } +done +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +# Without a systemd user session (su, sudo -i), podman would warn on every +# run; the cgroupfs manager works the same in any login. +install -d -m 755 -o greg -g greg /home/greg/.config /home/greg/.config/containers +printf '[engine]\ncgroup_manager = "cgroupfs"\nevents_logger = "file"\n' >/home/greg/.config/containers/containers.conf +chown greg: /home/greg/.config/containers/containers.conf + +# greg's project: ansible.cfg, inventory with the nodes and this VM, and +# ansible-navigator.yml. +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = ./inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[nodes]\nnode1\nnode2\n\n[storage]\ncontrol ansible_host=10.99.1.1\n' >/home/greg/ansible/inventory +cat >/home/greg/ansible/ansible-navigator.yml <<'X' +--- +ansible-navigator: + execution-environment: + image: localhost/lab-ee:2.16 + pull: + policy: never + mode: stdout + playbook-artifact: + enable: false +X +chown greg: /home/greg/ansible/* + +# This VM as a managed host: devops with greg's key and passwordless sudo, +# and a spare disk (task 2). +user_add devops +printf 'devops ALL=(ALL) NOPASSWD: ALL\n' >/etc/sudoers.d/le-lab-devops +chmod 440 /etc/sudoers.d/le-lab-devops +install -d -m 700 -o devops -g devops /home/devops/.ssh +install -m 600 -o devops -g devops /home/greg/.ssh/id_ed25519.pub /home/devops/.ssh/authorized_keys +restorecon -R /home/devops/.ssh 2>/dev/null || true +disk_add 1 300M >/dev/null + +# The managed nodes, with devops: greg's key and passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node "$n" 'useradd -m devops && printf "devops ALL=(ALL) NOPASSWD: ALL\n" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i "$n" 'cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && chmod 600 /home/devops/.ssh/authorized_keys' /dev/null && break + sleep 5 + done + ln -s /opt/le-lab-navigator/bin/ansible-navigator /usr/local/bin/ansible-navigator + /usr/local/bin/ansible-navigator --version >/dev/null + state_set navigator installed +fi + +# The execution environment localhost/lab-ee:2.16: the node image with +# ansible-core and ansible.posix and community.general from Ansible Galaxy. +# Built as root, given to greg's rootless podman, and removed from root's. +say "${C_DIM}Building the execution environment image...${C_0}" +d=$(mktemp -d) +cat >"$d/Containerfile" <=2,<3' 'community.general:>=11,<12' && break; sleep 5; \\ + done \\ + && ansible-doc community.general.lvg >/dev/null && ansible-doc ansible.posix.seboolean >/dev/null \\ + && dnf clean all +X +podman build -q --layers=false -t localhost/lab-ee:2.16 "$d" >/dev/null +rm -rf "$d" +podman save localhost/lab-ee:2.16 | runuser -l greg -c 'podman load -q' >/dev/null 2>&1 +podman rmi localhost/lab-ee:2.16 >/dev/null +runuser -l greg -c 'podman image exists localhost/lab-ee:2.16' diff --git a/labs/ex294/4-3-navigator-inventories-and-settings/README.md b/labs/ex294/4-3-navigator-inventories-and-settings/README.md new file mode 100644 index 0000000..3693386 --- /dev/null +++ b/labs/ex294/4-3-navigator-inventories-and-settings/README.md @@ -0,0 +1,49 @@ +# EX294 4.3: Navigator inventories and settings + +Make ansible-navigator see the same inventory and `ansible.cfg` as +Ansible does, and give its execution environment a roles directory from +outside the project. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core` and +`ansible-navigator` (from PyPI, in `/opt/le-lab-navigator`). Work as +greg, in `/home/greg/ansible`: its `ansible.cfg` reads the inventory +`inventory`, logs in as `devops` and becomes root. Its +`ansible-navigator.yml` uses the execution environment image +`localhost/lab-ee:2.16` (built on this VM from AlmaLinux 10's ansible-core +and collections from Ansible Galaxy; no registry or login) in stdout mode, +but someone left two more settings in it. + +greg also has a role, `banner`, in `/home/greg/roles`, outside the +project, and the project's `banner.yml` applies it to every node. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers reached over SSH by name; on both, `devops` +accepts greg's key and may use sudo without a password. For a root shell +on a node, `sudo podman exec -it node1 bash`. + +## Tasks + +1. Show the inventory's groups with ansible-navigator, in stdout mode and + in the interactive browser, and make them match: `ansible-navigator + inventory -m stdout --graph` prints the same as `ansible-inventory + --graph`. +2. Find which `ansible.cfg` ansible-navigator uses, and make it the + project's: `ansible-navigator config dump --only-changed -m stdout` + shows `CONFIG_FILE() = /home/greg/ansible/ansible.cfg`. +3. Set `roles_path` in the project's `ansible.cfg` to `/home/greg/roles` + and run `banner.yml` with navigator: it fails with "the role 'banner' + was not found". Fix it with a volume mount in `ansible-navigator.yml`, + so the run succeeds and every node has `/etc/motd.d/banner`. + +## Hints + +- `ansible-navigator settings --effective --sources -m stdout` says where + each setting came from: look at `ansible.config.path` and + `ansible.inventory.entries`. +- `ansible-navigator inventory -m interactive`: numbers open a view, Esc + goes back, `:q` quits. +- `execution-environment: volume-mounts:` takes a list of `src`, `dest` + and `options` (`Z` relabels the directory for SELinux). diff --git a/labs/ex294/4-3-navigator-inventories-and-settings/check.sh b/labs/ex294/4-3-navigator-inventories-and-settings/check.sh new file mode 100644 index 0000000..f92fba7 --- /dev/null +++ b/labs/ex294/4-3-navigator-inventories-and-settings/check.sh @@ -0,0 +1,24 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" "$OUT.a" && grep -q node1 "$OUT.a"' +need "ansible-navigator inventory -m stdout --graph prints the same groups" 'ans ansible-navigator inventory -m stdout --graph >"$OUT.n" && diff -q "$OUT.a" "$OUT.n"' + +task 2 "ansible-navigator uses the project's ansible.cfg" +ans ansible-navigator config dump --only-changed -m stdout >"$OUT.cfg" 2>/dev/null +need "the config dump shows CONFIG_FILE() = $C" 'grep -Fqx "CONFIG_FILE() = $C" "$OUT.cfg"' + +task 3 "roles_path outside the project, mounted into the execution environment" +need "$C sets roles_path to /home/greg/roles" 'ans ansible-config dump --only-changed | grep -F "DEFAULT_ROLES_PATH($C) = " | grep -q "/home/greg/roles\b"' +need "the execution environment mounts /home/greg/roles" 'ans ansible-navigator settings --effective -m stdout | python3 -c "import sys, yaml +m = yaml.safe_load(sys.stdin)[\"ansible-navigator\"][\"execution-environment\"].get(\"volume-mounts\") or [] +sys.exit(not any(str(v.get(\"src\", \"\")).rstrip(\"/\") == \"/home/greg/roles\" for v in m))"' +need "ansible-navigator finds the banner role (banner.yml --syntax-check)" 'ans ansible-navigator run banner.yml --syntax-check -m stdout' +for n in $(nodes_names); do + need "$n has /etc/motd.d/banner from the role" "in_node $n grep -q 'managed by Ansible' /etc/motd.d/banner" +done +rm -f "$OUT".* diff --git a/labs/ex294/4-3-navigator-inventories-and-settings/lab.conf b/labs/ex294/4-3-navigator-inventories-and-settings/lab.conf new file mode 100644 index 0000000..4ddb438 --- /dev/null +++ b/labs/ex294/4-3-navigator-inventories-and-settings/lab.conf @@ -0,0 +1,3 @@ +TITLE="4.3 Navigator inventories and settings" +CHAPTER=/ex294/4-running-playbooks/4-3-navigator-inventories-and-settings/ +SYSTEMS="rhel" diff --git a/labs/ex294/4-3-navigator-inventories-and-settings/reset.sh b/labs/ex294/4-3-navigator-inventories-and-settings/reset.sh new file mode 100644 index 0000000..1199f8d --- /dev/null +++ b/labs/ex294/4-3-navigator-inventories-and-settings/reset.sh @@ -0,0 +1,11 @@ +# Navigator, if the lab installed it. greg (with his project and his +# podman images) goes with users_remove, the nodes with nodes_down. +if [ "$(state_get navigator)" = installed ]; then + rm -f /usr/local/bin/ansible-navigator + rm -rf /opt/le-lab-navigator +fi +if id greg >/dev/null 2>&1; then + runuser -l greg -c 'podman system reset -f' >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/4-3-navigator-inventories-and-settings/setup.sh b/labs/ex294/4-3-navigator-inventories-and-settings/setup.sh new file mode 100644 index 0000000..e115f07 --- /dev/null +++ b/labs/ex294/4-3-navigator-inventories-and-settings/setup.sh @@ -0,0 +1,117 @@ +# The control node: ansible-core, python3-pip (for ansible-navigator's +# virtual environment) and greg with a key and quiet rootless podman. +for p in ansible-core python3-pip; do + pkg_installed $p || { echo $p >>"$LAB_STATE/missing"; pkg_install $p; } +done +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +# Without a systemd user session (su, sudo -i), podman would warn on every +# run; the cgroupfs manager works the same in any login. +install -d -m 755 -o greg -g greg /home/greg/.config /home/greg/.config/containers +printf '[engine]\ncgroup_manager = "cgroupfs"\nevents_logger = "file"\n' >/home/greg/.config/containers/containers.conf +chown greg: /home/greg/.config/containers/containers.conf + +# greg's project. ansible-navigator.yml also names another inventory +# (task 1) and another ansible.cfg (task 2); the role lives outside the +# project (task 3). +install -d -m 755 -o greg -g greg /home/greg/ansible /home/greg/ansible/old +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = ./inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nnode1\n\n[dbservers]\nnode2\n' >/home/greg/ansible/inventory +printf '[web]\nnode1\nnode2\n' >/home/greg/ansible/old/inventory +cat >/home/greg/ansible/old/ansible.cfg <<'X' +[defaults] +inventory = ./inventory +remote_user = devops +host_key_checking = false +forks = 2 + +[privilege_escalation] +become = true +X +cat >/home/greg/ansible/ansible-navigator.yml <<'X' +--- +ansible-navigator: + ansible: + config: + path: ./old/ansible.cfg + inventory: + entries: + - ./old/inventory + execution-environment: + image: localhost/lab-ee:2.16 + pull: + policy: never + mode: stdout + playbook-artifact: + enable: false +X +cat >/home/greg/ansible/banner.yml <<'X' +--- +- name: Put up the banner + hosts: all + roles: + - banner +X +install -d -m 755 -o greg -g greg /home/greg/roles /home/greg/roles/banner /home/greg/roles/banner/tasks +cat >/home/greg/roles/banner/tasks/main.yml <<'X' +--- +- name: Write the banner + ansible.builtin.copy: + content: "This node is managed by Ansible.\n" + dest: /etc/motd.d/banner + mode: "0644" +X +chown -R greg: /home/greg/ansible /home/greg/roles + +# The managed nodes, with devops: greg's key and passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node "$n" 'useradd -m devops && printf "devops ALL=(ALL) NOPASSWD: ALL\n" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i "$n" 'cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && chmod 600 /home/devops/.ssh/authorized_keys' /dev/null && break + sleep 5 + done + ln -s /opt/le-lab-navigator/bin/ansible-navigator /usr/local/bin/ansible-navigator + /usr/local/bin/ansible-navigator --version >/dev/null + state_set navigator installed +fi + +# The execution environment localhost/lab-ee:2.16: the node image with +# ansible-core and ansible.posix and community.general from Ansible Galaxy. +# Built as root, given to greg's rootless podman, and removed from root's. +say "${C_DIM}Building the execution environment image...${C_0}" +d=$(mktemp -d) +cat >"$d/Containerfile" <=2,<3' 'community.general:>=11,<12' && break; sleep 5; \\ + done \\ + && ansible-doc community.general.lvg >/dev/null && ansible-doc ansible.posix.seboolean >/dev/null \\ + && dnf clean all +X +podman build -q --layers=false -t localhost/lab-ee:2.16 "$d" >/dev/null +rm -rf "$d" +podman save localhost/lab-ee:2.16 | runuser -l greg -c 'podman load -q' >/dev/null 2>&1 +podman rmi localhost/lab-ee:2.16 >/dev/null +runuser -l greg -c 'podman image exists localhost/lab-ee:2.16' diff --git a/labs/ex294/5-1-cloning/README.md b/labs/ex294/5-1-cloning/README.md new file mode 100644 index 0000000..8e96446 --- /dev/null +++ b/labs/ex294/5-1-cloning/README.md @@ -0,0 +1,49 @@ +# EX294 5.1: Cloning a repository + +Make a bare Git repository on a managed node, clone it on the control node +over SSH, set your Git identity and push a first commit, and clone it a +second time. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core` and `git`. Work +as greg. His Ansible project is `/home/greg/ansible`: its `ansible.cfg` +reads the inventory `inventory`, logs in as `devops` and becomes root. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers reached over SSH by name, with `git` installed. On +each, `devops` has greg's key and passwordless sudo, so +`ssh devops@node1` works without a password. For a root shell on a node, +`sudo podman exec -it node1 bash`. Everything stays on this VM: the +repository lives on a node, not on a hosting service. + +## Tasks + +1. Make a bare repository `/srv/git/playbooks.git` with the branch `main` + on `node1` (`git init --bare -b main /srv/git/playbooks.git`, with + Ansible or in a shell there). Then, as greg, set `init.defaultBranch` + to `main` and clone it over SSH into `/home/greg/playbooks`, so that + `git remote -v` there shows the URL for fetch and push, and + `git status` says "On branch main". +2. Set greg's Git name and email globally, then commit a `README.md` in + the clone and push it, so that `git log` shows them as the author. +3. Clone the same repository again, into a directory of your choosing in + greg's home, so that it has the same `git log --oneline` as the first + clone. + +## Hints + +- `ansible node1 -a 'git init --bare -b main /srv/git/playbooks.git'` + makes it as root; for `devops` to push to it, give it to devops with + `ansible.builtin.file` and `recurse: true`. +- `git clone devops@node1:/srv/git/playbooks.git`; the first SSH + connection asks you to trust node1's host key. +- An empty repository clones with a warning. Over SSH, its clone starts + on Git's own default branch, `master`, unless + `git config --global init.defaultBranch main` comes first; in a clone + that's already on `master`, `git switch -c main` before the first + commit. +- The first push of a new branch: `git push -u origin main`. +- `git config --global user.name ...`, `user.email`, `--list`; + `git log --format='%an <%ae>'` shows the author. diff --git a/labs/ex294/5-1-cloning/check.sh b/labs/ex294/5-1-cloning/check.sh new file mode 100644 index 0000000..9754c4d --- /dev/null +++ b/labs/ex294/5-1-cloning/check.sh @@ -0,0 +1,40 @@ +nodes_wait 60 || true +R=/srv/git/playbooks.git +C=/home/greg/playbooks +# g CMD: run git CMD as greg, over SSH with his key and no prompts. +g() { + setsid -w timeout 60 runuser -u greg -- env HOME=/home/greg \ + GIT_SSH_COMMAND="ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10" \ + git "$@" /dev/null | + grep -Eq '^(ssh://)?([^@/]+@)?(node1(\.lab\.example)?|10\.99\.1\.11)(:[0-9]+)?[:/]+srv/git/playbooks\.git/?$' +} +# The other clones of it in greg's home. +others() { + find /home/greg -maxdepth 3 -name .git -type d 2>/dev/null | while read -r d; do + d=${d%/.git}; [ "$d" = "$C" ] && continue + origin_ok "$d" && printf '%s\n' "$d" + done +} +log() { g -C "$1" log --oneline 2>/dev/null; } + +task 1 "A bare repository on node1, cloned over SSH into /home/greg/playbooks, on main" +need "$R on node1 is a bare repository" "[ \"\$(in_node node1 git --git-dir=$R rev-parse --is-bare-repository)\" = true ]" +need "its default branch is main" "[ \"\$(in_node node1 git --git-dir=$R symbolic-ref HEAD)\" = refs/heads/main ]" +need "$C is a Git clone" "[ -d $C/.git ]" +need "its origin is devops@node1:$R (or another SSH URL for it)" "origin_ok $C" +need "greg can reach origin over SSH from $C (git ls-remote origin)" "g -C $C ls-remote origin" +need "$C is on the branch main" "[ \"\$(g -C $C symbolic-ref HEAD)\" = refs/heads/main ]" + +task 2 "greg's Git name and email, set globally, on a pushed README.md" +need "user.name is set in greg's global Git config" 'runuser -l greg -c "git config --global user.name" | grep -q .' +need "user.email is set in greg's global Git config" 'runuser -l greg -c "git config --global user.email" | grep -q @' +need "main in node1's repository has README.md" "in_node node1 git --git-dir=$R ls-tree --name-only main | grep -qx README.md" +need "a commit on node1's main has greg's name and email as the author" 'in_node node1 git --git-dir=$R log --format="%an <%ae>" main | grep -qxF "$(runuser -l greg -c "git config --global user.name") <$(runuser -l greg -c "git config --global user.email")>"' + +task 3 "A second clone of the repository, with the same log" +need "another directory in greg's home is a clone of node1's $R" '[ -n "$(others)" ]' +need "it has the same git log --oneline as $C, with a commit in it" '[ -n "$(log $C)" ] && for d in $(others); do [ "$(log "$d")" = "$(log $C)" ] && exit 0; done; exit 1' diff --git a/labs/ex294/5-1-cloning/lab.conf b/labs/ex294/5-1-cloning/lab.conf new file mode 100644 index 0000000..1dffb83 --- /dev/null +++ b/labs/ex294/5-1-cloning/lab.conf @@ -0,0 +1,3 @@ +TITLE="5.1 Cloning a repository" +CHAPTER=/ex294/5-git/5-1-cloning/ +SYSTEMS="rhel" diff --git a/labs/ex294/5-1-cloning/reset.sh b/labs/ex294/5-1-cloning/reset.sh new file mode 100644 index 0000000..8577515 --- /dev/null +++ b/labs/ex294/5-1-cloning/reset.sh @@ -0,0 +1,5 @@ +# The nodes (with the repository) go with nodes_down and greg (with his +# clones and Git settings) with users_remove; the packages the lab +# installed here. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/5-1-cloning/setup.sh b/labs/ex294/5-1-cloning/setup.sh new file mode 100644 index 0000000..2dbbed9 --- /dev/null +++ b/labs/ex294/5-1-cloning/setup.sh @@ -0,0 +1,35 @@ +# The control node: ansible-core, git and greg's project, with his key. +for p in ansible-core git-core; do pkg_installed $p || echo $p >>"$LAB_STATE/missing"; done +pkg_installed ansible-core || pkg_install ansible-core +pkg_installed git-core || pkg_install git-core +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'C' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +C +printf '[nodes]\nnode1\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes: devops with greg's key and sudo, git, and no /srv/git. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'id devops >/dev/null 2>&1 || useradd -m devops + echo devops:Lab-Pass-1 | chpasswd + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops + chmod 440 /etc/sudoers.d/devops + install -d -m 700 -o devops -g devops /home/devops/.ssh + cat >/home/devops/.ssh/authorized_keys + chown devops: /home/devops/.ssh/authorized_keys + chmod 600 /home/devops/.ssh/authorized_keys + rm -rf /srv/git' /dev/null || dnf -y -q install git-core >/dev/null' +done diff --git a/labs/ex294/5-2-adding-files/README.md b/labs/ex294/5-2-adding-files/README.md new file mode 100644 index 0000000..42c667a --- /dev/null +++ b/labs/ex294/5-2-adding-files/README.md @@ -0,0 +1,43 @@ +# EX294 5.2: Adding and pushing files + +Add a playbook to a clone and push it, ignore files that don't belong in +the repository, and get past a rejected push with a merge. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key and a Git name and email, and installs +`ansible-core` and `git`. Work as greg. His Ansible project is +`/home/greg/ansible`, with a playbook `site.yml`. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers reached over SSH by name. On node1, the bare +repository `/srv/git/playbooks.git` (owned by `devops`, who has greg's +key) has one commit, and greg's clone of it is `/home/greg/playbooks`, +with origin `devops@node1:/srv/git/playbooks.git`. For a root shell on a +node, `sudo podman exec -it node1 bash`. Everything stays on this VM: the +repository lives on a node, not on a hosting service. + +## Tasks + +1. Copy `/home/greg/ansible/site.yml` into `/home/greg/playbooks`, commit + it and push it, so that a fresh clone in another directory contains + it. +2. In `/home/greg/playbooks`, add a `.gitignore` that ignores + ansible-navigator's playbook artifacts (`*-artifact-*.json`) and log + (`ansible-navigator.log`) and the vault password file `.vault_pass`, + and commit it. Then `git status --short` doesn't list such files. +3. Make a second clone of the repository, and push a commit from it. Then + make a different commit in `/home/greg/playbooks` and try to push it: + it's rejected. Pull with `git pull --no-rebase`, then push again, so + that `git log --graph --oneline` shows the merge and node1's repository + has it. + +## Hints + +- `git add`, `git commit -m`, `git push`; `git log origin/main --oneline`. +- `touch site-artifact-2026-10-30T10:00:00.json ansible-navigator.log + .vault_pass` and `git status --short` test the `.gitignore`. +- Plain `git pull` on diverged branches stops with "Need to specify how + to reconcile divergent branches"; `--no-rebase` merges, and + `git config --global pull.rebase false` makes that the default. diff --git a/labs/ex294/5-2-adding-files/check.sh b/labs/ex294/5-2-adding-files/check.sh new file mode 100644 index 0000000..8c1510e --- /dev/null +++ b/labs/ex294/5-2-adding-files/check.sh @@ -0,0 +1,37 @@ +nodes_wait 60 || true +R=/srv/git/playbooks.git +C=/home/greg/playbooks +# g CMD: run git CMD as greg, over SSH with his key and no prompts. +g() { + setsid -w timeout 60 runuser -u greg -- env HOME=/home/greg \ + GIT_SSH_COMMAND="ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10" \ + git "$@" /dev/null | while read -r d; do + d=${d%/.git}; [ "$d" = "$C" ] && continue + g -C "$d" remote get-url origin 2>/dev/null | + grep -Eq '^(ssh://)?([^@/]+@)?(node1(\.lab\.example)?|10\.99\.1\.11)(:[0-9]+)?[:/]+srv/git/playbooks\.git/?$' && + printf '%s\n' "$d" + done +} +ignored() { g -C $C check-ignore -q --no-index "$1"; } + +task 1 "site.yml committed and pushed" +need "main in node1's repository has site.yml" "rg ls-tree -r --name-only main | grep -qx site.yml" +need "it's the playbook from /home/greg/ansible" "rg show main:site.yml | grep -q 'name: A web server'" + +task 2 "A committed .gitignore for artifacts, navigator logs and .vault_pass" +need "$C/.gitignore is committed, with no changes since" "g -C $C ls-files --error-unmatch .gitignore && g -C $C diff --quiet HEAD -- .gitignore" +need "it ignores navigator artifacts such as site-artifact-2026-10-30T10:00:00.json" "ignored site-artifact-2026-10-30T10:00:00.json" +need "it ignores ansible-navigator.log" "ignored ansible-navigator.log" +need "it ignores .vault_pass" "ignored .vault_pass" +need "it doesn't ignore playbooks" "! ignored site.yml" + +task 3 "A rejected push, a pull with a merge, and the push again" +need "there's a second clone of node1's repository in greg's home" '[ -n "$(others)" ]' +need "main in node1's repository has a merge commit" "rg rev-list --merges main | grep -q ." +need "$C has pushed everything: its main is node1's main" "[ \"\$(g -C $C rev-parse main)\" = \"\$(rg rev-parse main)\" ]" diff --git a/labs/ex294/5-2-adding-files/lab.conf b/labs/ex294/5-2-adding-files/lab.conf new file mode 100644 index 0000000..490f9e8 --- /dev/null +++ b/labs/ex294/5-2-adding-files/lab.conf @@ -0,0 +1,3 @@ +TITLE="5.2 Adding and pushing files" +CHAPTER=/ex294/5-git/5-2-adding-files/ +SYSTEMS="rhel" diff --git a/labs/ex294/5-2-adding-files/reset.sh b/labs/ex294/5-2-adding-files/reset.sh new file mode 100644 index 0000000..8577515 --- /dev/null +++ b/labs/ex294/5-2-adding-files/reset.sh @@ -0,0 +1,5 @@ +# The nodes (with the repository) go with nodes_down and greg (with his +# clones and Git settings) with users_remove; the packages the lab +# installed here. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/5-2-adding-files/setup.sh b/labs/ex294/5-2-adding-files/setup.sh new file mode 100644 index 0000000..89285f5 --- /dev/null +++ b/labs/ex294/5-2-adding-files/setup.sh @@ -0,0 +1,64 @@ +# The control node: ansible-core, git and greg's project, with his key. +for p in ansible-core git-core; do pkg_installed $p || echo $p >>"$LAB_STATE/missing"; done +pkg_installed ansible-core || pkg_install ansible-core +pkg_installed git-core || pkg_install git-core +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'C' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +C +printf '[nodes]\nnode1\nnode2\n' >/home/greg/ansible/inventory +cat >/home/greg/ansible/site.yml <<'C' +--- +- name: A web server + hosts: node1 + become: true + tasks: + - name: Install httpd + ansible.builtin.dnf: + name: httpd + state: present + + - name: Start and enable httpd + ansible.builtin.service: + name: httpd + state: started + enabled: true +C +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory /home/greg/ansible/site.yml + +# The managed nodes: devops with greg's key and sudo, and git. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'id devops >/dev/null 2>&1 || useradd -m devops + echo devops:Lab-Pass-1 | chpasswd + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops + chmod 440 /etc/sudoers.d/devops + install -d -m 700 -o devops -g devops /home/devops/.ssh + cat >/home/devops/.ssh/authorized_keys + chown devops: /home/devops/.ssh/authorized_keys + chmod 600 /home/devops/.ssh/authorized_keys + rm -rf /srv/git' /dev/null || dnf -y -q install git-core >/dev/null' +done + +# The repository on node1 with one commit, and greg's clone of it. +in_node node1 'install -d -o devops -g devops /srv/git/playbooks.git && + runuser -u devops -- git init -q --bare -b main /srv/git/playbooks.git' +runuser -l greg -c 'ssh-keyscan -t ed25519 node1 2>/dev/null >>~/.ssh/known_hosts + git config --global user.name "Greg" + git config --global user.email greg@lab.example.com + git -c init.defaultBranch=main clone -q devops@node1:/srv/git/playbooks.git 2>/dev/null + cd playbooks && printf "# Playbooks\n\nAnsible work for the lab nodes.\n" >README.md && + git add README.md && git commit -q -m "Start the repository" && git push -q -u origin main' /dev/null | sed "s/^/$n /"; done >"$OUT.runs" + +task 1 "A new playbook committed and pushed to main" +need "main has a commit after the lab's first one" '[ "$(gg rev-parse main)" != "$B" ] && gg merge-base --is-ancestor "$B" main' +gg diff --name-only --diff-filter=A "$B" main 2>/dev/null | grep -E '\.ya?ml$' >"$OUT.new" +need "the new commits add a .yml or .yaml file" 'grep -q . "$OUT.new"' +need "a fresh clone has a new file that is a playbook (ansible-playbook --syntax-check)" 'C=$(runuser -u greg -- mktemp -d) && runuser -u greg -- git clone -q "$R" "$C/p" && ok=1; while read -r f; do timeout 60 runuser -l greg -c "cd $C/p && ansible-playbook --syntax-check $f" /dev/null 2>&1; then + runuser -l greg -c 'podman system reset -f' >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/6-1-vs-code-git-and-navigator/setup.sh b/labs/ex294/6-1-vs-code-git-and-navigator/setup.sh new file mode 100644 index 0000000..cdf06c0 --- /dev/null +++ b/labs/ex294/6-1-vs-code-git-and-navigator/setup.sh @@ -0,0 +1,98 @@ +# The control node: ansible-core, git, python3-pip (for ansible-navigator's +# virtual environment) and greg with a key and quiet rootless podman. +for p in ansible-core python3-pip git; do + pkg_installed $p || { echo $p >>"$LAB_STATE/missing"; pkg_install $p; } +done +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +# Without a systemd user session (su, sudo -i), podman would warn on every +# run; the cgroupfs manager works the same in any login. +install -d -m 755 -o greg -g greg /home/greg/.config /home/greg/.config/containers +printf '[engine]\ncgroup_manager = "cgroupfs"\nevents_logger = "file"\n' >/home/greg/.config/containers/containers.conf +chown greg: /home/greg/.config/containers/containers.conf + +# greg's repository: one commit with ansible.cfg, the inventory and +# site.yml, which records each run on the node (task 3). greg has no Git +# identity (task 1). +d=$(mktemp -d) +cat >"$d/ansible.cfg" <<'X' +[defaults] +inventory = ./inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[nodes]\nnode1\nnode2\n' >"$d/inventory" +cat >"$d/site.yml" <<'X' +--- +- name: Check the managed nodes + hosts: all + vars: + run_by: "{{ 'ansible-navigator' if lookup('ansible.builtin.env', 'ANSIBLE_STDOUT_CALLBACK') == 'awx_display' else 'ansible-playbook' }}" + tasks: + - name: Make sure the node answers + ansible.builtin.ping: + + - name: Record this run in /var/log/lab-runs.log + ansible.builtin.lineinfile: + path: /var/log/lab-runs.log + line: "{{ now(utc=true, fmt='%Y-%m-%d %H:%M:%S') }} {{ run_by }}, limit {{ ansible_limit | default('none') }}" + create: true + mode: "0644" + changed_when: false +X +chown -R greg: "$d" +install -d -m 755 -o greg -g greg /home/greg/git +runuser -u greg -- git init -q --bare -b main /home/greg/git/ansible.git +runuser -u greg -- bash -c "cd '$d' && git init -q -b main && git add . && + git -c user.name='Lab setup' -c user.email=setup@lab.example commit -q -m 'Start the project' && + git push -q /home/greg/git/ansible.git main" +rm -rf "$d" +state_set base "$(runuser -u greg -- git -C /home/greg/git/ansible.git rev-parse main)" + +# The managed nodes, with devops: greg's key and passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node "$n" 'useradd -m devops && printf "devops ALL=(ALL) NOPASSWD: ALL\n" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i "$n" 'cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && chmod 600 /home/devops/.ssh/authorized_keys' /dev/null && break + sleep 5 + done + ln -s /opt/le-lab-navigator/bin/ansible-navigator /usr/local/bin/ansible-navigator + /usr/local/bin/ansible-navigator --version >/dev/null + state_set navigator installed +fi + +# The execution environment localhost/lab-ee:2.16: the node image with +# ansible-core and ansible.posix and community.general from Ansible Galaxy. +# Built as root, given to greg's rootless podman, and removed from root's. +say "${C_DIM}Building the execution environment image...${C_0}" +d=$(mktemp -d) +cat >"$d/Containerfile" <=2,<3' 'community.general:>=11,<12' && break; sleep 5; \\ + done \\ + && ansible-doc community.general.lvg >/dev/null && ansible-doc ansible.posix.seboolean >/dev/null \\ + && dnf clean all +X +podman build -q --layers=false -t localhost/lab-ee:2.16 "$d" >/dev/null +rm -rf "$d" +podman save localhost/lab-ee:2.16 | runuser -l greg -c 'podman load -q' >/dev/null 2>&1 +podman rmi localhost/lab-ee:2.16 >/dev/null +runuser -l greg -c 'podman image exists localhost/lab-ee:2.16' diff --git a/labs/ex294/6-2-development-containers/README.md b/labs/ex294/6-2-development-containers/README.md new file mode 100644 index 0000000..d9b56ad --- /dev/null +++ b/labs/ex294/6-2-development-containers/README.md @@ -0,0 +1,53 @@ +# EX294 6.2: Development containers + +Give a project a podman development container, run playbooks against the +managed nodes from inside it, and bring it up without VS Code. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, installs `ansible-core` and `npm`, and +pulls the image `ghcr.io/ansible/community-ansible-dev-tools:latest` +(public, no login) into greg's podman. Work as greg, in +`/home/greg/ansible`: its `ansible.cfg` reads the inventory `inventory`, +logs in as `devops` and becomes root. `site.yml` checks every node and +adds a line to the node's `/var/log/lab-runs.log` with the playbook's +directory, the ansible-core version, and whether it ran in VS Code's +terminal. + +For tasks 1 and 2 you need VS Code on your own computer with the Remote - +SSH and Dev Containers extensions: connect to this VM as greg (with his +password, or add your key to his `~/.ssh/authorized_keys`), open +`/home/greg/ansible`, and set **Dev > Containers: Docker Path** to +`podman`. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers reached over SSH by name; on both, `devops` +accepts greg's key and may use sudo without a password. For a root shell +on a node, `sudo podman exec -it node1 bash`. + +## Tasks + +1. Add a podman `.devcontainer/devcontainer.json` to + `/home/greg/ansible` that uses the image above, open the project in VS + Code with **Reopen in Container**, and run `ansible --version` in its + terminal: the config file is under `/workspaces/`, and the version is + the image's, not this VM's. +2. Run `site.yml` against the managed nodes from inside the container. It + fails with "Permission denied (publickey...)" until you forward your + agent or mount greg's `~/.ssh` into the container; then it succeeds. +3. Without VS Code, install the devcontainer CLI (`npm install -g + @devcontainers/cli`, as root), bring the same container up with + `devcontainer up`, and run `site.yml` with `devcontainer exec`. The + recap matches the run from VS Code. + +## Hints + +- **Ansible: Create a Devcontainer** in the Command Palette writes the + podman file; its `runArgs` let tools start containers inside it. +- `"mounts": ["source=${localEnv:HOME}/.ssh,target=/root/.ssh,type=bind,readonly"]`, + then **Dev Containers: Rebuild Container** (or `devcontainer up + --remove-existing-container`), since mounts apply only to a new + container. +- `devcontainer up --workspace-folder . --docker-path podman`, and the + same options for `devcontainer exec`. diff --git a/labs/ex294/6-2-development-containers/check.sh b/labs/ex294/6-2-development-containers/check.sh new file mode 100644 index 0000000..329f3e5 --- /dev/null +++ b/labs/ex294/6-2-development-containers/check.sh @@ -0,0 +1,24 @@ +nodes_wait 60 || true +OUT=$LAB_STATE/check.out +J=/home/greg/ansible/.devcontainer/devcontainer.json +# gp CMD: run podman CMD as greg. +gp() { timeout 120 runuser -l greg -c "podman $*" /dev/null; done >"$OUT.runs" + +task 1 "A podman dev container for the project, with the image's Ansible" +need "$J exists" '[ -f "$J" ]' +need "devcontainer.json names an image" 'python3 -c "import json, re, sys +s = re.sub(r\"^\s*//.*$\", \"\", open(sys.argv[1]).read(), flags=re.M) +sys.exit(not json.loads(s).get(\"image\"))" "$J"' +C=$(gp ps -a -q --filter label=devcontainer.local_folder=/home/greg/ansible 2>/dev/null | head -1) +need "greg has a dev container for /home/greg/ansible" '[ -n "$C" ]' +[ -n "$C" ] && gp start "$C" >/dev/null 2>&1 +need "ansible --version in the container finds /workspaces/ansible/ansible.cfg" 'gp exec -w /workspaces/ansible "$C" ansible --version | grep -qx " config file = /workspaces/ansible/ansible.cfg"' + +task 2 "site.yml reaches the nodes from inside the container" +need "a node logged a run of site.yml from /workspaces/ansible" 'grep -q " /workspaces/ansible ansible-core " "$OUT.runs"' + +task 3 "devcontainer CLI: up, and site.yml with devcontainer exec" +need "greg can run devcontainer --version" 'timeout 60 runuser -l greg -c "devcontainer --version" /dev/null 2>&1; then + runuser -l greg -c 'podman system reset -f' >/dev/null 2>&1 +fi +if [ "$(state_get devcontainer-cli)" != present ] && command -v npm >/dev/null; then + npm uninstall -g @devcontainers/cli >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/6-2-development-containers/setup.sh b/labs/ex294/6-2-development-containers/setup.sh new file mode 100644 index 0000000..f54b2a9 --- /dev/null +++ b/labs/ex294/6-2-development-containers/setup.sh @@ -0,0 +1,62 @@ +# The control node: ansible-core, npm (task 3 installs the devcontainer CLI +# with it), and greg with a key and quiet rootless podman. +for p in ansible-core nodejs-npm; do + pkg_installed $p || { echo $p >>"$LAB_STATE/missing"; pkg_install $p; } +done +[ -e /usr/local/bin/devcontainer ] && state_set devcontainer-cli present +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +# Without a systemd user session (su, sudo -i), podman would warn on every +# run; the cgroupfs manager works the same in any login. +install -d -m 755 -o greg -g greg /home/greg/.config /home/greg/.config/containers +printf '[engine]\ncgroup_manager = "cgroupfs"\nevents_logger = "file"\n' >/home/greg/.config/containers/containers.conf +chown greg: /home/greg/.config/containers/containers.conf + +# greg's project, with no .devcontainer yet (task 1). site.yml records +# each run on the node (tasks 2 and 3). +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = ./inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[nodes]\nnode1\nnode2\n' >/home/greg/ansible/inventory +cat >/home/greg/ansible/site.yml <<'X' +--- +- name: Check the managed nodes + hosts: all + vars: + run_from: "{{ 'VS Code' if lookup('ansible.builtin.env', 'TERM_PROGRAM') == 'vscode' else 'a shell' }}" + tasks: + - name: Make sure the node answers + ansible.builtin.ping: + + - name: Record this run in /var/log/lab-runs.log + ansible.builtin.lineinfile: + path: /var/log/lab-runs.log + line: "{{ now(utc=true, fmt='%Y-%m-%d %H:%M:%S') }} {{ playbook_dir }} ansible-core {{ ansible_version.full }} from {{ run_from }}" + create: true + mode: "0644" + changed_when: false +X +chown greg: /home/greg/ansible/* + +# The managed nodes, with devops: greg's key and passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node "$n" 'useradd -m devops && printf "devops ALL=(ALL) NOPASSWD: ALL\n" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i "$n" 'cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && chmod 600 /home/devops/.ssh/authorized_keys' /dev/null 2>&1 && break + sleep 5 +done +runuser -l greg -c 'podman image exists ghcr.io/ansible/community-ansible-dev-tools:latest' diff --git a/labs/ex294/7-1-common-modules/README.md b/labs/ex294/7-1-common-modules/README.md new file mode 100644 index 0000000..bb1811d --- /dev/null +++ b/labs/ex294/7-1-common-modules/README.md @@ -0,0 +1,40 @@ +# EX294 7.1: Commonly used modules + +Set up a web server with the core modules, make a `command` task run only +once, and read a package's version from the package facts. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core`. Work as greg, +in `/home/greg/ansible`: its `ansible.cfg` reads the inventory `inventory` +(group `nodes`), logs in as `devops` and becomes root. No collections are +installed beyond ansible-core's own. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers reached over SSH by name. On each, `devops` has +greg's key and passwordless sudo. node1 runs firewalld, which lets in only +SSH. For a root shell on a node, `sudo podman exec -it node1 bash`. + +## Tasks + +1. Write `/home/greg/ansible/web.yml`, which installs, starts and enables + httpd on `node1`, deploys its home page `/var/www/html/index.html`, and + opens node1's firewall for it (firewalld keeps running). Then + `curl http://node1/` on the control node prints the page, and a second + run of `web.yml` reports `changed=0`. +2. Write `/home/greg/ansible/dd.yml`, whose `command` task creates + `/root/swapfile` (16 MiB of zeros) on every node with `dd`, and make it + run only once: a second run of `dd.yml` reports `ok` for it, not + `changed`. +3. Write `/home/greg/ansible/sshver.yml`, which prints the installed + version of `openssh-server` on every node without running `rpm`. + +## Hints + +- `ansible.builtin.dnf`, `copy`, `service`; `ansible.posix.firewalld` + needs `ansible-galaxy collection install ansible.posix`, with + `permanent: true` and `immediate: true`. +- `creates:` makes a command skip once its file exists. +- `ansible.builtin.package_facts`, then `debug` with + `ansible_facts['packages']['openssh-server'][0]['version']`. diff --git a/labs/ex294/7-1-common-modules/check.sh b/labs/ex294/7-1-common-modules/check.sh new file mode 100644 index 0000000..5a3fd6a --- /dev/null +++ b/labs/ex294/7-1-common-modules/check.sh @@ -0,0 +1,44 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { setsid -w timeout 300 runuser -l greg -c "cd ~/ansible && $*" "$OUT"; recap0' + +task 2 "dd.yml makes /root/swapfile with dd, only once" +need "/home/greg/ansible/dd.yml exists, with a dd command" "[ -f $A/dd.yml ] && grep -q 'dd ' $A/dd.yml" +for n in $(nodes_names); do + need "/root/swapfile on $n is 16 MiB" "[ \"\$(in_node $n stat -c %s /root/swapfile)\" = 16777216 ]" +done +need "a second run of dd.yml reports changed=0 and leaves the files alone" 'before=$(for n in $(nodes_names); do in_node $n stat -c %Y /root/swapfile; done); ans ansible-playbook dd.yml >"$OUT"; recap0 && [ "$before" = "$(for n in $(nodes_names); do in_node $n stat -c %Y /root/swapfile; done)" ]' + +task 3 "sshver.yml prints openssh-server's version on every node" +need "/home/greg/ansible/sshver.yml exists" "[ -f $A/sshver.yml ]" +need "sshver.yml doesn't run rpm" "! grep -Eq '(^|[^_a-z])rpm +-' $A/sshver.yml" +need "running sshver.yml prints each node's openssh-server version" 'ans ansible-playbook sshver.yml >"$OUT" || exit 1 + for n in $(nodes_names); do + v=$(in_node $n rpm -q --qf "%{VERSION}" openssh-server) + awk -v n="$n" "\$0 == \"ok: [\" n \"] => {\" {f=1} f {print} /^}/ {f=0}" "$OUT" | grep -qF "$v" || exit 1 + done' +rm -f "$OUT" diff --git a/labs/ex294/7-1-common-modules/lab.conf b/labs/ex294/7-1-common-modules/lab.conf new file mode 100644 index 0000000..dd02341 --- /dev/null +++ b/labs/ex294/7-1-common-modules/lab.conf @@ -0,0 +1,3 @@ +TITLE="7.1 Commonly used modules" +CHAPTER=/ex294/7-plays-and-playbooks/7-1-common-modules/ +SYSTEMS="rhel" diff --git a/labs/ex294/7-1-common-modules/reset.sh b/labs/ex294/7-1-common-modules/reset.sh new file mode 100644 index 0000000..6fc3bca --- /dev/null +++ b/labs/ex294/7-1-common-modules/reset.sh @@ -0,0 +1,5 @@ +# The nodes (with httpd and firewalld) go with nodes_down, and greg (with +# his project and collections) with users_remove; the packages the lab +# installed here. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/7-1-common-modules/setup.sh b/labs/ex294/7-1-common-modules/setup.sh new file mode 100644 index 0000000..8ebb7d6 --- /dev/null +++ b/labs/ex294/7-1-common-modules/setup.sh @@ -0,0 +1,42 @@ +# The control node: ansible-core and greg's project, with his key. +for p in ansible-core; do pkg_installed $p || echo $p >>"$LAB_STATE/missing"; done +pkg_installed ansible-core || pkg_install ansible-core +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'C' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +C +printf '[nodes]\nnode1\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes: devops with greg's key and sudo, no httpd and no +# swap file; firewalld running on node1, with only SSH open. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'id devops >/dev/null 2>&1 || useradd -m devops + echo devops:Lab-Pass-1 | chpasswd + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops + chmod 440 /etc/sudoers.d/devops + install -d -m 700 -o devops -g devops /home/devops/.ssh + cat >/home/devops/.ssh/authorized_keys + chown devops: /home/devops/.ssh/authorized_keys + chmod 600 /home/devops/.ssh/authorized_keys + rm -f /root/swapfile' /dev/null || dnf -y -q install firewalld >/dev/null + systemctl enable --now firewalld >/dev/null 2>&1 + for i in $(seq 30); do firewall-cmd -q --state 2>/dev/null && break; sleep 1; done + for s in $(firewall-cmd --permanent --list-services); do + [ "$s" = ssh ] || firewall-cmd -q --permanent --remove-service="$s" + done + firewall-cmd -q --reload' +in_node node1 'firewall-cmd --state' >/dev/null || die "firewalld didn't start on node1" diff --git a/labs/ex294/7-2-registering-results/README.md b/labs/ex294/7-2-registering-results/README.md new file mode 100644 index 0000000..88e31ed --- /dev/null +++ b/labs/ex294/7-2-registering-results/README.md @@ -0,0 +1,38 @@ +# EX294 7.2: Registering results + +Save the output of commands with `register` and use it: print part of it, +write it to a file on each node, and print one line per item of a loop. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core`. Work as greg, +in `/home/greg/ansible`: its `ansible.cfg` reads the inventory `inventory` +(group `nodes`), logs in as `devops` and becomes root. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers reached over SSH by name. On each, `devops` has +greg's key and passwordless sudo. For a root shell on a node, +`sudo podman exec -it node1 bash`. + +## Tasks + +1. Write `/home/greg/ansible/df.yml`, which runs `df -h /` on every node, + saves the output, and prints only its second line, using + `stdout_lines[1]`. Every task reports `ok`, not `changed`. +2. Write the output of `hostnamectl` on each node to `/root/hostinfo.txt` + on that node, with a playbook. +3. Write `/home/greg/ansible/enabled.yml`, which loops + `systemctl is-enabled` over three services, registers the result, and + prints each service with its result: one line per service, labeled + with the service's name. + +## Hints + +- `register: rootfs` and `changed_when: false`; `debug` with + `msg: "{{ rootfs.stdout_lines[1] }}"`. +- `ansible.builtin.copy` with `content: "{{ info.stdout }}\n"`. +- `systemctl is-enabled` exits non-zero for a disabled service: + `failed_when: false`. Loop over `result.results`, and set + `loop_control: label: "{{ item.item }}"`. +- `ansible all -b -a 'cat /root/hostinfo.txt'` checks task 2. diff --git a/labs/ex294/7-2-registering-results/check.sh b/labs/ex294/7-2-registering-results/check.sh new file mode 100644 index 0000000..03c621b --- /dev/null +++ b/labs/ex294/7-2-registering-results/check.sh @@ -0,0 +1,31 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { setsid -w timeout 180 runuser -l greg -c "cd ~/ansible && $*" {" ... "}" blocks of debug output in $OUT. +blocks() { awk -v n="$1" 'index($0, "ok: [" n "] =>") == 1 && / => \{$/ {f=1} f {print} /^}/ {f=0}' "$OUT"; } + +task 1 "df.yml prints the second line of df -h / on every node, reporting ok" +need "/home/greg/ansible/df.yml exists and uses stdout_lines[1]" "[ -f $A/df.yml ] && grep -qF 'stdout_lines[1]' $A/df.yml" +need "running df.yml reports changed=0 and no failures" 'ans ansible-playbook df.yml >"$OUT"; recap0' +for n in $(nodes_names); do + need "df.yml prints $n's root file system line (... NN% /)" "blocks $n | grep -Eq '\": \"[^ \"]+ +[0-9.,]+[A-Z]? +[0-9.,]+[A-Z]? +[0-9.,]+[A-Z]? +[0-9]+% /\"'" +done + +task 2 "/root/hostinfo.txt on each node holds its own hostnamectl output" +for n in $(nodes_names); do + need "/root/hostinfo.txt on $n has hostnamectl's output for $n" "in_node $n grep -Eq 'Static hostname: *$n\$' /root/hostinfo.txt" + need "it has the rest of the output too" "in_node $n grep -q 'Operating System:' /root/hostinfo.txt" +done + +task 3 "enabled.yml prints each of three services with its is-enabled result" +need "/home/greg/ansible/enabled.yml exists and runs systemctl is-enabled" "[ -f $A/enabled.yml ] && grep -q 'is-enabled' $A/enabled.yml" +need "running enabled.yml has no failures" 'ans ansible-playbook enabled.yml >"$OUT" && grep -Eq "^node[0-9]+ +: ok=" "$OUT"' +for n in $(nodes_names); do + need "on $n, it prints at least three lines labeled (item=SERVICE)" "[ \"\$(grep -E '^ok: \\[$n\\] => \\(item=[A-Za-z0-9_.@-]+\\) => \\{\$' \"\$OUT\" | sort -u | wc -l)\" -ge 3 ]" + need "the printed lines for $n give the results (enabled, disabled, ...)" "blocks $n | grep -Eq '(enabled|disabled|static|masked|indirect|generated|alias|linked|not-found)'" +done +rm -f "$OUT" diff --git a/labs/ex294/7-2-registering-results/lab.conf b/labs/ex294/7-2-registering-results/lab.conf new file mode 100644 index 0000000..4b07da6 --- /dev/null +++ b/labs/ex294/7-2-registering-results/lab.conf @@ -0,0 +1,3 @@ +TITLE="7.2 Registering results" +CHAPTER=/ex294/7-plays-and-playbooks/7-2-registering-results/ +SYSTEMS="rhel" diff --git a/labs/ex294/7-2-registering-results/reset.sh b/labs/ex294/7-2-registering-results/reset.sh new file mode 100644 index 0000000..fb9866e --- /dev/null +++ b/labs/ex294/7-2-registering-results/reset.sh @@ -0,0 +1,4 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove; the packages the lab installed here. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/7-2-registering-results/setup.sh b/labs/ex294/7-2-registering-results/setup.sh new file mode 100644 index 0000000..38d4fb9 --- /dev/null +++ b/labs/ex294/7-2-registering-results/setup.sh @@ -0,0 +1,32 @@ +# The control node: ansible-core and greg's project, with his key. +for p in ansible-core; do pkg_installed $p || echo $p >>"$LAB_STATE/missing"; done +pkg_installed ansible-core || pkg_install ansible-core +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'C' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +C +printf '[nodes]\nnode1\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes: devops with greg's key and sudo, and no hostinfo.txt. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'id devops >/dev/null 2>&1 || useradd -m devops + echo devops:Lab-Pass-1 | chpasswd + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops + chmod 440 /etc/sudoers.d/devops + install -d -m 700 -o devops -g devops /home/devops/.ssh + cat >/home/devops/.ssh/authorized_keys + chown devops: /home/devops/.ssh/authorized_keys + chmod 600 /home/devops/.ssh/authorized_keys + rm -f /root/hostinfo.txt' = 2048`; a + list under `when` means all of them. +- Extra vars are strings: `upgrade | default(false) | bool`. +- Run with `-v` to see `false_condition` on skipped hosts. diff --git a/labs/ex294/7-3-conditionals/check.sh b/labs/ex294/7-3-conditionals/check.sh new file mode 100644 index 0000000..12ecadd --- /dev/null +++ b/labs/ex294/7-3-conditionals/check.sh @@ -0,0 +1,88 @@ +nodes_wait 60 || true +OUT=$LAB_STATE/check.out CB=$LAB_STATE/callback +# A stdout callback that writes a play's results as one line of JSON. +mkdir -p "$CB" +cat >"$CB/lab_json.py" <<'X' +import json +from ansible.plugins.callback import CallbackBase +from ansible.playbook.handler import Handler + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'stdout' + CALLBACK_NAME = 'lab_json' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.events = [] + + def _add(self, status, result): + r = result._result + self.events.append({ + 'host': result._host.get_name(), 'task': result._task.get_name(), + 'action': result._task.action, 'status': status, + 'changed': bool(r.get('changed')), 'msg': str(r.get('msg', '')), + 'handler': isinstance(result._task, Handler)}) + + def v2_runner_on_ok(self, result): + self._add('ok', result) + + def v2_runner_on_failed(self, result, ignore_errors=False): + self._add('ignored' if ignore_errors else 'failed', result) + + def v2_runner_on_skipped(self, result): + self._add('skipped', result) + + def v2_runner_on_unreachable(self, result): + self._add('unreachable', result) + + def v2_playbook_on_stats(self, stats): + s = {h: stats.summarize(h) for h in sorted(stats.processed)} + self._display.display(json.dumps({'events': self.events, 'stats': s})) +X +chmod -R a+rX "$CB" +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" /dev/null | grep '^{"events"' >"$OUT"; [ -s "$OUT" ]; } +# msgs [HOST]: what debug tasks printed in that run (on HOST), one a line. +msgs() { python3 -c 'import json, sys +for e in json.load(open(sys.argv[1]))["events"]: + if sys.argv[2] in ("", e["host"]) and e["status"] == "ok" and e["action"].split(".")[-1] == "debug" and e["msg"]: + print(e["msg"].strip())' "$OUT" "${1:-}"; } +# events: that run's results, one a line: host, status, action, handler +# (True or False), task name and message, separated by tabs. +events() { python3 -c 'import json, sys +for e in json.load(open(sys.argv[1]))["events"]: + print("\t".join(str(e[k]).replace("\t", " ").replace("\n", " ") for k in ("host", "status", "action", "handler", "task", "msg")))' "$OUT"; } +# recap HOST KEY: a count from that run's recap (ok, changed, failures, +# unreachable, skipped, rescued, ignored). +recap() { python3 -c 'import json, sys +print(json.load(open(sys.argv[1]))["stats"].get(sys.argv[2], {}).get(sys.argv[3], -1))' "$OUT" "$1" "$2"; } +# clean: that run reached every node and nothing failed. +clean() { python3 -c 'import json, sys +s = json.load(open(sys.argv[1]))["stats"] +sys.exit(not (len(s) >= int(sys.argv[2]) and all(v["failures"] == 0 and v["unreachable"] == 0 for v in s.values())))' "$OUT" "$(nodes_names | wc -l)"; } + +task 1 "disk.yml: a message where there's a disk vdc, \"Disk vdc does not exist\" elsewhere" +need "/home/greg/ansible/disk.yml exists" '[ -f /home/greg/ansible/disk.yml ]' +need "ansible-playbook disk.yml runs on every node without failures" 'play disk.yml && clean' +need "node1, which has vdc, prints one message, and not \"does not exist\"" '[ "$(msgs node1 | wc -l)" = 1 ] && ! msgs node1 | grep -q "does not exist"' +for n in node2 node3; do + need "$n, which has no vdc, prints only \"Disk vdc does not exist\"" "[ \"\$(msgs $n)\" = 'Disk vdc does not exist' ]" +done + +task 2 "mariadb-server only on dbservers hosts with at least 2 GiB, from a play on all" +need "/home/greg/ansible/mariadb.yml exists" '[ -f /home/greg/ansible/mariadb.yml ]' +need "mariadb.yml's play targets all three nodes" 'ans ansible-playbook --list-hosts mariadb.yml | grep -q "hosts (3)"' +need "mariadb-server is installed on node2 (dbservers, 4 GiB)" 'in_node node2 rpm -q mariadb-server' +need "mariadb-server is not installed on node3 (dbservers, but 1 GiB)" '! in_node node3 rpm -q mariadb-server' +need "mariadb-server is not installed on node1 (webservers)" '! in_node node1 rpm -q mariadb-server' + +task 3 "upgrade.yml prints Upgrading only with -e upgrade=true" +need "/home/greg/ansible/upgrade.yml exists" '[ -f /home/greg/ansible/upgrade.yml ]' +need "with -e upgrade=true it runs cleanly and prints Upgrading" 'play upgrade.yml -e upgrade=true && clean && msgs | grep -q Upgrading' +need "with -e upgrade=false it runs cleanly and doesn't" 'play upgrade.yml -e upgrade=false && clean && ! msgs | grep -q Upgrading' +need "without -e it runs cleanly and doesn't" 'play upgrade.yml && clean && ! msgs | grep -q Upgrading' +rm -f "$OUT" diff --git a/labs/ex294/7-3-conditionals/lab.conf b/labs/ex294/7-3-conditionals/lab.conf new file mode 100644 index 0000000..f1d2cb9 --- /dev/null +++ b/labs/ex294/7-3-conditionals/lab.conf @@ -0,0 +1,3 @@ +TITLE="7.3 Conditionals" +CHAPTER=/ex294/7-plays-and-playbooks/7-3-conditionals/ +SYSTEMS="rhel" diff --git a/labs/ex294/7-3-conditionals/reset.sh b/labs/ex294/7-3-conditionals/reset.sh new file mode 100644 index 0000000..79f2f4b --- /dev/null +++ b/labs/ex294/7-3-conditionals/reset.sh @@ -0,0 +1,9 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove; here the hardware unit and what setup installed. +if [ -f /etc/systemd/system/le-lab-node-hw.service ]; then + systemctl disable --quiet --now le-lab-node-hw.service 2>/dev/null + rm -f /etc/systemd/system/le-lab-node-hw.service + systemctl daemon-reload +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/7-3-conditionals/setup.sh b/labs/ex294/7-3-conditionals/setup.sh new file mode 100644 index 0000000..8dd8588 --- /dev/null +++ b/labs/ex294/7-3-conditionals/setup.sh @@ -0,0 +1,80 @@ +# The control node: ansible-core, and greg with an SSH key and his project. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nnode1\n\n[dbservers]\nnode2\nnode3\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, each with devops: greg's key and passwordless sudo. +nodes_up 3 +for n in $(nodes_names); do + in_node $n 'useradd -m devops && echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i $n 'install -m 600 -o devops -g devops /dev/stdin /home/devops/.ssh/authorized_keys' "$LAB_STATE/hardware" <<'X' +node1 4096 vda:20 vdb:2 vdc:1 +node2 4096 vda:20 vdb:2 +node3 1024 vda:20 +X +cat >"$LAB_STATE/hardware.sh" <<'X' +#!/bin/bash +cd "$(dirname "$0")" || exit 1 +while read -r name mem disks; do + pid=$(podman inspect -f '{{.State.Pid}}' "$name" 2>/dev/null) + [ "${pid:-0}" -gt 0 ] || continue + for m in /proc/meminfo /sys/block; do + nsenter -t "$pid" -m findmnt -n --mountpoint "$m" >/dev/null && nsenter -t "$pid" -m umount "$m" + done + podman exec -i "$name" bash -s -- "$mem" $disks <<'Y' +d=/var/lib/le-lab-hw mem=$1; shift +rm -rf $d; mkdir -p $d/block +awk -v kb=$((mem * 1024)) ' + /^MemTotal:/ { printf "MemTotal: %8d kB\n", kb; next } + /^(MemFree|MemAvailable):/ { printf "%-16s%8d kB\n", $1, kb / 2; next } + { print }' /proc/meminfo >$d/meminfo +for x; do + b=$d/block/${x%%:*} + mkdir -p $b/device $b/queue + echo $((${x#*:} * 2097152)) >$b/size + echo 0 >$b/removable; echo 1 >$b/queue/rotational + echo 512 >$b/queue/logical_block_size; echo 512 >$b/queue/physical_block_size + echo "Virtual disk" >$b/device/model +done +Y + nsenter -t "$pid" -m mount --bind /var/lib/le-lab-hw/meminfo /proc/meminfo + nsenter -t "$pid" -m mount --bind /var/lib/le-lab-hw/block /sys/block +done /etc/systemd/system/le-lab-node-hw.service <"$CB/lab_json.py" <<'X' +import json +from ansible.plugins.callback import CallbackBase +from ansible.playbook.handler import Handler + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'stdout' + CALLBACK_NAME = 'lab_json' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.events = [] + + def _add(self, status, result): + r = result._result + self.events.append({ + 'host': result._host.get_name(), 'task': result._task.get_name(), + 'action': result._task.action, 'status': status, + 'changed': bool(r.get('changed')), 'msg': str(r.get('msg', '')), + 'handler': isinstance(result._task, Handler)}) + + def v2_runner_on_ok(self, result): + self._add('ok', result) + + def v2_runner_on_failed(self, result, ignore_errors=False): + self._add('ignored' if ignore_errors else 'failed', result) + + def v2_runner_on_skipped(self, result): + self._add('skipped', result) + + def v2_runner_on_unreachable(self, result): + self._add('unreachable', result) + + def v2_playbook_on_stats(self, stats): + s = {h: stats.summarize(h) for h in sorted(stats.processed)} + self._display.display(json.dumps({'events': self.events, 'stats': s})) +X +chmod -R a+rX "$CB" +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" /dev/null | grep '^{"events"' >"$OUT"; [ -s "$OUT" ]; } +# msgs [HOST]: what debug tasks printed in that run (on HOST), one a line. +msgs() { python3 -c 'import json, sys +for e in json.load(open(sys.argv[1]))["events"]: + if sys.argv[2] in ("", e["host"]) and e["status"] == "ok" and e["action"].split(".")[-1] == "debug" and e["msg"]: + print(e["msg"].strip())' "$OUT" "${1:-}"; } +# events: that run's results, one a line: host, status, action, handler +# (True or False), task name and message, separated by tabs. +events() { python3 -c 'import json, sys +for e in json.load(open(sys.argv[1]))["events"]: + print("\t".join(str(e[k]).replace("\t", " ").replace("\n", " ") for k in ("host", "status", "action", "handler", "task", "msg")))' "$OUT"; } +# recap HOST KEY: a count from that run's recap (ok, changed, failures, +# unreachable, skipped, rescued, ignored). +recap() { python3 -c 'import json, sys +print(json.load(open(sys.argv[1]))["stats"].get(sys.argv[2], {}).get(sys.argv[3], -1))' "$OUT" "$1" "$2"; } +# clean: that run reached every node and nothing failed. +clean() { python3 -c 'import json, sys +s = json.load(open(sys.argv[1]))["stats"] +sys.exit(not (len(s) >= int(sys.argv[2]) and all(v["failures"] == 0 and v["unreachable"] == 0 for v in s.values())))' "$OUT" "$(nodes_names | wc -l)"; } + +OUT2=$LAB_STATE/check.txt +# rescued HOST: in that run, a service task failed on HOST and a debug task +# then printed its name and message. +rescued() { events | awk -F'\t' -v h="$1" ' + $1 == h && $2 == "failed" && $3 ~ /(^|\.)(service|systemd|systemd_service)$/ { t[$5] = $6 } + $1 == h && $2 == "ok" && $3 ~ /(^|\.)debug$/ { for (n in t) if (index($6, n) && index($6, t[n])) ok = 1 } + END { exit !ok }'; } +# ran HOST: a command or shell task ran on HOST in that run. +ran() { events | awk -F'\t' -v h="$1" '$1 == h && $2 == "ok" && $3 ~ /(^|\.)(command|shell)$/ { ok = 1 } END { exit !ok }'; } + +task 1 "block.yml: start a missing service, rescue with its name and message, log in always" +need "/home/greg/ansible/block.yml exists" '[ -f /home/greg/ansible/block.yml ]' +need "ansible-playbook block.yml reaches every node with failed=0" 'play block.yml && clean' +for n in $(nodes_names); do + need "$n shows rescued=1" '[ "$(recap '$n' rescued)" -ge 1 ]' + need "on $n, a service task failed and the rescue printed its name and message" "rescued $n" + need "/var/log/block.log on $n has a line" "in_node $n 'grep -q . /var/log/block.log'" +done + +task 2 "grep.yml: no match is fine, a missing file fails" +need "/home/greg/ansible/grep.yml exists" '[ -f /home/greg/ansible/grep.yml ]' +need "ansible-playbook grep.yml runs grep on every node with failed=0" 'play grep.yml && clean && ran node1 && ran node2' +need "with -e grep_file=/etc/no-such-file it fails on every node" 'play grep.yml -e grep_file=/etc/no-such-file && [ "$(recap node1 failures)" = 1 ] && [ "$(recap node2 failures)" = 1 ]' + +task 3 "stop.yml: when node1 fails, the play stops on every host" +need "/home/greg/ansible/stop.yml exists" '[ -f /home/greg/ansible/stop.yml ]' +need "stop.yml has a task after the failing one" '[ "$(ans ansible-playbook --list-tasks stop.yml | grep -v "^ *play #" | grep -c "TAGS: ")" -ge 2 ]' +need "the run shows NO MORE HOSTS LEFT" 'ans ansible-playbook stop.yml >"$OUT2"; grep -q "NO MORE HOSTS LEFT" "$OUT2"' +need "node1 failed and node2 didn't" 'grep -Eq "^node1 +: .*failed=1" "$OUT2" && grep -Eq "^node2 +: .*failed=0" "$OUT2"' +need "no task ran on any host after that" '! sed -n "/NO MORE HOSTS LEFT/,/PLAY RECAP/p" "$OUT2" | grep -Eq "^(ok|changed|fatal|failed): "' +rm -f "$OUT" "$OUT2" diff --git a/labs/ex294/7-4-error-handling/lab.conf b/labs/ex294/7-4-error-handling/lab.conf new file mode 100644 index 0000000..fe587c4 --- /dev/null +++ b/labs/ex294/7-4-error-handling/lab.conf @@ -0,0 +1,3 @@ +TITLE="7.4 Error handling" +CHAPTER=/ex294/7-plays-and-playbooks/7-4-error-handling/ +SYSTEMS="rhel" diff --git a/labs/ex294/7-4-error-handling/reset.sh b/labs/ex294/7-4-error-handling/reset.sh new file mode 100644 index 0000000..c856c7b --- /dev/null +++ b/labs/ex294/7-4-error-handling/reset.sh @@ -0,0 +1,4 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove; here what setup installed. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/7-4-error-handling/setup.sh b/labs/ex294/7-4-error-handling/setup.sh new file mode 100644 index 0000000..aecac4a --- /dev/null +++ b/labs/ex294/7-4-error-handling/setup.sh @@ -0,0 +1,26 @@ +# The control node: ansible-core, and greg with an SSH key and his project. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[nodes]\nnode1\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, each with devops: greg's key and passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node $n 'useradd -m devops && echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i $n 'install -m 600 -o devops -g devops /dev/stdin /home/devops/.ssh/authorized_keys' "$CB/lab_json.py" <<'X' +import json +from ansible.plugins.callback import CallbackBase +from ansible.playbook.handler import Handler + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'stdout' + CALLBACK_NAME = 'lab_json' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.events = [] + + def _add(self, status, result): + r = result._result + self.events.append({ + 'host': result._host.get_name(), 'task': result._task.get_name(), + 'action': result._task.action, 'status': status, + 'changed': bool(r.get('changed')), 'msg': str(r.get('msg', '')), + 'handler': isinstance(result._task, Handler)}) + + def v2_runner_on_ok(self, result): + self._add('ok', result) + + def v2_runner_on_failed(self, result, ignore_errors=False): + self._add('ignored' if ignore_errors else 'failed', result) + + def v2_runner_on_skipped(self, result): + self._add('skipped', result) + + def v2_runner_on_unreachable(self, result): + self._add('unreachable', result) + + def v2_playbook_on_stats(self, stats): + s = {h: stats.summarize(h) for h in sorted(stats.processed)} + self._display.display(json.dumps({'events': self.events, 'stats': s})) +X +chmod -R a+rX "$CB" +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" /dev/null | grep '^{"events"' >"$OUT"; [ -s "$OUT" ]; } +# msgs [HOST]: what debug tasks printed in that run (on HOST), one a line. +msgs() { python3 -c 'import json, sys +for e in json.load(open(sys.argv[1]))["events"]: + if sys.argv[2] in ("", e["host"]) and e["status"] == "ok" and e["action"].split(".")[-1] == "debug" and e["msg"]: + print(e["msg"].strip())' "$OUT" "${1:-}"; } +# events: that run's results, one a line: host, status, action, handler +# (True or False), task name and message, separated by tabs. +events() { python3 -c 'import json, sys +for e in json.load(open(sys.argv[1]))["events"]: + print("\t".join(str(e[k]).replace("\t", " ").replace("\n", " ") for k in ("host", "status", "action", "handler", "task", "msg")))' "$OUT"; } +# recap HOST KEY: a count from that run's recap (ok, changed, failures, +# unreachable, skipped, rescued, ignored). +recap() { python3 -c 'import json, sys +print(json.load(open(sys.argv[1]))["stats"].get(sys.argv[2], {}).get(sys.argv[3], -1))' "$OUT" "$1" "$2"; } +# clean: that run reached every node and nothing failed. +clean() { python3 -c 'import json, sys +s = json.load(open(sys.argv[1]))["stats"] +sys.exit(not (len(s) >= int(sys.argv[2]) and all(v["failures"] == 0 and v["unreachable"] == 0 for v in s.values())))' "$OUT" "$(nodes_names | wc -l)"; } + +# handlers HOST: how many handlers ran on HOST in that run. +handlers() { events | awk -F'\t' -v h="$1" '$1 == h && $2 == "ok" && $4 == "True"' | wc -l; } +# header HOST: the Server header httpd on HOST sends. +header() { curl -sI -m 10 "http://$1/" | tr -d '\r' | sed -n 's/^Server: //Ip'; } +C=/etc/httpd/conf/httpd.conf + +task 1 "web.yml sets ServerTokens Prod and restarts httpd only on a change" +need "/home/greg/ansible/web.yml exists" '[ -f /home/greg/ansible/web.yml ]' +for n in $(nodes_names); do + need "$n's httpd.conf says ServerTokens Prod" "in_node $n grep -qx 'ServerTokens Prod' $C" + need "httpd on $n was restarted since: its Server header is just Apache" '[ "$(header '$n')" = Apache ]' +done +need "a second run of web.yml has changed=0 and runs no handler" 'play web.yml && clean && for n in $(nodes_names); do [ "$(recap $n changed)" = 0 ] && [ "$(handlers $n)" = 0 ] || exit 1; done' + +task 2 "listen.yml: one listen topic runs two handlers" +need "/home/greg/ansible/listen.yml exists" '[ -f /home/greg/ansible/listen.yml ]' +need "its handlers use listen" 'grep -Eq "^[[:space:]]*listen:" /home/greg/ansible/listen.yml' +need "with KeepAlive On taken out of httpd.conf, listen.yml runs cleanly" 'for n in $(nodes_names); do in_node $n "sed -i /^KeepAlive/d $C"; done; play listen.yml && clean' +for n in $(nodes_names); do + need "two handlers ran on $n" '[ "$(handlers '$n')" -ge 2 ]' + need "$n's httpd.conf says KeepAlive On again" "in_node $n grep -qx 'KeepAlive On' $C" +done + +task 3 "force.yml: a later task fails, and the handler runs anyway" +need "/home/greg/ansible/force.yml exists" '[ -f /home/greg/ansible/force.yml ]' +need "with Timeout 120 taken out of httpd.conf, force.yml fails a task on every node" 'for n in $(nodes_names); do in_node $n "sed -i /^Timeout/d $C"; done; play force.yml; for n in $(nodes_names); do [ "$(recap $n failures)" -ge 1 ] || exit 1; done' +for n in $(nodes_names); do + need "the handler ran on $n all the same" '[ "$(handlers '$n')" -ge 1 ]' + need "$n's httpd.conf says Timeout 120 again" "in_node $n grep -qx 'Timeout 120' $C" +done +rm -f "$OUT" diff --git a/labs/ex294/7-5-desired-state/lab.conf b/labs/ex294/7-5-desired-state/lab.conf new file mode 100644 index 0000000..e16e902 --- /dev/null +++ b/labs/ex294/7-5-desired-state/lab.conf @@ -0,0 +1,3 @@ +TITLE="7.5 Desired state" +CHAPTER=/ex294/7-plays-and-playbooks/7-5-desired-state/ +SYSTEMS="rhel" diff --git a/labs/ex294/7-5-desired-state/reset.sh b/labs/ex294/7-5-desired-state/reset.sh new file mode 100644 index 0000000..c856c7b --- /dev/null +++ b/labs/ex294/7-5-desired-state/reset.sh @@ -0,0 +1,4 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove; here what setup installed. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/7-5-desired-state/setup.sh b/labs/ex294/7-5-desired-state/setup.sh new file mode 100644 index 0000000..6b47088 --- /dev/null +++ b/labs/ex294/7-5-desired-state/setup.sh @@ -0,0 +1,31 @@ +# The control node: ansible-core, and greg with an SSH key and his project. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nnode1\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, each with devops: greg's key and passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node $n 'useradd -m devops && echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i $n 'install -m 600 -o devops -g devops /dev/stdin /home/devops/.ssh/authorized_keys' /dev/null && systemctl enable --now --quiet httpd' +done + +# Ready to go: greg reaches every node as root. +timeout 120 runuser -l greg -c 'cd ~/ansible && ansible all -o -a id' /dev/null + rm -f /etc/systemd/system/le-lab-node-hw.service + systemctl daemon-reload +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/8-1-creating-roles/setup.sh b/labs/ex294/8-1-creating-roles/setup.sh new file mode 100644 index 0000000..7682d61 --- /dev/null +++ b/labs/ex294/8-1-creating-roles/setup.sh @@ -0,0 +1,80 @@ +# The control node: ansible-core, and greg with an SSH key and his project. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nnode1\nnode2\nnode3\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, each with devops: greg's key and passwordless sudo. +nodes_up 3 +for n in $(nodes_names); do + in_node $n 'useradd -m devops && echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i $n 'install -m 600 -o devops -g devops /dev/stdin /home/devops/.ssh/authorized_keys' "$LAB_STATE/hardware" <<'X' +node1 4096 vda:20 +node2 4096 vda:20 +node3 1024 vda:20 +X +cat >"$LAB_STATE/hardware.sh" <<'X' +#!/bin/bash +cd "$(dirname "$0")" || exit 1 +while read -r name mem disks; do + pid=$(podman inspect -f '{{.State.Pid}}' "$name" 2>/dev/null) + [ "${pid:-0}" -gt 0 ] || continue + for m in /proc/meminfo /sys/block; do + nsenter -t "$pid" -m findmnt -n --mountpoint "$m" >/dev/null && nsenter -t "$pid" -m umount "$m" + done + podman exec -i "$name" bash -s -- "$mem" $disks <<'Y' +d=/var/lib/le-lab-hw mem=$1; shift +rm -rf $d; mkdir -p $d/block +awk -v kb=$((mem * 1024)) ' + /^MemTotal:/ { printf "MemTotal: %8d kB\n", kb; next } + /^(MemFree|MemAvailable):/ { printf "%-16s%8d kB\n", $1, kb / 2; next } + { print }' /proc/meminfo >$d/meminfo +for x; do + b=$d/block/${x%%:*} + mkdir -p $b/device $b/queue + echo $((${x#*:} * 2097152)) >$b/size + echo 0 >$b/removable; echo 1 >$b/queue/rotational + echo 512 >$b/queue/logical_block_size; echo 512 >$b/queue/physical_block_size + echo "Virtual disk" >$b/device/model +done +Y + nsenter -t "$pid" -m mount --bind /var/lib/le-lab-hw/meminfo /proc/meminfo + nsenter -t "$pid" -m mount --bind /var/lib/le-lab-hw/block /sys/block +done /etc/systemd/system/le-lab-node-hw.service </dev/null | awk '/^# /{s = ($2 == "/home/greg/ansible/roles")} s && /^- /'; } +# served ROLE: http://node1/roles/ROLE.tar.gz is a tarball with the role at its top. +served() { curl -sf -m 10 "http://node1/roles/$1.tar.gz" | tar -tz 2>/dev/null | grep -Eq "^(\./)?$1/meta/main.yml$"; } +git_() { runuser -u greg -- git --git-dir=$REPO "$@"; } + +task 1 "motd and issue: tarballs on node1's web server, installed from roles/requirements.yml" +for r in motd issue; do + need "http://node1/roles/$r.tar.gz has the role $r at its top" "served $r" + need "$P/roles/requirements.yml installs $r from that URL" "grep -Eq 'http://(node1|node1\.lab\.example|10\.99\.1\.11)/roles/$r\.tar\.gz' $P/roles/requirements.yml" + need "ansible-galaxy role list -p roles lists $r in $P/roles" "listed | grep -q '^- $r,'" + need "ansible-galaxy installed it (roles/$r/meta/.galaxy_install_info)" "[ -f $P/roles/$r/meta/.galaxy_install_info ]" +done + +task 2 "chrony from the Git repository, branch devel" +need "ansible-galaxy role list -p roles shows chrony, devel" "listed | grep -qx -- '- chrony, devel'" + +task 3 "A change to chrony's devel branch, installed with --force" +need "the devel branch of $REPO has a commit the lab didn't make" '[ "$(git_ rev-parse devel)" != "$(state_get devel)" ]' +need "the installed chrony role has the files of that commit" 'T=$(mktemp -d) && git_ archive devel | tar -x -C "$T" && diff -r -x .galaxy_install_info "$T" $P/roles/chrony; r=$?; rm -rf "$T"; exit $r' diff --git a/labs/ex294/8-2-installing-roles/lab.conf b/labs/ex294/8-2-installing-roles/lab.conf new file mode 100644 index 0000000..f2ee806 --- /dev/null +++ b/labs/ex294/8-2-installing-roles/lab.conf @@ -0,0 +1,3 @@ +TITLE="8.2 Installing roles" +CHAPTER=/ex294/8-roles-and-collections/8-2-installing-roles/ +SYSTEMS="rhel" diff --git a/labs/ex294/8-2-installing-roles/reset.sh b/labs/ex294/8-2-installing-roles/reset.sh new file mode 100644 index 0000000..69c7c8d --- /dev/null +++ b/labs/ex294/8-2-installing-roles/reset.sh @@ -0,0 +1,6 @@ +# The node goes with nodes_down and greg (with his project and role +# sources) with users_remove; here the Git repository and what setup +# installed. +[ -f "$LAB_STATE/made-srv-git" ] && rm -rf /srv/git +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/8-2-installing-roles/setup.sh b/labs/ex294/8-2-installing-roles/setup.sh new file mode 100644 index 0000000..6f7a774 --- /dev/null +++ b/labs/ex294/8-2-installing-roles/setup.sh @@ -0,0 +1,84 @@ +[ -e /srv/git ] && refuse "/srv/git already exists; the lab keeps its Git repository there" +# The control node: ansible-core, and greg with an SSH key and his project. +for p in ansible-core git; do pkg_installed $p || { echo $p >>"$LAB_STATE/missing"; pkg_install $p; }; done +user_add greg +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[nodes]\nnode1\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, each with devops: greg's key and passwordless sudo. +nodes_up 1 +for n in $(nodes_names); do + in_node $n 'useradd -m devops && echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i $n 'install -m 600 -o devops -g devops /dev/stdin /home/devops/.ssh/authorized_keys' /dev/null && systemctl enable --now --quiet httpd' + +# Two roles to package, in greg's ~/role-src. +G="runuser -u greg --" +$G mkdir /home/greg/role-src +for r in motd issue; do + (cd /home/greg/role-src && $G ansible-galaxy role init $r >/dev/null) +done +cat >/home/greg/role-src/motd/tasks/main.yml <<'X' +--- +- name: Write the message of the day + ansible.builtin.copy: + content: "{{ motd_text }}\n" + dest: /etc/motd + mode: "0644" +X +printf -- '---\nmotd_text: Managed by Ansible\n' >/home/greg/role-src/motd/defaults/main.yml +cat >/home/greg/role-src/issue/tasks/main.yml <<'X' +--- +- name: Write the login banner + ansible.builtin.copy: + content: "{{ issue_text }}\n" + dest: /etc/issue.net + mode: "0644" +X +printf -- '---\nissue_text: Authorized users only\n' >/home/greg/role-src/issue/defaults/main.yml +chown -R greg: /home/greg/role-src + +# A third role in a Git repository, with the branches main and devel. +state_set made-srv-git yes +install -d -m 755 -o greg -g greg /srv/git +W=$(runuser -u greg -- mktemp -d) +( + cd "$W" + $G ansible-galaxy role init chrony >/dev/null + cat >chrony/tasks/main.yml <<'X' +--- +- name: Install chrony + ansible.builtin.dnf: + name: chrony + state: present +X + chown -R greg: chrony + cd chrony + C="git -c user.name=Lab -c user.email=lab@lab.example" + $G git init -q -b main + $G git add . + $G $C commit -q -m "The chrony role" + $G git checkout -q -b devel + printf -- '---\nchrony_pool: 2.pool.ntp.org\n' >defaults/main.yml + $G $C commit -q -a -m "A default pool" + $G git clone -q --bare . /srv/git/chrony-role.git +) +rm -rf "$W" +state_set devel "$(runuser -u greg -- git --git-dir=/srv/git/chrony-role.git rev-parse devel)" + +# Ready to go: greg reaches node1 as root. +timeout 120 runuser -l greg -c 'cd ~/ansible && ansible all -o -a id' /dev/null | grep -qx MANIFEST.json' +need "ansible.posix is installed in $P/collections" '[ -f $P/collections/ansible_collections/ansible/posix/MANIFEST.json ]' +need "ansible-galaxy collection list shows it under the project's collections directory" 'ans ansible-galaxy collection list ansible.posix 2>/dev/null | grep -qx "# $P/collections/ansible_collections"' + +task 2 "community.general for this ansible-core, and ini.yml using ini_file" +need "ansible-doc community.general.ini_file works" 'ans ansible-doc community.general.ini_file >"$OUT" 2>&1 && grep -q "^> COMMUNITY.GENERAL.INI_FILE" "$OUT"' +need "the installed community.general supports this ansible-core (no \"does not support\" warning)" '! grep -q "does not support Ansible version" "$OUT"' +need "/home/greg/ansible/ini.yml exists and uses community.general" 'grep -q "community\.general" $P/ini.yml' +need "ansible-playbook ini.yml runs without failures" 'ans ansible-playbook ini.yml >"$OUT" 2>&1; grep -Eq "^node1 +: ok=" "$OUT" && ! grep -E " : ok=" "$OUT" | grep -Eq "(unreachable|failed)=[1-9]"' +need "node1's /etc/lab.ini has port = 8080 in [web]" "in_node node1 python3 -c 'import configparser, sys; c = configparser.ConfigParser(); c.read(\"/etc/lab.ini\"); sys.exit(c.get(\"web\", \"port\", fallback=\"\") != \"8080\")'" + +task 3 "community.crypto installed with -p outside collections_path" +need "community.crypto is installed in /home/greg/elsewhere" '[ -f /home/greg/elsewhere/ansible_collections/community/crypto/MANIFEST.json ]' +need "Ansible can't find its modules: ansible-doc community.crypto.openssl_privatekey fails" '! ans ansible-doc community.crypto.openssl_privatekey 2>/dev/null | grep -q "^> COMMUNITY.CRYPTO.OPENSSL_PRIVATEKEY"' +rm -f "$OUT" diff --git a/labs/ex294/8-3-installing-collections/lab.conf b/labs/ex294/8-3-installing-collections/lab.conf new file mode 100644 index 0000000..fd7cddb --- /dev/null +++ b/labs/ex294/8-3-installing-collections/lab.conf @@ -0,0 +1,3 @@ +TITLE="8.3 Installing collections" +CHAPTER=/ex294/8-roles-and-collections/8-3-installing-collections/ +SYSTEMS="rhel" diff --git a/labs/ex294/8-3-installing-collections/reset.sh b/labs/ex294/8-3-installing-collections/reset.sh new file mode 100644 index 0000000..390f650 --- /dev/null +++ b/labs/ex294/8-3-installing-collections/reset.sh @@ -0,0 +1,4 @@ +# The node goes with nodes_down and greg (with his project) with +# users_remove (his collections too); here what setup installed. +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/8-3-installing-collections/setup.sh b/labs/ex294/8-3-installing-collections/setup.sh new file mode 100644 index 0000000..bd4b8cf --- /dev/null +++ b/labs/ex294/8-3-installing-collections/setup.sh @@ -0,0 +1,29 @@ +# The control node: ansible-core, and greg with an SSH key and his project. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[nodes]\nnode1\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, each with devops: greg's key and passwordless sudo. +nodes_up 1 +for n in $(nodes_names); do + in_node $n 'useradd -m devops && echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops && install -d -m 700 -o devops -g devops /home/devops/.ssh' + in_node -i $n 'install -m 600 -o devops -g devops /dev/stdin /home/devops/.ssh/authorized_keys' /dev/null && systemctl enable --now --quiet httpd' + +# Ready to go: greg reaches node1 as root. +timeout 120 runuser -l greg -c 'cd ~/ansible && ansible all -o -a id' /dev/null | grep -c '^[\^=]')" -gt 0 ] && return 0; sleep 2; done; return 1; } + +task 1 "The timesync role: a pool, chronyd using it, and no changes on a second run" +need "/home/greg/ansible/timesync.yml exists" '[ -f $P/timesync.yml ]' +need "/etc/chrony.conf was written by the timesync role (Ansible managed)" 'grep -q "Ansible managed" /etc/chrony.conf' +need "/etc/chrony.conf has a pool line" 'grep -Eq "^pool [^ ]+" /etc/chrony.conf' +need "chronyd is running" active chronyd +need "chronyc sources lists servers" sources +need "a second run of timesync.yml reports changed=0" 'ans ansible-playbook timesync.yml >"$OUT" 2>&1; grep -Eq "^localhost +: ok=[0-9]+ +changed=0 +unreachable=0 +failed=0" "$OUT"' + +task 2 "The selinux role: enforcing, and httpd_can_network_connect on for good" +need "/home/greg/ansible/selinux.yml exists" '[ -f $P/selinux.yml ]' +need "getenforce says Enforcing" '[ "$(getenforce)" = Enforcing ]' +need "/etc/selinux/config says SELINUX=enforcing" 'grep -qx "SELINUX=enforcing" /etc/selinux/config' +need "getsebool httpd_can_network_connect says on" 'getsebool httpd_can_network_connect | grep -q "on$"' +need "the boolean is set persistently" 'grep -qx "httpd_can_network_connect=1" /var/lib/selinux/targeted/active/booleans.local' + +task 3 "The storage role's variable for a logical volume's size" +need "the answer is in /root/lab-answers/3" '[ -s /root/lab-answers/3 ]' +need "that's the right variable" 'answer 3 | grep -Eq "(^|[.[:space:]])size$"' +rm -f "$OUT" diff --git a/labs/ex294/8-4-system-roles/lab.conf b/labs/ex294/8-4-system-roles/lab.conf new file mode 100644 index 0000000..800a285 --- /dev/null +++ b/labs/ex294/8-4-system-roles/lab.conf @@ -0,0 +1,3 @@ +TITLE="8.4 System roles" +CHAPTER=/ex294/8-roles-and-collections/8-4-system-roles/ +SYSTEMS="rhel" diff --git a/labs/ex294/8-4-system-roles/reset.sh b/labs/ex294/8-4-system-roles/reset.sh new file mode 100644 index 0000000..dcf8337 --- /dev/null +++ b/labs/ex294/8-4-system-roles/reset.sh @@ -0,0 +1,19 @@ +# Put back chrony and SELinux as they were, then remove what was installed +# since setup. greg goes with users_remove. +if [ -f "$LAB_STATE/chrony.conf" ]; then + cp -a "$LAB_STATE/chrony.conf" /etc/chrony.conf + systemctl try-restart chronyd 2>/dev/null +fi +if [ -f "$LAB_STATE/selinux-config" ]; then + cp -a "$LAB_STATE/selinux-config" /etc/selinux/config + [ "$(state_get enforce)" = Enforcing ] && setenforce 1 + [ "$(state_get enforce)" = Permissive ] && setenforce 0 + b=$(state_get boolean) + [ -n "$b" ] && setsebool -P httpd_can_network_connect "$b" +fi +rm -f /etc/sudoers.d/le-lab-greg +if [ -f "$LAB_STATE/rpms" ]; then + new=$(rpm -qa --qf '%{NAME}\n' | sort -u | comm -13 "$LAB_STATE/rpms" -) + [ -n "$new" ] && dnf -y -q remove $new >/dev/null 2>&1 +fi +true diff --git a/labs/ex294/8-4-system-roles/setup.sh b/labs/ex294/8-4-system-roles/setup.sh new file mode 100644 index 0000000..6ffb0b7 --- /dev/null +++ b/labs/ex294/8-4-system-roles/setup.sh @@ -0,0 +1,36 @@ +# Time and SELinux can't be managed in a container node (they belong to +# the VM's kernel), so this lab manages the control node itself. +command -v getenforce >/dev/null && [ "$(getenforce)" != Disabled ] || + refuse "SELinux is disabled on this VM; turning it on needs a relabel and a reboot" +[ -f /etc/chrony.conf ] || refuse "/etc/chrony.conf is missing; install chrony first" + +# Everything installed from here on (by setup or by the roles) goes again +# at reset. +rpm -qa --qf '%{NAME}\n' | sort -u >"$LAB_STATE/rpms" +for p in ansible-core rhel-system-roles; do pkg_installed $p || pkg_install $p; done + +# What the tasks change, as it was. +cp -a /etc/chrony.conf "$LAB_STATE/chrony.conf" +cp -a /etc/selinux/config "$LAB_STATE/selinux-config" +state_set enforce "$(getenforce)" +state_set boolean "$(getsebool httpd_can_network_connect | awk '{print $3}')" + +# The starting point: SELinux permissive, the boolean off. +sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config +setenforce 0 +setsebool -P httpd_can_network_connect off + +# greg's project; he may use sudo, for become on localhost. +user_add greg +printf 'greg ALL=(ALL) NOPASSWD: ALL\n' >/etc/sudoers.d/le-lab-greg +chmod 440 /etc/sudoers.d/le-lab-greg +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory + +[privilege_escalation] +become = true +X +printf '[webservers]\nlocalhost ansible_connection=local\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory diff --git a/labs/ex294/9-1-packages-and-repositories/README.md b/labs/ex294/9-1-packages-and-repositories/README.md new file mode 100644 index 0000000..7b3e037 --- /dev/null +++ b/labs/ex294/9-1-packages-and-repositories/README.md @@ -0,0 +1,45 @@ +# EX294 9.1: Packages and repositories + +Give the managed nodes their repositories, install packages and a group on +the right hosts, and bring the web servers up to date. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core`. Work as greg, +in `/home/greg/ansible`: its `ansible.cfg` reads the inventory `inventory`, +logs in as `devops` and becomes root. `node1` is in the group `webservers` +and `node2` in `dbservers`. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers, prepared as in chapter 3.1: `devops` takes greg's +key and may use sudo without a password, so `ansible all -m ping` works +straight away. The lab has taken away every repository on both nodes +(the old files are in `/root/repos.orig`), and left one package on node1 +a version behind. For a root shell on a node, `sudo podman exec -it node1 +bash`. + +## Tasks + +1. Configure BaseOS and AppStream repositories on every node, with + signature checking, from URLs you choose (the AlmaLinux 10 mirrors at + `https://repo.almalinux.org/almalinux/10/`, for example). `dnf + repolist` on each node lists both, and `dnf install` works. +2. In one playbook, install a package group on the database servers, and + `php` and `mariadb` on the web servers. +3. Upgrade every package on the web servers, so `dnf check-update` there + finds nothing to update. + +## Hints + +- `ansible.builtin.yum_repository` with `baseurl`, `gpgcheck: true` and + `gpgkey`; `ansible.builtin.rpm_key` imports the key. The key is at + `https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-10`. + `rpm_key` needs `gpg2`; the lab installed `gnupg2` on the nodes before + it took their repositories away. +- A group is `name: "@Group Name"` in `ansible.builtin.dnf`; `dnf group + list --hidden` on a node shows the names. A group of only optional + packages installs nothing. +- `name: "*"` with `state: latest` updates everything. +- Check with `ansible all -a 'dnf repolist'` and `ansible webservers -a + 'rpm -q php mariadb'`. diff --git a/labs/ex294/9-1-packages-and-repositories/check.sh b/labs/ex294/9-1-packages-and-repositories/check.sh new file mode 100644 index 0000000..52cb999 --- /dev/null +++ b/labs/ex294/9-1-packages-and-repositories/check.sh @@ -0,0 +1,31 @@ +nodes_wait 60 || true +# repos NODE: an enabled BaseOS and an enabled AppStream repository, both +# with signature checking (dnf's own reading of the configuration). +repos() { + in_node "$1" "python3 -c ' +import sys, dnf +b = dnf.Base(); b.conf.read(); b.read_all_repos() +found = set() +for r in b.repos.iter_enabled(): + where = \" \".join(list(r.baseurl) + [r.mirrorlist or \"\", r.metalink or \"\"]).lower() + for k in (\"baseos\", \"appstream\"): + if k in where and r.gpgcheck: + found.add(k) +sys.exit(found != {\"baseos\", \"appstream\"})'" +} + +task 1 "BaseOS and AppStream repositories with signature checking on every node" +for n in $(nodes_names); do + need "$n has enabled BaseOS and AppStream repositories with gpgcheck on" repos $n + need "dnf on $n finds packages in them (dnf repoquery zip)" "in_node $n 'dnf -q repoquery --available zip' | grep -q zip" +done + +task 2 "A package group on the database servers, php and mariadb on the web servers" +need "node2 has a package group installed (dnf group list --installed)" "in_node node2 'dnf -q group list --installed' | grep -q '^ '" +need "php and mariadb are installed on node1" 'in_node node1 rpm -q php mariadb' + +task 3 "Every package on the web servers is up to date" +OLD=$(state_get old-pkg) +need "node1 has its repositories (task 1)" repos node1 +need "$OLD on node1 has been upgraded" "[ \"\$(in_node node1 rpm -q $OLD)\" != \"$(state_get old-ver)\" ]" +need "dnf check-update on node1 finds nothing to update" 'in_node node1 dnf -q check-update' diff --git a/labs/ex294/9-1-packages-and-repositories/lab.conf b/labs/ex294/9-1-packages-and-repositories/lab.conf new file mode 100644 index 0000000..b629395 --- /dev/null +++ b/labs/ex294/9-1-packages-and-repositories/lab.conf @@ -0,0 +1,3 @@ +TITLE="9.1 Packages and repositories" +CHAPTER=/ex294/9-automating-rhcsa-tasks/9-1-packages-and-repositories/ +SYSTEMS="rhel" diff --git a/labs/ex294/9-1-packages-and-repositories/reset.sh b/labs/ex294/9-1-packages-and-repositories/reset.sh new file mode 100644 index 0000000..6caf66b --- /dev/null +++ b/labs/ex294/9-1-packages-and-repositories/reset.sh @@ -0,0 +1,8 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove; here, what the tasks and then setup installed on this VM. +if [ -f "$LAB_STATE/rpms" ]; then + new=$(comm -13 "$LAB_STATE/rpms" <(rpm -qa --qf '%{NAME}\n' | sort -u)) + [ -z "$new" ] || dnf -y -q remove $new >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/9-1-packages-and-repositories/setup.sh b/labs/ex294/9-1-packages-and-repositories/setup.sh new file mode 100644 index 0000000..eca9417 --- /dev/null +++ b/labs/ex294/9-1-packages-and-repositories/setup.sh @@ -0,0 +1,46 @@ +# The control node: ansible-core and greg's project, with a key the nodes +# already accept. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nnode1\n\n[dbservers]\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, prepared as in chapter 3.1: devops with greg's key and +# passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'useradd -m devops && install -d -m 700 -o devops -g devops /home/devops/.ssh && + cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && + chmod 600 /home/devops/.ssh/authorized_keys && + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops' \ + "$LAB_STATE/rpms" + +# node1 gets a package one version behind (task 3). +old='' +for p in tar sudo 'openssh openssh-server openssh-clients'; do + if in_node node1 "dnf -y -q downgrade $p" >/dev/null 2>&1; then old=${p%% *}; break; fi +done +[ -n "$old" ] || die "could not put an older package on node1; run le-lab reset and try again" +state_set old-pkg "$old" +state_set old-ver "$(in_node node1 rpm -q "$old")" + +# Neither node has any repositories (task 1). gnupg2 first, which a full +# system has and rpm_key needs. +for n in $(nodes_names); do + in_node "$n" 'dnf -y -q install gnupg2 && mkdir -p /root/repos.orig && mv /etc/yum.repos.d/*.repo /root/repos.orig/ && dnf -q clean all' >/dev/null +done diff --git a/labs/ex294/9-2-services/README.md b/labs/ex294/9-2-services/README.md new file mode 100644 index 0000000..fc281b7 --- /dev/null +++ b/labs/ex294/9-2-services/README.md @@ -0,0 +1,48 @@ +# EX294 9.2: Services + +Start, enable, stop and mask services, deploy a unit of your own, and set +a default target, all so that a second run changes nothing. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`, sudo without a password) with an SSH key, and installs +`ansible-core`. Work as greg, in `/home/greg/ansible`: its `ansible.cfg` +reads the inventory `inventory`, logs in as `devops` and becomes root. +`node1` is in the group `webservers`, `node2` in `dbservers`, and this VM +itself in `control`, as `localhost ansible_connection=local`. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers, prepared as in chapter 3.1: `devops` takes greg's +key and may use sudo without a password. `rpcbind` runs on both. They +share this VM's clock, so `chronyd` can be enabled there but never runs +(systemd skips it); it runs here, on the control node, where the lab has +stopped and disabled it. For a root shell on a node, `sudo podman exec -it +node1 bash`. + +## Tasks + +1. Make sure `chronyd` is running and enabled on the control node and + enabled on node1 and node2, and `rpcbind` stopped and masked on the + nodes. `systemctl is-enabled chronyd rpcbind` on a node shows `enabled` + and `masked`, also after a reboot. +2. Write `/home/greg/ansible/hello.yml` to deploy a oneshot unit + `hello.service` on every node that writes `hello from systemd` to the + journal, and enable and start it. `journalctl` shows the line, and a + second run reports `changed=0`. +3. Write `/home/greg/ansible/target.yml` to set the default target of the + database server to `multi-user.target`. `systemctl get-default` + agrees, and the task is `ok` on a second run. + +## Hints + +- `ansible.builtin.service` with `state: started` and `enabled: true`; + `ansible.builtin.systemd_service` with `masked: true`. Install `chrony` + on the nodes first. On the nodes, `state: started` for chronyd reports + `changed` every time, because systemd skips it there. +- A oneshot unit that should count as started needs `RemainAfterExit=yes`, + and `daemon_reload: true` before it starts. +- `systemctl get-default` in a `command` task with `changed_when: false`, + or `ansible.builtin.file` with `state: link` on + `/etc/systemd/system/default.target`. +- Check with `ansible all -a 'systemctl is-enabled chronyd rpcbind'`. diff --git a/labs/ex294/9-2-services/check.sh b/labs/ex294/9-2-services/check.sh new file mode 100644 index 0000000..9f94968 --- /dev/null +++ b/labs/ex294/9-2-services/check.sh @@ -0,0 +1,34 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" "$OUT"; unchanged node1 node2' + +task 3 "node2 boots to multi-user.target; target.yml changes nothing on a second run" +need "systemctl get-default on node2 shows multi-user.target" '[ "$(in_node node2 systemctl get-default)" = multi-user.target ]' +need "/home/greg/ansible/target.yml exists" '[ -f /home/greg/ansible/target.yml ]' +need "ansible-playbook target.yml reports changed=0 on node2" 'ans ansible-playbook target.yml >"$OUT"; unchanged node2' +rm -f "$OUT" diff --git a/labs/ex294/9-2-services/lab.conf b/labs/ex294/9-2-services/lab.conf new file mode 100644 index 0000000..e9c40b1 --- /dev/null +++ b/labs/ex294/9-2-services/lab.conf @@ -0,0 +1,3 @@ +TITLE="9.2 Services" +CHAPTER=/ex294/9-automating-rhcsa-tasks/9-2-services/ +SYSTEMS="rhel" diff --git a/labs/ex294/9-2-services/reset.sh b/labs/ex294/9-2-services/reset.sh new file mode 100644 index 0000000..b5a9f4b --- /dev/null +++ b/labs/ex294/9-2-services/reset.sh @@ -0,0 +1,24 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove. Here: this VM's units as setup found them, then what the +# tasks and setup installed. +rm -f /etc/sudoers.d/le-lab-greg +if [ ! -f "$LAB_STATE/had-hello" ] && [ -f /etc/systemd/system/hello.service ]; then + systemctl disable --now --quiet hello.service 2>/dev/null + rm -f /etc/systemd/system/hello.service +fi +for u in rpcbind.socket rpcbind chronyd; do + s=$(state_get "unit-$u") || continue + [ -n "$s" ] || continue + systemctl unmask --quiet "$u" 2>/dev/null + case $s in enabled*) systemctl enable --quiet "$u" 2>/dev/null ;; disabled*) systemctl disable --quiet "$u" 2>/dev/null ;; esac + case $s in *" active") systemctl start "$u" 2>/dev/null ;; esac +done +t=$(state_get default-target) +[ -z "$t" ] || [ "$(systemctl get-default)" = "$t" ] || systemctl set-default --quiet "$t" 2>/dev/null +systemctl daemon-reload +if [ -f "$LAB_STATE/rpms" ]; then + new=$(comm -13 "$LAB_STATE/rpms" <(rpm -qa --qf '%{NAME}\n' | sort -u)) + [ -z "$new" ] || dnf -y -q remove $new >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/9-2-services/setup.sh b/labs/ex294/9-2-services/setup.sh new file mode 100644 index 0000000..55b8b79 --- /dev/null +++ b/labs/ex294/9-2-services/setup.sh @@ -0,0 +1,57 @@ +# The control node: ansible-core and greg's project, with a key the nodes +# already accept, and sudo without a password for the localhost plays. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +printf 'greg ALL=(ALL) NOPASSWD: ALL\n' >/etc/sudoers.d/le-lab-greg +chmod 440 /etc/sudoers.d/le-lab-greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +cat >/home/greg/ansible/inventory <<'X' +[webservers] +node1 + +[dbservers] +node2 + +[control] +localhost ansible_connection=local +X +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, prepared as in chapter 3.1: devops with greg's key and +# passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'useradd -m devops && install -d -m 700 -o devops -g devops /home/devops/.ssh && + cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && + chmod 600 /home/devops/.ssh/authorized_keys && + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops' \ + "$LAB_STATE/rpms" + +# This VM's own settings the tasks may change, for reset. +for u in chronyd rpcbind rpcbind.socket; do + state_set "unit-$u" "$(systemctl is-enabled "$u" 2>/dev/null) $(systemctl is-active "$u" 2>/dev/null)" +done +state_set default-target "$(systemctl get-default)" +[ -e /etc/systemd/system/hello.service ] && touch "$LAB_STATE/had-hello" + +# Task 1: chronyd stopped here, rpcbind running on the nodes. +systemctl disable --now --quiet chronyd 2>/dev/null || true +for n in $(nodes_names); do + in_node "$n" 'dnf -y -q install rpcbind && systemctl enable --now --quiet rpcbind' >/dev/null +done +# Task 3: node2 boots to graphical.target. +in_node node2 systemctl set-default graphical.target >/dev/null 2>&1 diff --git a/labs/ex294/9-3-firewall-rules/README.md b/labs/ex294/9-3-firewall-rules/README.md new file mode 100644 index 0000000..7850afb --- /dev/null +++ b/labs/ex294/9-3-firewall-rules/README.md @@ -0,0 +1,43 @@ +# EX294 9.3: Firewall rules + +Open services and ports on the managed nodes with +`ansible.posix.firewalld`, see the difference between permanent and +immediate, and do the same with the firewall system role. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core`. Work as greg, +in `/home/greg/ansible`: its `ansible.cfg` reads the inventory `inventory`, +logs in as `devops` and becomes root. `node1` is in the group `webservers` +and `node2` in `dbservers`. The `ansible.posix` collection and the system +roles aren't installed yet. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers, prepared as in chapter 3.1: `devops` takes greg's +key and may use sudo without a password. Both run firewalld, and node1 +runs a web server that the firewall blocks for now. For a root shell on a +node, `sudo podman exec -it node1 bash`. + +## Tasks + +1. Open HTTP and port 8080/tcp permanently and immediately on the web + servers. `curl http://node1/` works from this VM, and `firewall-cmd + --permanent --list-all` on node1 lists both. +2. Add port 9090/tcp on the web servers with `permanent: true` only, and + find it: `--list-ports` doesn't show it, `--permanent --list-ports` + does. Then reload firewalld there, after which both do. +3. Use the firewall system role to allow `mysql` on the database servers. + `firewall-cmd --list-services` on node2 includes `mysql`. + +## Hints + +- `ansible-galaxy collection install ansible.posix` for + `ansible.posix.firewalld`: `service` or `port`, `permanent`, + `immediate`, `state: enabled`. +- `ansible webservers -a 'firewall-cmd --reload'`, or a + `systemd_service` task with `state: reloaded`. +- The system roles come from the `rhel-system-roles` package on the + control node; the role is `redhat.rhel_system_roles.firewall`, with the + rules in the variable `firewall`. Its README is in + `/usr/share/doc/rhel-system-roles/firewall/`. diff --git a/labs/ex294/9-3-firewall-rules/check.sh b/labs/ex294/9-3-firewall-rules/check.sh new file mode 100644 index 0000000..1a1da9a --- /dev/null +++ b/labs/ex294/9-3-firewall-rules/check.sh @@ -0,0 +1,29 @@ +nodes_wait 60 || true +# fw NODE WHAT ITEM: ITEM is in NODE's firewall-cmd --list-WHAT (runtime) and +# --permanent --list-WHAT. +fw() { + in_node "$1" firewall-cmd --list-"$2" | tr ' ' '\n' | grep -qx "$3" && + in_node "$1" firewall-cmd --permanent --list-"$2" | tr ' ' '\n' | grep -qx "$3" +} +# web: the page on node1 answers from here, polling for a few seconds. +web() { + local i + for i in 1 2 3 4 5; do + curl -s -m 3 http://node1/ | grep -q 'node1 web page' && return 0 + sleep 2 + done + return 1 +} + +task 1 "HTTP and 8080/tcp open on the web servers, now and permanently" +need "firewalld is running on node1" 'in_node node1 systemctl is-active --quiet firewalld' +need "curl http://node1/ gets the page from this VM" web +need "node1 allows the http service, now and permanently" fw node1 services http +need "node1 allows 8080/tcp, now and permanently" fw node1 ports 8080/tcp + +task 2 "9090/tcp added with permanent only, then live after a reload" +need "node1 allows 9090/tcp, now and permanently" fw node1 ports 9090/tcp + +task 3 "mysql allowed on the database servers by the firewall system role" +need "firewalld is running on node2" 'in_node node2 systemctl is-active --quiet firewalld' +need "node2 allows the mysql service, now and permanently" fw node2 services mysql diff --git a/labs/ex294/9-3-firewall-rules/lab.conf b/labs/ex294/9-3-firewall-rules/lab.conf new file mode 100644 index 0000000..105245e --- /dev/null +++ b/labs/ex294/9-3-firewall-rules/lab.conf @@ -0,0 +1,3 @@ +TITLE="9.3 Firewall rules" +CHAPTER=/ex294/9-automating-rhcsa-tasks/9-3-firewall-rules/ +SYSTEMS="rhel" diff --git a/labs/ex294/9-3-firewall-rules/reset.sh b/labs/ex294/9-3-firewall-rules/reset.sh new file mode 100644 index 0000000..6caf66b --- /dev/null +++ b/labs/ex294/9-3-firewall-rules/reset.sh @@ -0,0 +1,8 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove; here, what the tasks and then setup installed on this VM. +if [ -f "$LAB_STATE/rpms" ]; then + new=$(comm -13 "$LAB_STATE/rpms" <(rpm -qa --qf '%{NAME}\n' | sort -u)) + [ -z "$new" ] || dnf -y -q remove $new >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/9-3-firewall-rules/setup.sh b/labs/ex294/9-3-firewall-rules/setup.sh new file mode 100644 index 0000000..35973ff --- /dev/null +++ b/labs/ex294/9-3-firewall-rules/setup.sh @@ -0,0 +1,38 @@ +# The control node: ansible-core and greg's project, with a key the nodes +# already accept. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nnode1\n\n[dbservers]\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, prepared as in chapter 3.1: devops with greg's key and +# passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'useradd -m devops && install -d -m 700 -o devops -g devops /home/devops/.ssh && + cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && + chmod 600 /home/devops/.ssh/authorized_keys && + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops' \ + "$LAB_STATE/rpms" + +# firewalld on both nodes, and a web server behind it on node1. +for n in $(nodes_names); do + in_node "$n" 'dnf -y -q install firewalld && systemctl enable --now --quiet firewalld' >/dev/null +done +in_node node1 'dnf -y -q install httpd && echo "node1 web page" >/var/www/html/index.html && + systemctl enable --now --quiet httpd' >/dev/null diff --git a/labs/ex294/9-4-storage-and-file-systems/README.md b/labs/ex294/9-4-storage-and-file-systems/README.md new file mode 100644 index 0000000..73e784e --- /dev/null +++ b/labs/ex294/9-4-storage-and-file-systems/README.md @@ -0,0 +1,58 @@ +# EX294 9.4: Storage and file systems + +Partition a disk, make file systems and mount them for good, size a +logical volume to what each host has room for, and let the storage system +role do the same from variables. + +## Before you start + +This VM is the control node, and here also the host with the disks: +`localhost ansible_connection=local`, in the inventory group `storage`. +The lab creates the user `greg` (password `Lab-Pass-1`, sudo without a +password) with an SSH key, and installs `ansible-core`. Work as greg, in +`/home/greg/ansible`: its `ansible.cfg` reads the inventory `inventory`, +logs in as `devops` and becomes root. The `ansible.posix` and +`community.general` collections and the system roles aren't installed +yet. + +Spare disks on this VM, where the chapter has `vdb` and friends: + +- `/dev/loop40`, 2 GiB, for task 1 +- `/dev/loop41`, 1 GiB, for task 2 +- `/dev/loop42`, 2 GiB, for task 3 + +The managed node `node1` (10.99.1.11, group `nodes`) is an AlmaLinux 10 +container, prepared as in chapter 3.1: `devops` takes greg's key and may +use sudo without a password. A container has no disks of its own, so it +plays the host without the volume group. For a root shell there, `sudo +podman exec -it node1 bash`. + +## Tasks + +1. On the storage hosts, create a 1 GiB partition on `/dev/loop40`, put + XFS on it, and mount it at `/srv/archive` persistently. `findmnt + /srv/archive` shows it, also after a reboot. +2. Create the volume group `research` on `/dev/loop41` on the storage + hosts. Then write `/home/greg/ansible/lv.yml` for all hosts: a 1500 MiB + logical volume `data` in `research` where it fits, an 800 MiB one with + the message "Could not create logical volume of that size" where it + doesn't, ext4 on it mounted at `/mnt/data`, and the message "Volume + group does not exist" on hosts without `research`. Each host ends with + the right size or message, and `lvs research` agrees. +3. Use the storage system role to make a 1 GiB XFS logical volume on + `/dev/loop42`, mounted at `/srv/web`. Check with `lsblk` and + `/etc/fstab`. + +## Hints + +- `ansible-galaxy collection install ansible.posix + 'community.general:>=11,<12'`: community.general 12 and later need a + newer ansible-core than this one. +- `community.general.parted` (needs the `parted` package here), + `community.general.filesystem`, `ansible.posix.mount` with `state: + mounted`. A partition of `/dev/loop40` is `/dev/loop40p1`. +- `community.general.lvg` and `lvol`; `block` and `rescue` for the + smaller volume; `ansible_facts['lvm']['vgs']` says which hosts have + `research`. +- The system roles come from the `rhel-system-roles` package; in + `storage_pools`, a disk is named like `loop42`. diff --git a/labs/ex294/9-4-storage-and-file-systems/check.sh b/labs/ex294/9-4-storage-and-file-systems/check.sh new file mode 100644 index 0000000..db68622 --- /dev/null +++ b/labs/ex294/9-4-storage-and-file-systems/check.sh @@ -0,0 +1,38 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" /dev/null; } +# in_fstab PATH: /etc/fstab mounts something at PATH. +in_fstab() { awk -v m="$1" '$1 !~ /^#/ && $2 == m' /etc/fstab | grep -q .; } +# mib DEV: the size of DEV in MiB. +mib() { echo $(( $(lsblk -bdno SIZE "$1") / 1048576 )); } +# The size the logical volume should have: 1500 MiB where research has +# room for it, 800 MiB where it doesn't. +VG=$(vgs --noheadings --units m --nosuffix -o vg_size research 2>/dev/null | awk '{printf "%d", $1}') +WANT=800; [ "${VG:-0}" -ge 1500 ] && WANT=1500 + +task 1 "A 1 GiB XFS partition on /dev/loop40, mounted at /srv/archive persistently" +need "/dev/loop40p1 is about 1 GiB" '[ -b /dev/loop40p1 ] && s=$(mib /dev/loop40p1) && [ "$s" -ge 1000 ] && [ "$s" -le 1030 ]' +need "/dev/loop40p1 is mounted at /srv/archive" '[ "$(mnt /srv/archive SOURCE)" = /dev/loop40p1 ]' +need "/srv/archive is XFS" '[ "$(mnt /srv/archive FSTYPE)" = xfs ]' +need "/etc/fstab mounts /srv/archive" 'in_fstab /srv/archive' + +task 2 "Volume group research, and lv.yml: 1500 MiB or 800 MiB, or a message" +need "volume group research is on /dev/loop41" 'pvs --noheadings -o vg_name /dev/loop41 2>/dev/null | grep -qw research' +need "research/data is $WANT MiB" '[ -b /dev/research/data ] && [ "$(mib /dev/research/data)" = "$WANT" ]' +need "research/data is mounted at /mnt/data as ext4" '[ "$(mnt /mnt/data FSTYPE)" = ext4 ] && [ "$(readlink -f "$(mnt /mnt/data SOURCE)")" = "$(readlink -f /dev/research/data)" ]' +need "/etc/fstab mounts /mnt/data" 'in_fstab /mnt/data' +need "/home/greg/ansible/lv.yml exists" '[ -f /home/greg/ansible/lv.yml ]' +need "ansible-playbook lv.yml runs without failures and says \"Volume group does not exist\" for node1" \ + 'ans ansible-playbook lv.yml >"$OUT"; grep -Eq "^node1 +: ok=.* failed=0" "$OUT" && grep -Eq "^localhost +: ok=.* failed=0" "$OUT" && + grep -A1 "^ok: \[node1\] => {" "$OUT" | grep -q "Volume group does not exist"' + +task 3 "A 1 GiB XFS logical volume at /srv/web, from the storage system role" +need "/srv/web is mounted from a logical volume" '[ "$(lsblk -no TYPE "$(mnt /srv/web SOURCE)" 2>/dev/null)" = lvm ]' +need "/srv/web is XFS" '[ "$(mnt /srv/web FSTYPE)" = xfs ]' +need "its logical volume is 1 GiB" '[ "$(mib "$(mnt /srv/web SOURCE)")" = 1024 ]' +need "its volume group is on /dev/loop42" 'lvs --noheadings -o devices "$(mnt /srv/web SOURCE)" 2>/dev/null | grep -q /dev/loop42' +need "/etc/fstab mounts /srv/web" 'in_fstab /srv/web' +rm -f "$OUT" diff --git a/labs/ex294/9-4-storage-and-file-systems/lab.conf b/labs/ex294/9-4-storage-and-file-systems/lab.conf new file mode 100644 index 0000000..dd84e65 --- /dev/null +++ b/labs/ex294/9-4-storage-and-file-systems/lab.conf @@ -0,0 +1,3 @@ +TITLE="9.4 Storage and file systems" +CHAPTER=/ex294/9-automating-rhcsa-tasks/9-4-storage-and-file-systems/ +SYSTEMS="rhel" diff --git a/labs/ex294/9-4-storage-and-file-systems/reset.sh b/labs/ex294/9-4-storage-and-file-systems/reset.sh new file mode 100644 index 0000000..a992e68 --- /dev/null +++ b/labs/ex294/9-4-storage-and-file-systems/reset.sh @@ -0,0 +1,18 @@ +# The nodes go with nodes_down, greg (with his project) with users_remove, +# and the spare disks (with their volume groups) with disks_remove. Here: +# the mounts, then what the tasks and setup installed on this VM. +rm -f /etc/sudoers.d/le-lab-greg +for m in /srv/web /mnt/data /srv/archive; do + umount -l "$m" 2>/dev/null + awk -v m="$m" '$1 !~ /^#/ && $2 == m' /etc/fstab | grep -q . && + awk -v m="$m" '$1 ~ /^#/ || $2 != m' /etc/fstab >/etc/fstab.le-lab && cat /etc/fstab.le-lab >/etc/fstab + rm -f /etc/fstab.le-lab + rmdir "$m" 2>/dev/null +done +systemctl daemon-reload +if [ -f "$LAB_STATE/rpms" ]; then + new=$(comm -13 "$LAB_STATE/rpms" <(rpm -qa --qf '%{NAME}\n' | sort -u)) + [ -z "$new" ] || dnf -y -q remove $new >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/9-4-storage-and-file-systems/setup.sh b/labs/ex294/9-4-storage-and-file-systems/setup.sh new file mode 100644 index 0000000..cc607c7 --- /dev/null +++ b/labs/ex294/9-4-storage-and-file-systems/setup.sh @@ -0,0 +1,53 @@ +# The mount points the tasks use must be free. +for m in /srv/archive /mnt/data /srv/web; do + if findmnt -n "$m" >/dev/null || awk -v m="$m" '$1 !~ /^#/ && $2 == m' /etc/fstab | grep -q .; then + refuse "$m is already mounted or in /etc/fstab; free it first" + fi +done +vgs research >/dev/null 2>&1 && refuse "a volume group called research already exists" +vgs vg_web >/dev/null 2>&1 && refuse "a volume group called vg_web already exists" + +# The control node: ansible-core and greg's project, with a key the nodes +# already accept, and sudo without a password for the localhost plays. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +printf 'greg ALL=(ALL) NOPASSWD: ALL\n' >/etc/sudoers.d/le-lab-greg +chmod 440 /etc/sudoers.d/le-lab-greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +cat >/home/greg/ansible/inventory <<'X' +[storage] +localhost ansible_connection=local + +[nodes] +node1 +X +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# One managed node, prepared as in chapter 3.1 (devops with greg's key and +# passwordless sudo), with the LVM tools but no disks: a host without the +# volume group (task 2). +nodes_up 1 +in_node -i node1 'useradd -m devops && install -d -m 700 -o devops -g devops /home/devops/.ssh && + cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && + chmod 600 /home/devops/.ssh/authorized_keys && + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops' \ + /dev/null +# What's installed here now; reset removes what the tasks add. +rpm -qa --qf '%{NAME}\n' | sort -u >"$LAB_STATE/rpms" + +# The control node's spare disks. +disk_add 0 2G >/dev/null +disk_add 1 1G >/dev/null +disk_add 2 2G >/dev/null diff --git a/labs/ex294/9-5-file-content-and-archives/README.md b/labs/ex294/9-5-file-content-and-archives/README.md new file mode 100644 index 0000000..1022f58 --- /dev/null +++ b/labs/ex294/9-5-file-content-and-archives/README.md @@ -0,0 +1,55 @@ +# EX294 9.5: File content and archives + +Change one line of a file whatever it says now, keep a block of lines up +to date, and archive a directory on every node and fetch the archives. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core`. Work as greg, +in `/home/greg/ansible`: its `ansible.cfg` reads the inventory `inventory`, +logs in as `devops` and becomes root. `node1` is in the group `webservers` +and `node2` in `dbservers`. The `community.general` collection isn't +installed yet. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers, prepared as in chapter 3.1: `devops` takes greg's +key and may use sudo without a password. Their `/etc/ssh/sshd_config` +files say different things about `PermitRootLogin`. (Drop-ins of the +node image in `/etc/ssh/sshd_config.d/` still allow root logins, and they +win over `sshd_config`, so `sshd -T` keeps saying `permitrootlogin yes`; +leave them.) For a root shell on a node, `sudo podman exec -it node1 +bash`. + +## Tasks + +1. Write `/home/greg/ansible/sshd.yml` to set `PermitRootLogin no` in + `/etc/ssh/sshd_config` on every node, whatever the line says now, and + restart sshd only if it changed. `grep ^PermitRootLogin` shows one + line, and a second run doesn't restart sshd. +2. Add this three-line block to `/etc/motd` on every node, between + markers: + + ``` + This node is managed by Ansible. + Changes made by hand will be lost. + Questions: ops@lab.example + ``` + + Then change the last line to `Questions: admin@lab.example` and run it + again. The file has one block, with the new line, between the markers. +3. Archive `/etc` on every node as `/root/etc.tar.bz2`, and fetch them all + to the control node, under `/home/greg/ansible/backups/`. + `backups/node1/root/etc.tar.bz2` and the other exist, and `tar tjf` + lists them. + +## Hints + +- `ansible.builtin.lineinfile` with a `regexp` that matches the line + whatever its value, `validate: /usr/sbin/sshd -t -f %s`, and a handler + for the restart. +- `ansible.builtin.blockinfile` finds its own block again by its markers. +- `ansible-galaxy collection install 'community.general:>=11,<12'` + (12 and later need a newer ansible-core) for + `community.general.archive` (`format: bz2`); `ansible.builtin.fetch` + with `dest: backups/`. `tar tjf` here needs the `bzip2` package. diff --git a/labs/ex294/9-5-file-content-and-archives/check.sh b/labs/ex294/9-5-file-content-and-archives/check.sh new file mode 100644 index 0000000..a75b7cc --- /dev/null +++ b/labs/ex294/9-5-file-content-and-archives/check.sh @@ -0,0 +1,39 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" "$OUT"; grep -Eq "^node1 +: ok=" "$OUT" && grep -Eq "^node2 +: ok=" "$OUT" && ! grep -E " : ok=" "$OUT" | grep -Eq "(changed|unreachable|failed)=[1-9]"' +need "the second run didn't restart sshd" '[ -n "$B1" ] && [ "$(sshd_start node1)" = "$B1" ] && [ "$(sshd_start node2)" = "$B2" ]' + +task 2 "One marked three-line block in /etc/motd, with the changed line" +for n in $(nodes_names); do + need "/etc/motd on $n has one BEGIN and one END marker" "[ \"\$(in_node $n grep -c BEGIN /etc/motd)\" = 1 ] && [ \"\$(in_node $n grep -c END /etc/motd)\" = 1 ]" + need "the block on $n has the three lines, with Questions: admin@lab.example" "[ \"\$(in_node $n cat /etc/motd | sed -n '/BEGIN/,/END/p' | sed '1d;\$d')\" = \"\$BLOCK\" ]" + need "/etc/motd on $n still starts with its own line" "in_node $n head -1 /etc/motd | grep -qx '$n, a practice node'" +done + +task 3 "/etc archived as /root/etc.tar.bz2 on every node, fetched to backups/" +for n in $(nodes_names); do + need "/root/etc.tar.bz2 on $n is a bzip2 tar file of /etc" "in_node $n python3 -c '$TARPY' /root/etc.tar.bz2" + need "/home/greg/ansible/backups/$n/root/etc.tar.bz2 is a copy of it" "python3 -c '$TARPY' /home/greg/ansible/backups/$n/root/etc.tar.bz2" +done +rm -f "$OUT" diff --git a/labs/ex294/9-5-file-content-and-archives/lab.conf b/labs/ex294/9-5-file-content-and-archives/lab.conf new file mode 100644 index 0000000..9c68f59 --- /dev/null +++ b/labs/ex294/9-5-file-content-and-archives/lab.conf @@ -0,0 +1,3 @@ +TITLE="9.5 File content and archives" +CHAPTER=/ex294/9-automating-rhcsa-tasks/9-5-file-content-and-archives/ +SYSTEMS="rhel" diff --git a/labs/ex294/9-5-file-content-and-archives/reset.sh b/labs/ex294/9-5-file-content-and-archives/reset.sh new file mode 100644 index 0000000..7f47e78 --- /dev/null +++ b/labs/ex294/9-5-file-content-and-archives/reset.sh @@ -0,0 +1,8 @@ +# The nodes go with nodes_down and greg (with his project and backups) with +# users_remove; here, what the tasks and then setup installed on this VM. +if [ -f "$LAB_STATE/rpms" ]; then + new=$(comm -13 "$LAB_STATE/rpms" <(rpm -qa --qf '%{NAME}\n' | sort -u)) + [ -z "$new" ] || dnf -y -q remove $new >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/9-5-file-content-and-archives/setup.sh b/labs/ex294/9-5-file-content-and-archives/setup.sh new file mode 100644 index 0000000..8821c31 --- /dev/null +++ b/labs/ex294/9-5-file-content-and-archives/setup.sh @@ -0,0 +1,41 @@ +# The control node: ansible-core and greg's project, with a key the nodes +# already accept. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nnode1\n\n[dbservers]\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, prepared as in chapter 3.1: devops with greg's key and +# passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'useradd -m devops && install -d -m 700 -o devops -g devops /home/devops/.ssh && + cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && + chmod 600 /home/devops/.ssh/authorized_keys && + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops' \ + "$LAB_STATE/rpms" + +# Task 1: each node's sshd_config says something different about root. +in_node node1 'printf "\n# Set by hand\nPermitRootLogin yes\n" >>/etc/ssh/sshd_config' +in_node node2 'sed -i "s/^#PermitRootLogin .*/PermitRootLogin prohibit-password/" /etc/ssh/sshd_config' +for n in $(nodes_names); do + in_node "$n" 'sshd -t && systemctl restart sshd' + # Task 2: a message of the day to add to. + in_node "$n" "printf '%s, a practice node\n' $n >/etc/motd" +done +nodes_wait 60 diff --git a/labs/ex294/9-6-task-scheduling/README.md b/labs/ex294/9-6-task-scheduling/README.md new file mode 100644 index 0000000..5bc7378 --- /dev/null +++ b/labs/ex294/9-6-task-scheduling/README.md @@ -0,0 +1,40 @@ +# EX294 9.6: Task scheduling + +Schedule a user's cron job, add and remove a job in `/etc/cron.d`, and +deploy a systemd timer. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core`. Work as greg, +in `/home/greg/ansible`: its `ansible.cfg` reads the inventory `inventory`, +logs in as `devops` and becomes root. `node1` is in the group `webservers` +and `node2` in `dbservers`. + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers, prepared as in chapter 3.1: `devops` takes greg's +key and may use sudo without a password. Both run crond, and node1 has a +user `student`. For a root shell on a node, `sudo podman exec -it node1 +bash`. + +## Tasks + +1. Schedule `logger "EX294 in progress"` for `student` every 2 minutes on + the web servers. `crontab -l -u student` shows it, and after a few + minutes `journalctl -t student` on node1 has the messages. +2. Add a job to `/etc/cron.d/backup` on every node that archives `/etc` at + 02:00 every Sunday, as root. The file has the line with `root`. Then + remove it again from the web servers with `state: absent`; it's gone + there, and stays on the database servers. +3. Deploy a systemd timer `cleanup.timer` on the database servers that + runs a cleanup daily. `systemctl list-timers` lists it with a next run + time. + +## Hints + +- `ansible.builtin.cron` with `name`, `user`, `minute: "*/2"`, `job`; for + `/etc/cron.d`, `cron_file` and `user: root`, and `weekday: "0"`. +- `state: absent` finds the job by its `name`, so use the same one. +- A timer is two units: the oneshot service that does the work and the + timer with `OnCalendar=daily`. Enable and start the timer, after + `daemon_reload: true`. diff --git a/labs/ex294/9-6-task-scheduling/check.sh b/labs/ex294/9-6-task-scheduling/check.sh new file mode 100644 index 0000000..58a6f5e --- /dev/null +++ b/labs/ex294/9-6-task-scheduling/check.sh @@ -0,0 +1,18 @@ +nodes_wait 60 || true +# The /etc/cron.d line task 2 asks for: 02:00 on Sundays, as root, with /etc. +JOB='^0 +2 +\* +\* +(0|7|[Ss][Uu][Nn]) +root +.*/etc' + +task 1 "logger \"EX294 in progress\" every 2 minutes for student on the web servers" +need "student's crontab on node1 runs logger \"EX294 in progress\" every 2 minutes" "in_node node1 crontab -l -u student | grep -Eq '^\*/2 +\* +\* +\* +\* +(/usr/bin/)?logger .?EX294 in progress'" +need "crond is running on node1" 'in_node node1 systemctl is-active --quiet crond' + +task 2 "A Sunday 02:00 /etc backup in /etc/cron.d/backup, then removed from the web servers" +need "/etc/cron.d/backup on node2 archives /etc at 02:00 on Sundays, as root" "in_node node2 cat /etc/cron.d/backup | grep -Eq '$JOB'" +need "the job is gone from node1's /etc/cron.d/backup" "! in_node node1 cat /etc/cron.d/backup 2>/dev/null | grep -Eq '$JOB'" + +task 3 "A daily cleanup.timer on the database servers" +need "cleanup.timer is enabled on node2" "[ \"\$(in_node node2 systemctl is-enabled cleanup.timer)\" = enabled ]" +need "cleanup.timer is active on node2" 'in_node node2 systemctl is-active --quiet cleanup.timer' +need "cleanup.timer runs every day (OnCalendar)" "in_node node2 systemctl show -P TimersCalendar cleanup.timer | grep -Eq 'OnCalendar=\*-\*-\* [0-9]'" +need "systemctl list-timers on node2 shows a next run for cleanup.timer" "in_node node2 systemctl list-timers --no-legend cleanup.timer | grep -q cleanup.timer && ! in_node node2 systemctl list-timers --no-legend cleanup.timer | grep -q '^-'" +need "the unit it starts exists" "u=\$(in_node node2 systemctl show -P Triggers cleanup.timer) && [ -n \"\$u\" ] && in_node node2 systemctl cat \"\$u\"" diff --git a/labs/ex294/9-6-task-scheduling/lab.conf b/labs/ex294/9-6-task-scheduling/lab.conf new file mode 100644 index 0000000..fa8522b --- /dev/null +++ b/labs/ex294/9-6-task-scheduling/lab.conf @@ -0,0 +1,3 @@ +TITLE="9.6 Task scheduling" +CHAPTER=/ex294/9-automating-rhcsa-tasks/9-6-task-scheduling/ +SYSTEMS="rhel" diff --git a/labs/ex294/9-6-task-scheduling/reset.sh b/labs/ex294/9-6-task-scheduling/reset.sh new file mode 100644 index 0000000..6caf66b --- /dev/null +++ b/labs/ex294/9-6-task-scheduling/reset.sh @@ -0,0 +1,8 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove; here, what the tasks and then setup installed on this VM. +if [ -f "$LAB_STATE/rpms" ]; then + new=$(comm -13 "$LAB_STATE/rpms" <(rpm -qa --qf '%{NAME}\n' | sort -u)) + [ -z "$new" ] || dnf -y -q remove $new >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/9-6-task-scheduling/setup.sh b/labs/ex294/9-6-task-scheduling/setup.sh new file mode 100644 index 0000000..16e62d9 --- /dev/null +++ b/labs/ex294/9-6-task-scheduling/setup.sh @@ -0,0 +1,34 @@ +# The control node: ansible-core and greg's project, with a key the nodes +# already accept. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nnode1\n\n[dbservers]\nnode2\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory + +# The managed nodes, prepared as in chapter 3.1: devops with greg's key and +# passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'useradd -m devops && install -d -m 700 -o devops -g devops /home/devops/.ssh && + cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && + chmod 600 /home/devops/.ssh/authorized_keys && + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops' \ + "$LAB_STATE/rpms" + +# The user whose job task 1 schedules. +in_node node1 useradd -m student diff --git a/labs/ex294/9-7-security/README.md b/labs/ex294/9-7-security/README.md new file mode 100644 index 0000000..3f9dad0 --- /dev/null +++ b/labs/ex294/9-7-security/README.md @@ -0,0 +1,44 @@ +# EX294 9.7: Security + +Serve a web page from an unusual directory and port with SELinux +enforcing, using the SELinux modules: a file context rule and a relabel, a +boolean and a port label. + +## Before you start + +SELinux doesn't work inside the lab's managed-node containers, so this +VM, the control node, is also the web server: `localhost +ansible_connection=local`, in the inventory group `webservers`. The lab +creates the user `greg` (password `Lab-Pass-1`, sudo without a password) +and installs `ansible-core` and `policycoreutils-python-utils` (for +`semanage`). Work as greg, in `/home/greg/ansible`: its `ansible.cfg` +reads the inventory `inventory` and becomes root. The `ansible.posix` and +`community.general` collections aren't installed yet, and neither is +Apache. SELinux is enforcing; keep it that way. + +## Tasks + +1. Serve a page from `/srv/www` on the web servers: install Apache, put + an `index.html` in `/srv/www`, add a file context rule and relabel, and + configure Apache to use `/srv/www` as its document root. Open the + firewall for HTTP if it's running. `ls -Z /srv/www/index.html` shows + `httpd_sys_content_t` and `curl http://localhost/` gets the page. +2. Turn on `httpd_can_network_connect` persistently. `getsebool` shows + `on`, also after a reboot. +3. Label port 8888/tcp as `http_port_t` and make Apache listen on it too, + with the firewall open for it if it's running. `ss -tlnp | grep 8888` + shows httpd, and the AVC log (`ausearch -m avc -ts recent`) is quiet. + +## Hints + +- `ansible-galaxy collection install ansible.posix + 'community.general:>=11,<12'`: community.general 12 and later need a + newer ansible-core than this one. +- `community.general.sefcontext` with `target: '/srv/www(/.*)?'`, and a + handler running `restorecon -Rv /srv/www`. +- A file in `/etc/httpd/conf.d/` can set `DocumentRoot /srv/www`, a + `` with `Require all granted`, and `Listen 8888`. +- `ansible.posix.seboolean` with `persistent: true`; + `community.general.seport`. +- `ansible.posix.firewalld`, only where `firewalld.service` is running + (`service_facts`). diff --git a/labs/ex294/9-7-security/check.sh b/labs/ex294/9-7-security/check.sh new file mode 100644 index 0000000..346017c --- /dev/null +++ b/labs/ex294/9-7-security/check.sh @@ -0,0 +1,35 @@ +# page URL: what URL serves is /srv/www/index.html, polling for a few +# seconds. +page() { + local i + [ -s /srv/www/index.html ] || return 1 + for i in 1 2 3 4 5; do + [ "$(curl -s -m 3 "$1")" = "$(cat /srv/www/index.html)" ] && return 0 + sleep 2 + done + return 1 +} +# fw ARG: firewalld, if it's running, allows ARG (--query-service=http) +# now and permanently. +fw() { ! active firewalld || { firewall-cmd -q "$1" && firewall-cmd -q --permanent "$1"; }; } + +task 1 "Apache serves /srv/www, labeled httpd_sys_content_t by a file context rule" +need "SELinux is enforcing" '[ "$(getenforce)" = Enforcing ]' +need "/srv/www/index.html exists" '[ -s /srv/www/index.html ]' +need "a file context rule labels /srv/www httpd_sys_content_t (semanage fcontext -l -C)" 'semanage fcontext -l -C | grep -E "^/srv/www" | grep -q httpd_sys_content_t' +need "/srv/www/index.html has the type httpd_sys_content_t" '[ "$(stat -c %C /srv/www/index.html | cut -d: -f3)" = httpd_sys_content_t ]' +need "restorecon wouldn't change anything in /srv/www" '[ -z "$(restorecon -Rnv /srv/www)" ]' +need "httpd is running and enabled" 'active httpd && enabled httpd' +need "curl http://localhost/ gets /srv/www/index.html" 'page http://localhost/' +need "the firewall allows http, now and permanently, if it's running" 'fw --query-service=http' + +task 2 "httpd_can_network_connect on, persistently" +need "getsebool httpd_can_network_connect shows on" 'getsebool httpd_can_network_connect | grep -q "> on$"' +need "it stays on at boot (semanage boolean -l -C)" 'semanage boolean -l -C | grep -Eq "^httpd_can_network_connect +\(on +, +on\)"' + +task 3 "Port 8888/tcp labeled http_port_t, and Apache listening on it" +need "SELinux is enforcing" '[ "$(getenforce)" = Enforcing ]' +need "8888/tcp is labeled http_port_t (semanage port -l -C)" 'semanage port -l -C | grep -E "^http_port_t +tcp" | grep -Eq "\b8888\b"' +need "httpd listens on port 8888" 'ss -Hltnp "sport = :8888" | grep -q httpd' +need "curl http://localhost:8888/ gets /srv/www/index.html" 'page http://localhost:8888/' +need "the firewall allows 8888/tcp, now and permanently, if it's running" 'fw --query-port=8888/tcp' diff --git a/labs/ex294/9-7-security/lab.conf b/labs/ex294/9-7-security/lab.conf new file mode 100644 index 0000000..a50b974 --- /dev/null +++ b/labs/ex294/9-7-security/lab.conf @@ -0,0 +1,3 @@ +TITLE="9.7 Security" +CHAPTER=/ex294/9-automating-rhcsa-tasks/9-7-security/ +SYSTEMS="rhel" diff --git a/labs/ex294/9-7-security/reset.sh b/labs/ex294/9-7-security/reset.sh new file mode 100644 index 0000000..77abd3a --- /dev/null +++ b/labs/ex294/9-7-security/reset.sh @@ -0,0 +1,34 @@ +# greg (with his project) goes with users_remove. Here: Apache, the SELinux +# rules and firewall rules the tasks add, then what the tasks and setup +# installed. +rm -f /etc/sudoers.d/le-lab-greg +systemctl disable --now --quiet httpd 2>/dev/null +if [ "$(getenforce 2>/dev/null)" = Permissive ]; then + setenforce 1 + sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config +fi +if command -v semanage >/dev/null; then + semanage fcontext -l -C 2>/dev/null | awk '$1 ~ /^\/srv\/www/ {print $1}' | sort -u | + while read -r r; do semanage fcontext -d "$r" 2>/dev/null; done + semanage port -l -C 2>/dev/null | grep -Eq '^http_port_t +tcp +.*\b8888\b' && + semanage port -d -t http_port_t -p tcp 8888 2>/dev/null +fi +case $(state_get boolean) in +on) setsebool -P httpd_can_network_connect on ;; +*) setsebool -P httpd_can_network_connect off ;; +esac +rm -rf /srv/www +if [ -f "$LAB_STATE/firewalld" ] && active firewalld; then + [ -f "$LAB_STATE/had-http" ] || firewall-cmd -q --permanent --remove-service=http 2>/dev/null + [ -f "$LAB_STATE/had-8888" ] || firewall-cmd -q --permanent --remove-port=8888/tcp 2>/dev/null + firewall-cmd -q --reload +fi +if [ -f "$LAB_STATE/rpms" ]; then + new=$(comm -13 "$LAB_STATE/rpms" <(rpm -qa --qf '%{NAME}\n' | sort -u)) + [ -z "$new" ] || dnf -y -q remove $new >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +# Setup made sure there was no Apache before. +pkg_installed httpd || rm -rf /etc/httpd /var/www /var/log/httpd +systemctl daemon-reload +true diff --git a/labs/ex294/9-7-security/setup.sh b/labs/ex294/9-7-security/setup.sh new file mode 100644 index 0000000..568e111 --- /dev/null +++ b/labs/ex294/9-7-security/setup.sh @@ -0,0 +1,41 @@ +# The web server is this VM, with SELinux enforcing and no Apache yet. +[ "$(getenforce 2>/dev/null)" = Enforcing ] || refuse "SELinux must be enforcing (getenforce); set it back first" +pkg_installed httpd && refuse "Apache (httpd) is already installed; this lab installs it from scratch, so use a VM without it" +[ -e /srv/www ] && refuse "/srv/www already exists; move it away first" +listening 80 tcp && refuse "something already listens on port 80" +listening 8888 tcp && refuse "something already listens on port 8888" + +# The control node: ansible-core, semanage for checking, and greg's +# project, with sudo without a password for the localhost plays. +for p in ansible-core policycoreutils-python-utils; do + pkg_installed $p || { echo $p >>"$LAB_STATE/missing"; pkg_install $p; } +done +if semanage fcontext -l -C | grep -q '^/srv/www'; then refuse "there's already a file context rule for /srv/www (semanage fcontext -l -C)"; fi +if semanage port -l -C | grep -Eq '[ ,]8888(,|$)'; then refuse "port 8888 already has a local SELinux label (semanage port -l -C)"; fi +user_add greg +printf 'greg ALL=(ALL) NOPASSWD: ALL\n' >/etc/sudoers.d/le-lab-greg +chmod 440 /etc/sudoers.d/le-lab-greg +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nlocalhost ansible_connection=local\n' >/home/greg/ansible/inventory +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory +# What's installed here now; reset removes what the tasks add. +rpm -qa --qf '%{NAME}\n' | sort -u >"$LAB_STATE/rpms" + +# Task 2 starts off. +state_set boolean "$(getsebool httpd_can_network_connect | awk '{print $NF}')" +setsebool -P httpd_can_network_connect off +# The firewall, as it is, for reset. +if active firewalld; then + touch "$LAB_STATE/firewalld" + firewall-cmd -q --permanent --query-service=http && touch "$LAB_STATE/had-http" + firewall-cmd -q --permanent --query-port=8888/tcp && touch "$LAB_STATE/had-8888" +fi +true diff --git a/labs/ex294/9-8-users-and-groups/README.md b/labs/ex294/9-8-users-and-groups/README.md new file mode 100644 index 0000000..ff602e7 --- /dev/null +++ b/labs/ex294/9-8-users-and-groups/README.md @@ -0,0 +1,56 @@ +# EX294 9.8: Users and groups + +Create users from a variables file on the right hosts, in the right +groups and with hashed passwords, keep the playbook idempotent, and give a +group sudo rights. + +## Before you start + +This VM is the control node. The lab creates the user `greg` (password +`Lab-Pass-1`) with an SSH key, and installs `ansible-core`. Work as greg, +in `/home/greg/ansible`: its `ansible.cfg` reads the inventory `inventory`, +logs in as `devops` and becomes root. `node1` is in the group +`webservers` and `node2` in `dbservers`. `user_list.yml` there is the +variables file: + +```yaml +users: + - name: fred + job: developer + uid: 3001 + - name: wilma + job: manager + uid: 3002 + - name: barney + job: developer + uid: 3003 +``` + +The managed nodes `node1` (10.99.1.11) and `node2` (10.99.1.12) are +AlmaLinux 10 containers, prepared as in chapter 3.1: `devops` takes greg's +key and may use sudo without a password, from `/etc/sudoers.d/devops`. +For a root shell on a node, `sudo podman exec -it node1 bash`. + +## Tasks + +1. Write `/home/greg/ansible/users.yml` to create the users from + `user_list.yml`, with their UIDs: developers on the web servers in the + group `developers`, managers on the database servers in `opsmgr`, all + with the password `Redhat123`, hashed. `id` shows the groups, and `ssh + fred@node1` with the password works. +2. Make `users.yml` report `changed=0` on a second run. +3. Let the `developers` group run any command with sudo without a + password, with a validated drop-in. `sudo -n whoami` as a developer on node1 + prints `root`. + +## Hints + +- `vars_files`, a `loop` over `users` and `when: item.job == 'developer'`; + `ansible.builtin.group` before `ansible.builtin.user` with `groups` and + `append: true`. +- `password_hash('sha512')` makes the hash; `update_password: on_create` + or a fixed salt keeps a second run from changing it. +- `ansible.builtin.copy` with `validate: /usr/sbin/visudo -cf %s`, to a + file of its own (not `/etc/sudoers.d/devops`, which Ansible's own login + needs). +- Test a login with `ssh -o PubkeyAuthentication=no fred@node1`. diff --git a/labs/ex294/9-8-users-and-groups/check.sh b/labs/ex294/9-8-users-and-groups/check.sh new file mode 100644 index 0000000..3b1c195 --- /dev/null +++ b/labs/ex294/9-8-users-and-groups/check.sh @@ -0,0 +1,33 @@ +nodes_wait 60 || true +# ans CMD: run CMD as greg in his project directory. +ans() { timeout 300 runuser -l greg -c "cd ~/ansible && $*" "$OUT"; grep -Eq "^node1 +: ok=" "$OUT" && grep -Eq "^node2 +: ok=" "$OUT" && ! grep -E " : ok=" "$OUT" | grep -Eq "(changed|unreachable|failed)=[1-9]"' + +task 3 "The developers group may run any command with sudo, no password, from a validated drop-in" +need "a file in /etc/sudoers.d on node1 has a rule for %developers" "in_node node1 'grep -rqs \"^%developers\" /etc/sudoers.d'" +need "visudo -c on node1 finds no errors" 'in_node node1 visudo -cq' +need "sudo -n whoami as fred on node1 prints root" '[ "$(in_node node1 runuser -u fred -- sudo -n whoami)" = root ]' +rm -f "$OUT" diff --git a/labs/ex294/9-8-users-and-groups/lab.conf b/labs/ex294/9-8-users-and-groups/lab.conf new file mode 100644 index 0000000..e209437 --- /dev/null +++ b/labs/ex294/9-8-users-and-groups/lab.conf @@ -0,0 +1,3 @@ +TITLE="9.8 Users and groups" +CHAPTER=/ex294/9-automating-rhcsa-tasks/9-8-users-and-groups/ +SYSTEMS="rhel" diff --git a/labs/ex294/9-8-users-and-groups/reset.sh b/labs/ex294/9-8-users-and-groups/reset.sh new file mode 100644 index 0000000..6caf66b --- /dev/null +++ b/labs/ex294/9-8-users-and-groups/reset.sh @@ -0,0 +1,8 @@ +# The nodes go with nodes_down and greg (with his project) with +# users_remove; here, what the tasks and then setup installed on this VM. +if [ -f "$LAB_STATE/rpms" ]; then + new=$(comm -13 "$LAB_STATE/rpms" <(rpm -qa --qf '%{NAME}\n' | sort -u)) + [ -z "$new" ] || dnf -y -q remove $new >/dev/null 2>&1 +fi +while read -r p; do dnf -y -q remove "$p" >/dev/null 2>&1; done <"$LAB_STATE/missing" 2>/dev/null +true diff --git a/labs/ex294/9-8-users-and-groups/setup.sh b/labs/ex294/9-8-users-and-groups/setup.sh new file mode 100644 index 0000000..49c750e --- /dev/null +++ b/labs/ex294/9-8-users-and-groups/setup.sh @@ -0,0 +1,44 @@ +# The control node: ansible-core and greg's project, with a key the nodes +# already accept. +pkg_installed ansible-core || { echo ansible-core >>"$LAB_STATE/missing"; pkg_install ansible-core; } +user_add greg +install -d -m 700 -o greg -g greg /home/greg/.ssh +runuser -u greg -- ssh-keygen -q -t ed25519 -N '' -f /home/greg/.ssh/id_ed25519 +install -d -m 755 -o greg -g greg /home/greg/ansible +cat >/home/greg/ansible/ansible.cfg <<'X' +[defaults] +inventory = inventory +remote_user = devops +host_key_checking = false + +[privilege_escalation] +become = true +X +printf '[webservers]\nnode1\n\n[dbservers]\nnode2\n' >/home/greg/ansible/inventory +cat >/home/greg/ansible/user_list.yml <<'X' +--- +users: + - name: fred + job: developer + uid: 3001 + - name: wilma + job: manager + uid: 3002 + - name: barney + job: developer + uid: 3003 +X +chown greg: /home/greg/ansible/ansible.cfg /home/greg/ansible/inventory /home/greg/ansible/user_list.yml + +# The managed nodes, prepared as in chapter 3.1: devops with greg's key and +# passwordless sudo. +nodes_up 2 +for n in $(nodes_names); do + in_node -i "$n" 'useradd -m devops && install -d -m 700 -o devops -g devops /home/devops/.ssh && + cat >/home/devops/.ssh/authorized_keys && chown devops: /home/devops/.ssh/authorized_keys && + chmod 600 /home/devops/.ssh/authorized_keys && + echo "devops ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/devops && chmod 440 /etc/sudoers.d/devops' \ + "$LAB_STATE/rpms"