From afceba909083e437cc5eb5da49a1c4dcc83fae9f Mon Sep 17 00:00:00 2001 From: Michal Opala Date: Fri, 24 Jul 2026 14:20:57 +0200 Subject: [PATCH 1/6] M #-: Add helper/vmdns role to manage /etc/hosts - Add raft/failover.sh VIP hook (reuses raft/vip.sh) - Add vmdns/refresh.rb hook - Add support for HA failover events - Add support for systemd-resolved (server) Signed-off-by: Michal Opala --- playbooks/site.yml | 3 + roles/helper/vmdns/README.md | 43 +++++++ roles/helper/vmdns/defaults/main.yml | 2 + roles/helper/vmdns/files/refresh.rb | 67 ++++++++++ roles/helper/vmdns/meta/main.yml | 7 ++ roles/helper/vmdns/tasks/main.yml | 147 ++++++++++++++++++++++ roles/opennebula/common/defaults/main.yml | 2 + roles/opennebula/server/README.md | 42 ++++--- roles/opennebula/server/files/failover.sh | 11 ++ roles/opennebula/server/tasks/config.yml | 26 +++- 10 files changed, 328 insertions(+), 22 deletions(-) create mode 100644 roles/helper/vmdns/README.md create mode 100644 roles/helper/vmdns/defaults/main.yml create mode 100644 roles/helper/vmdns/files/refresh.rb create mode 100644 roles/helper/vmdns/meta/main.yml create mode 100644 roles/helper/vmdns/tasks/main.yml create mode 100644 roles/opennebula/server/files/failover.sh diff --git a/playbooks/site.yml b/playbooks/site.yml index 5a4325e5..3a0273a0 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -108,6 +108,9 @@ tags: [prometheus] when: *prometheus + - role: helper/vmdns + tags: [vmdns] + - hosts: "{{ node_group | d('node') }}" tags: [node, stage2] collections: diff --git a/roles/helper/vmdns/README.md b/roles/helper/vmdns/README.md new file mode 100644 index 00000000..21aa227d --- /dev/null +++ b/roles/helper/vmdns/README.md @@ -0,0 +1,43 @@ +Role: opennebula.deploy.helper.vmdns +==================================== + +A role that automatically updates /etc/hosts on the RAFT leader. + +Requirements +------------ + +N/A + +Role Variables +-------------- + +| Name | Type | Default | Description | +|----------------|-------|---------|--------------------------------------------------------------------| +| `vmdns_server` | `str` | `null` | DNS service to configure (currently only `resolved` is supported). | + +Dependencies +------------ + +- ansible.posix + +Example Playbook +---------------- + + - hosts: frontend + vars: + leader_hook: raft/failover.sh + follower_hook: raft/failover.sh + vmdns_server: resolved + roles: + - role: opennebula.deploy.helper.facts + - role: opennebula.deploy.helper.vmdns + +License +------- + +Apache-2.0 + +Author Information +------------------ + +[OpenNebula Systems](https://opennebula.io/) diff --git a/roles/helper/vmdns/defaults/main.yml b/roles/helper/vmdns/defaults/main.yml new file mode 100644 index 00000000..bf20003d --- /dev/null +++ b/roles/helper/vmdns/defaults/main.yml @@ -0,0 +1,2 @@ +--- +vmdns_server: null # [null, 'resolved'] diff --git a/roles/helper/vmdns/files/refresh.rb b/roles/helper/vmdns/files/refresh.rb new file mode 100644 index 00000000..adcf0493 --- /dev/null +++ b/roles/helper/vmdns/files/refresh.rb @@ -0,0 +1,67 @@ +#!/usr/bin/env ruby +# managed by one-deploy + +def run(cmd) + o = `#{cmd}` + s = $? + raise unless s.success? + o +end + +def debounce(lock_file = '/var/tmp/vmdns.lock') + File.open(lock_file, File::CREAT | File::RDWR, 0o0644) do |lf| + next unless lf.flock(File::LOCK_EX | File::LOCK_NB) + begin + # NOTE: This is not completely precise, but should be good enough + # for a simple reduction. + sleep(1) + yield + ensure + lf.flock(File::LOCK_UN) + end + end +end + +def refresh(hosts_file = '/etc/hosts') + File.open(hosts_file, File::RDONLY) do |hf| + hf.each_line(chomp: true).each_with_object({block: false, lines: []}) do |line, acc| + case line + when '# BEGIN VMDNS (one-deploy)' + acc[:block] = true + when '# END VMDNS (one-deploy)' + acc[:block] = false + else + acc[:lines] << line unless acc[:block] + end + end[:lines] + end.then do |unmanaged| + require 'json' + + document = JSON.parse run('onevm list --json') + + File.open(hosts_file, File::CREAT | File::TRUNC | File::WRONLY, 0o0644) do |hf| + unmanaged.each do |line| + hf.puts(line) + end + + hf.puts('# BEGIN VMDNS (one-deploy)') + + document.dig('VM_POOL', 'VM')&.each do |vm| + ip = [vm.dig('TEMPLATE', 'NIC')].flatten.dig(0, 'IP') + next unless ip + + name = vm['NAME'] + next unless name + + id = vm['ID'] + next unless id + + hf.puts("#{ip} #{name} one-#{id}") + end + + hf.puts('# END VMDNS (one-deploy)') + end + end +end + +debounce { refresh } diff --git a/roles/helper/vmdns/meta/main.yml b/roles/helper/vmdns/meta/main.yml new file mode 100644 index 00000000..86a710b1 --- /dev/null +++ b/roles/helper/vmdns/meta/main.yml @@ -0,0 +1,7 @@ +--- +collections: + - opennebula.deploy + - ansible.posix + +dependencies: + - role: opennebula.deploy.common diff --git a/roles/helper/vmdns/tasks/main.yml b/roles/helper/vmdns/tasks/main.yml new file mode 100644 index 00000000..6f04fe60 --- /dev/null +++ b/roles/helper/vmdns/tasks/main.yml @@ -0,0 +1,147 @@ +--- +- when: vmdns_server in ['resolved'] + vars: + _use_ha: >- + {{ use_ha | d((force_ha | bool is true) or (federation.groups.frontend | count > 1)) }} + + _files: + - dest: /var/lib/one/remotes/hooks/vmdns/ + state: directory + mode: u=rwx,g=rx,o= + + - when: "{{ _use_ha }}" + dest: /var/lib/one/remotes/hooks/raft/failover.d/01-vmdns.rb + src: /var/lib/one/remotes/hooks/vmdns/refresh.rb + state: link + force: true + + - when: "{{ vmdns_server == 'resolved' }}" + dest: /etc/systemd/resolved.conf.d/ + state: directory + owner: 0 + group: 0 + mode: u=rwx,go=rx + + _configs: + - dest: /etc/systemd/resolved.conf.d/99-vmdns.conf + mode: u=rw,go=r + content: | + # managed by one-deploy + [Resolve] + DNSStubListenerExtra=0.0.0.0 + + _scripts: + - dest: /var/lib/one/remotes/hooks/vmdns/refresh.rb + src: "{{ role_path }}/files/refresh.rb" + mode: u=rwx,g=rx,o= + + _templates: + - TEMPLATE: + NAME: vmdns_insert + TYPE: api + CALL: one.template.instantiate + RESOURCE: VM + REMOTE: 'NO' + TIMEOUT: 60 + COMMAND: /var/lib/one/remotes/hooks/vmdns/refresh.rb + + - TEMPLATE: + NAME: vmdns_remove + TYPE: state + RESOURCE: VM + REMOTE: 'NO' + 'ON': DONE # Ansible converts ON to True otherwise.. + TIMEOUT: 60 + COMMAND: /var/lib/one/remotes/hooks/vmdns/refresh.rb + block: + - name: Assert that it is possible to install working RAFT hook + ansible.builtin.assert: + that: (_use_ha is false) + or + (leader_hook == 'raft/failover.sh' and follower_hook == 'raft/failover.sh') + fail_msg: Please switch to raft/failover.sh for VIP management first. + + - ansible.builtin.include_role: + name: opennebula/leader + when: leader is undefined + + - name: Install required OS packages + ansible.builtin.package: + name: "{{ _common + _specific[ansible_os_family] }}" + vars: + _common: [acl] + _specific: + Debian: [] + RedHat: [] + Suse: [] + register: package + until: package is success + retries: 12 + delay: 5 + + - name: Grant oneadmin access to /etc/hosts + ansible.posix.acl: + path: /etc/hosts + entity: 9869 + etype: user + permissions: rw + state: present + + - name: Touch files and directories + ansible.builtin.file: + dest: "{{ item.dest }}" + src: "{{ item.src | d(omit) }}" + state: "{{ item.state }}" + force: "{{ item.force | d(false) }}" + owner: "{{ item.owner | d(9869) }}" + group: "{{ item.group | d(9869) }}" + mode: "{{ item.mode | d(omit) }}" + when: item.when | d(true) + loop: "{{ _files }}" + + - name: Create configs and scripts + ansible.builtin.copy: + dest: "{{ item.dest }}" + src: "{{ item.src | d(omit) }}" + content: "{{ item.content | d(omit) }}" + owner: "{{ item.owner | d(9869) }}" + group: "{{ item.group | d(9869) }}" + mode: "{{ item.mode }}" + when: item.when | d(true) + loop: "{{ _configs + _scripts }}" + register: copy_configs_and_scripts + + - name: Restart systemd-resolved + ansible.builtin.systemd_service: + name: systemd-resolved.service + state: restarted + when: + - vmdns_server == 'resolved' + - copy_configs_and_scripts is changed + + - when: inventory_hostname == leader + block: + - name: Create hooks + ansible.builtin.shell: + cmd: | + set -o errexit + + TEMPLATE="$(mktemp)" && trap "rm -rf $TEMPLATE" EXIT ERR + tee "$TEMPLATE" + + if onehook show '{{ item.TEMPLATE.NAME }}'; then + exit 0 + fi + + onehook create "$TEMPLATE" + + exit 78 # EREMCHG + stdin: >- + {{ item.TEMPLATE | opennebula.deploy.to_one }} + executable: /bin/bash + register: shell + changed_when: + - shell.rc in [78] + failed_when: + - shell.rc not in [0, 78] + loop: "{{ _templates }}" diff --git a/roles/opennebula/common/defaults/main.yml b/roles/opennebula/common/defaults/main.yml index 42244c26..a71d956a 100644 --- a/roles/opennebula/common/defaults/main.yml +++ b/roles/opennebula/common/defaults/main.yml @@ -7,3 +7,5 @@ keep_empty_bridge: true admin_pubkey: "{{ _admin_pubkey_loaded }}" zone_name: "{{ ('OpenNebula' if (federation.role != 'SLAVE') else undef()) | mandatory('The zone_name var must be provided.') }}" auth: { default: null } +leader_hook: raft/vip.sh +follower_hook: raft/vip.sh diff --git a/roles/opennebula/server/README.md b/roles/opennebula/server/README.md index 7c2c7835..588f9c4b 100644 --- a/roles/opennebula/server/README.md +++ b/roles/opennebula/server/README.md @@ -11,26 +11,28 @@ N/A Role Variables -------------- -| Name | Type | Default | Example | Description | -|---------------------|--------|--------------|---------------|-----------------------------------------------------------------------------------------------------------------| -| `one_pass` | `str` | `null` | `asd123` | Use specific password for the `oneadmin` user. | -| `force_ha` | `bool` | `false` | | Deploy OpenNebula in HA mode even with a single Frontend. | -| `unsafe_migrations` | `bool` | `true` | | Disable LibVirt's NFS/mountpoint checks. | -| `keep_empty_bridge` | `bool` | `true` | | Make sure empty network bridges are never removed (from Nodes). | -| `one_vip` | `str` | undefined | `10.11.12.13` | When OpenNebula is in HA mode it points to the Leader. | -| `one_vip_if` | `str` | undefined | `eth0` | NIC device to assign the `one_vip` address to (on Frontends). | -| `one_vip_cidr` | `int` | undefined | `24` | CIDR prefix of the subnet `one_vip` is allocated in. | -| `db_backend` | `str` | `MariaDB` | |`MariaDB` or `SQLite`. | -| `db_name` | `str` | `opennebula` | | Name of the database/schema used by OpenNebula. | -| `db_owner` | `str` | `oneadmin` | | User used by OpenNebula to access the database. | -| `db_password` | `str` | `opennebula` | | Password used by OpenNebula to authenticate the user. | -| `gate_endpoint` | `str` | conditional | (check below) | An URL used to reach the OneGate endpoint (HTTP). | -| `admin_pubkey` | `str` | loaded | (check below) | SSH pubkey loaded from `/var/lib/one/.ssh/id_rsa.pub`, provided by the user (as string) or ignored when `null`. | -| `sched_rank` | `dict` | undefined | (check below) | Rank scheduler configuration. | -| `sched_drs` | `dict` | undefined | (check below) | OpenNebula Distributed Resource Scheduler configuration. | -| `auth.default` | `str` | `null` | | Pick default auth mechanism (currently only `ldap` is supported in one-deploy). | -| `auth.ldap.config` | `dict` | `{}` | (check below) | LDAP authentication config (/etc/one/auth/ldap_auth.conf). | -| `auth.ldap.mapping` | `dict` | `{}` | (check below) | LDAP authentication group mapping (manually defined). | +| Name | Type | Default | Example | Description | +|---------------------|--------|---------------|---------------|-----------------------------------------------------------------------------------------------------------------| +| `one_pass` | `str` | `null` | `asd123` | Use specific password for the `oneadmin` user. | +| `force_ha` | `bool` | `false` | | Deploy OpenNebula in HA mode even with a single Frontend. | +| `unsafe_migrations` | `bool` | `true` | | Disable LibVirt's NFS/mountpoint checks. | +| `keep_empty_bridge` | `bool` | `true` | | Make sure empty network bridges are never removed (from Nodes). | +| `one_vip` | `str` | undefined | `10.11.12.13` | When OpenNebula is in HA mode it points to the Leader. | +| `one_vip_if` | `str` | undefined | `eth0` | NIC device to assign the `one_vip` address to (on Frontends). | +| `one_vip_cidr` | `int` | undefined | `24` | CIDR prefix of the subnet `one_vip` is allocated in. | +| `leader_hook` | `str` | `raft/vip.sh` | | Define RAFT leader VIP handler. | +| `follower_hook` | `str` | `raft/vip.sh` | | Define RAFT follower VIP handler. | +| `db_backend` | `str` | `MariaDB` | |`MariaDB` or `SQLite`. | +| `db_name` | `str` | `opennebula` | | Name of the database/schema used by OpenNebula. | +| `db_owner` | `str` | `oneadmin` | | User used by OpenNebula to access the database. | +| `db_password` | `str` | `opennebula` | | Password used by OpenNebula to authenticate the user. | +| `gate_endpoint` | `str` | conditional | (check below) | An URL used to reach the OneGate endpoint (HTTP). | +| `admin_pubkey` | `str` | loaded | (check below) | SSH pubkey loaded from `/var/lib/one/.ssh/id_rsa.pub`, provided by the user (as string) or ignored when `null`. | +| `sched_rank` | `dict` | undefined | (check below) | Rank scheduler configuration. | +| `sched_drs` | `dict` | undefined | (check below) | OpenNebula Distributed Resource Scheduler configuration. | +| `auth.default` | `str` | `null` | | Pick default auth mechanism (currently only `ldap` is supported in one-deploy). | +| `auth.ldap.config` | `dict` | `{}` | (check below) | LDAP authentication config (/etc/one/auth/ldap_auth.conf). | +| `auth.ldap.mapping` | `dict` | `{}` | (check below) | LDAP authentication group mapping (manually defined). | Dependencies ------------ diff --git a/roles/opennebula/server/files/failover.sh b/roles/opennebula/server/files/failover.sh new file mode 100644 index 00000000..687898b1 --- /dev/null +++ b/roles/opennebula/server/files/failover.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# managed by one-deploy + +set -o errexit; shopt -s nullglob + +/var/lib/one/remotes/hooks/raft/vip.sh "$@" + +for SCRIPT in /var/lib/one/remotes/hooks/raft/failover.d/[0-9][0-9]-*; do + if ! [[ -x "$SCRIPT" ]]; then continue; fi + $SCRIPT "$@" +done diff --git a/roles/opennebula/server/tasks/config.yml b/roles/opennebula/server/tasks/config.yml index 8643e6cb..b5be60af 100644 --- a/roles/opennebula/server/tasks/config.yml +++ b/roles/opennebula/server/tasks/config.yml @@ -209,6 +209,28 @@ - when: use_ha is true block: + - when: _hooks is contains('raft/failover.sh') + vars: + _hooks: + - "{{ leader_hook }}" + - "{{ follower_hook }}" + block: + - name: Create raft/failover.d + ansible.builtin.file: + path: /var/lib/one/remotes/hooks/raft/failover.d + state: directory + owner: 9869 + group: 9869 + mode: u=rwx,g=rx,o= + + - name: Create raft/failover.sh + ansible.builtin.copy: + dest: /var/lib/one/remotes/hooks/raft/failover.sh + src: "{{ role_path }}/files/failover.sh" + owner: 9869 + group: 9869 + mode: u=rwx,g=rx,o= + - name: Configure oned (RAFT) opennebula.deploy.cfgtool: dest: /etc/one/oned.conf @@ -216,13 +238,13 @@ actions: - put: path: [RAFT_LEADER_HOOK, COMMAND] - value: '"raft/vip.sh"' + value: '"{{ leader_hook }}"' - put: path: [RAFT_LEADER_HOOK, ARGUMENTS] value: '"leader {{ one_vip_if }} {{ one_vip }}/{{ one_vip_cidr }}"' - put: path: [RAFT_FOLLOWER_HOOK, COMMAND] - value: '"raft/vip.sh"' + value: '"{{ follower_hook }}"' - put: path: [RAFT_FOLLOWER_HOOK, ARGUMENTS] value: '"follower {{ one_vip_if }} {{ one_vip }}/{{ one_vip_cidr }}"' From ec006455b8d967036d34d973a6cd9886a309e3dd Mon Sep 17 00:00:00 2001 From: Michal Opala Date: Thu, 30 Jul 2026 23:44:04 +0200 Subject: [PATCH 2/6] M #-: Apply several fixes - Silently skip invalid hostnames (fix) - Rename debounce to throttle (fix) - Make sure throttle synchronizes correctly (fix) - Do not truncate(0) /etc/hosts (fix) - Do not refresh /etc/hosts on followers (fix) - Set correct permissions for resolved's drop-in (fix) Signed-off-by: Michal Opala --- roles/helper/vmdns/files/refresh.rb | 87 +++++++++++++++++++---------- roles/helper/vmdns/tasks/main.yml | 16 ++++-- 2 files changed, 69 insertions(+), 34 deletions(-) diff --git a/roles/helper/vmdns/files/refresh.rb b/roles/helper/vmdns/files/refresh.rb index adcf0493..203e3d4a 100644 --- a/roles/helper/vmdns/files/refresh.rb +++ b/roles/helper/vmdns/files/refresh.rb @@ -1,6 +1,27 @@ #!/usr/bin/env ruby # managed by one-deploy +RFC1123_REGEX = %r{ \A + + (?=.{1,253}\z) + + (?: + [a-zA-Z0-9] + (?: + [a-zA-Z0-9-]{0,61} + [a-zA-Z0-9] + )? + [.] + )* + + [a-zA-Z0-9] + (?: + [a-zA-Z0-9-]{0,61} + [a-zA-Z0-9] + )? + + \z }x + def run(cmd) o = `#{cmd}` s = $? @@ -8,21 +29,25 @@ def run(cmd) o end -def debounce(lock_file = '/var/tmp/vmdns.lock') - File.open(lock_file, File::CREAT | File::RDWR, 0o0644) do |lf| - next unless lf.flock(File::LOCK_EX | File::LOCK_NB) - begin - # NOTE: This is not completely precise, but should be good enough - # for a simple reduction. - sleep(1) - yield - ensure - lf.flock(File::LOCK_UN) - end +def throttle(thrt_lock = '/var/tmp/vmdns-thrt.lock', + exec_lock = '/var/tmp/vmdns-exec.lock') + # Silently drop rapid bursts. + File.open(thrt_lock, File::CREAT | File::RDWR, 0o0644) do |lf| + return unless lf.flock(File::LOCK_EX | File::LOCK_NB) + sleep(1) + end + # Ensure exclusive execution. + File.open(exec_lock, File::CREAT | File::RDWR, 0o0644) do |lf| + lf.flock(File::LOCK_EX) + yield end end def refresh(hosts_file = '/etc/hosts') + require 'json' + + document = JSON.parse run('onevm list --json') + File.open(hosts_file, File::RDONLY) do |hf| hf.each_line(chomp: true).each_with_object({block: false, lines: []}) do |line, acc| case line @@ -35,33 +60,37 @@ def refresh(hosts_file = '/etc/hosts') end end[:lines] end.then do |unmanaged| - require 'json' + document.dig('VM_POOL', 'VM')&.each_with_object([]) do |vm, acc| + ip = [vm.dig('TEMPLATE', 'NIC')].flatten.dig(0, 'IP') + next unless ip - document = JSON.parse run('onevm list --json') + name = vm['NAME'] + next unless name && name =~ RFC1123_REGEX - File.open(hosts_file, File::CREAT | File::TRUNC | File::WRONLY, 0o0644) do |hf| - unmanaged.each do |line| - hf.puts(line) - end + id = vm['ID'] + next unless id - hf.puts('# BEGIN VMDNS (one-deploy)') + acc << "#{ip} #{name} one-#{id}" + end.then do |managed| + File.open(hosts_file, File::CREAT | File::RDWR, 0o0644) do |hf| + hf.rewind - document.dig('VM_POOL', 'VM')&.each do |vm| - ip = [vm.dig('TEMPLATE', 'NIC')].flatten.dig(0, 'IP') - next unless ip + unmanaged.each { |line| hf.puts(line) } - name = vm['NAME'] - next unless name + # NOTE: The original / unmanaged data is never completely removed + # from the file. + hf.truncate(hf.pos) - id = vm['ID'] - next unless id + unless managed.empty? + hf.puts('# BEGIN VMDNS (one-deploy)') - hf.puts("#{ip} #{name} one-#{id}") - end + managed.each { |line| hf.puts(line) } - hf.puts('# END VMDNS (one-deploy)') + hf.puts('# END VMDNS (one-deploy)') + end + end end end end -debounce { refresh } +throttle { refresh } if [nil, 'leader'].include?(ARGV[0]) diff --git a/roles/helper/vmdns/tasks/main.yml b/roles/helper/vmdns/tasks/main.yml index 6f04fe60..376a568a 100644 --- a/roles/helper/vmdns/tasks/main.yml +++ b/roles/helper/vmdns/tasks/main.yml @@ -7,6 +7,8 @@ _files: - dest: /var/lib/one/remotes/hooks/vmdns/ state: directory + owner: 9869 + group: 9869 mode: u=rwx,g=rx,o= - when: "{{ _use_ha }}" @@ -24,6 +26,8 @@ _configs: - dest: /etc/systemd/resolved.conf.d/99-vmdns.conf + owner: 0 + group: 0 mode: u=rw,go=r content: | # managed by one-deploy @@ -33,6 +37,8 @@ _scripts: - dest: /var/lib/one/remotes/hooks/vmdns/refresh.rb src: "{{ role_path }}/files/refresh.rb" + owner: 9869 + group: 9869 mode: u=rwx,g=rx,o= _templates: @@ -93,8 +99,8 @@ src: "{{ item.src | d(omit) }}" state: "{{ item.state }}" force: "{{ item.force | d(false) }}" - owner: "{{ item.owner | d(9869) }}" - group: "{{ item.group | d(9869) }}" + owner: "{{ item.owner | d(omit) }}" + group: "{{ item.group | d(omit) }}" mode: "{{ item.mode | d(omit) }}" when: item.when | d(true) loop: "{{ _files }}" @@ -104,9 +110,9 @@ dest: "{{ item.dest }}" src: "{{ item.src | d(omit) }}" content: "{{ item.content | d(omit) }}" - owner: "{{ item.owner | d(9869) }}" - group: "{{ item.group | d(9869) }}" - mode: "{{ item.mode }}" + owner: "{{ item.owner | d(omit) }}" + group: "{{ item.group | d(omit) }}" + mode: "{{ item.mode | d(omit) }}" when: item.when | d(true) loop: "{{ _configs + _scripts }}" register: copy_configs_and_scripts From d010ba43da378e6bfdf77367a69ee5dbdbb67757 Mon Sep 17 00:00:00 2001 From: Michal Opala Date: Fri, 31 Jul 2026 12:47:40 +0200 Subject: [PATCH 3/6] M #-: Refactor helper/hosts role - Optimize for speed - Never truncate or recreate /etc/hosts (fix) Signed-off-by: Michal Opala --- roles/helper/hosts/tasks/main.yml | 84 +++++++++++++++++++------------ 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/roles/helper/hosts/tasks/main.yml b/roles/helper/hosts/tasks/main.yml index d0318e66..4ad0b002 100644 --- a/roles/helper/hosts/tasks/main.yml +++ b/roles/helper/hosts/tasks/main.yml @@ -1,43 +1,65 @@ --- -- when: ensure_hostname | bool is true +- when: ensure_hostname | bool block: - name: Set hostname ansible.builtin.hostname: name: "{{ inventory_hostname }}" -- when: ensure_hosts | bool is true +- when: ensure_hosts | bool + vars: + # Split /etc/hosts into lines. + _lines: >- + {{ (slurp.content | b64decode).splitlines() }} + + # Remove comments, empty lines and loopback entries. + _cleaned: >- + {{ _lines | map('regex_replace', '#.*', '') + | map('regex_replace', '^(127\.0\.\d+\.\d+|::\d+)\s.*', '') + | map('trim') + | select }} + + # Split all lines into words, remove empty strings. + _words: >- + {{ _cleaned | map('split') + | flatten + | map('trim') + | select + | list }} + + # Collect possible new hostnames. + _new_hostnames: >- + {{ federation.groups.all | reject('in', _words) }} + + # Extract addresses for possible hostnames. + _new_addresses: >- + {{ _new_hostnames | map('extract', hostvars, ['ansible_host']) + | map('d') }} + + # Assemble a list of valid address/hostname pairs. + _new_items: >- + {{ _new_addresses | zip(_new_hostnames) + | selectattr(0, 'truthy') }} + + _new_lines: >- + {{ _new_items | map('join', ' ') }} + + _lf: "\n" block: - name: Slurp /etc/hosts ansible.builtin.slurp: path: /etc/hosts register: slurp - - name: Populate /etc/hosts - ansible.builtin.lineinfile: - path: /etc/hosts - line: '{{ _addr }} {{ _host }}' - when: - - _host is truthy - - _addr is truthy - - _host not in _words - - _addr not in _words - vars: - # Split /etc/hosts into lines. - _lines: >- - {{ (slurp.content | b64decode).splitlines() }} - # Remove comments, empty lines and "loopback"-related entries. - _cleaned: >- - {{ _lines | map('regex_replace', '#.*', '') - | map('regex_replace', '^(127\.0\.\d+\.\d+|::\d+)\s.*', '') - | map('trim') - | select }} - # Split all lines into words, remove empty strings. - _words: >- - {{ _cleaned | map('split') - | flatten - | map('trim') - | select - | list }} - _addr: "{{ hostvars[_host].ansible_host | d() }}" - _host: "{{ item }}" - loop: "{{ federation.groups.all }}" + # NOTE: Modules like lineinfile entirely recreate the destination, + # which causes not only an inode change, but also ACL removal. + # Here we update /etc/hosts in a single step and in such a way + # that it's never recreated or truncated. + # NOTE: This is not intended to be a complete /etc/hosts management + # solution, which is why we never modify existing entries. + # If your production environment requires non-static inventory, + # then please consider managing a real DNS service. + - name: Append new entries to /etc/hosts + ansible.builtin.command: + cmd: tee --append /etc/hosts + stdin: "{{ _new_lines | join(_lf) }}" + when: _new_lines | count > 0 From 785f2a0c103c1d76bf74d8416a969497d23721b2 Mon Sep 17 00:00:00 2001 From: Michal Opala Date: Fri, 31 Jul 2026 13:32:39 +0200 Subject: [PATCH 4/6] M #-: Prohibit helper/vmdns role in production environments Signed-off-by: Michal Opala --- roles/helper/vmdns/README.md | 1 + roles/precheck/pre_reboot/tasks/security.yml | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/roles/helper/vmdns/README.md b/roles/helper/vmdns/README.md index 21aa227d..f79e45e7 100644 --- a/roles/helper/vmdns/README.md +++ b/roles/helper/vmdns/README.md @@ -28,6 +28,7 @@ Example Playbook leader_hook: raft/failover.sh follower_hook: raft/failover.sh vmdns_server: resolved + prod_env: false roles: - role: opennebula.deploy.helper.facts - role: opennebula.deploy.helper.vmdns diff --git a/roles/precheck/pre_reboot/tasks/security.yml b/roles/precheck/pre_reboot/tasks/security.yml index 68a0cd2c..deb7d7e1 100644 --- a/roles/precheck/pre_reboot/tasks/security.yml +++ b/roles/precheck/pre_reboot/tasks/security.yml @@ -50,3 +50,13 @@ when: - prod_env | d(false) | bool is true - _items[item].condition + +- name: Make sure helper/vmdns role is not used in production environments + ansible.builtin.assert: + that: (vmdns_server | d(none) is none) + or + (prod_env | d(false) | bool is false) + fail_msg: > + The helper/vmdns role produces a solution that cannot be considered secure. + Please do not use it in production environments. + For non-production environments, ensure the prod_env variable is set to false or undefined. From 43af8f02b56682453388ef56478e0594bd547dfd Mon Sep 17 00:00:00 2001 From: Michal Opala Date: Fri, 31 Jul 2026 14:57:40 +0200 Subject: [PATCH 5/6] M #-: Apply linter sugesstions (fix) Signed-off-by: Michal Opala --- roles/helper/hosts/tasks/main.yml | 1 + roles/precheck/pre_reboot/tasks/security.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/roles/helper/hosts/tasks/main.yml b/roles/helper/hosts/tasks/main.yml index 4ad0b002..5420cab9 100644 --- a/roles/helper/hosts/tasks/main.yml +++ b/roles/helper/hosts/tasks/main.yml @@ -62,4 +62,5 @@ ansible.builtin.command: cmd: tee --append /etc/hosts stdin: "{{ _new_lines | join(_lf) }}" + changed_when: true when: _new_lines | count > 0 diff --git a/roles/precheck/pre_reboot/tasks/security.yml b/roles/precheck/pre_reboot/tasks/security.yml index deb7d7e1..8cd63242 100644 --- a/roles/precheck/pre_reboot/tasks/security.yml +++ b/roles/precheck/pre_reboot/tasks/security.yml @@ -59,4 +59,4 @@ fail_msg: > The helper/vmdns role produces a solution that cannot be considered secure. Please do not use it in production environments. - For non-production environments, ensure the prod_env variable is set to false or undefined. + For non-production environments, ensure the prod_env variable is set to false or left undefined. From aea3dfbf0697dee0d141b6a06a1deca29250421f Mon Sep 17 00:00:00 2001 From: Michal Opala Date: Fri, 31 Jul 2026 15:19:09 +0200 Subject: [PATCH 6/6] M #-: Correctly handle empty VM list (fix) Signed-off-by: Michal Opala --- roles/helper/vmdns/files/refresh.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/helper/vmdns/files/refresh.rb b/roles/helper/vmdns/files/refresh.rb index 203e3d4a..f3c35ac9 100644 --- a/roles/helper/vmdns/files/refresh.rb +++ b/roles/helper/vmdns/files/refresh.rb @@ -60,7 +60,7 @@ def refresh(hosts_file = '/etc/hosts') end end[:lines] end.then do |unmanaged| - document.dig('VM_POOL', 'VM')&.each_with_object([]) do |vm, acc| + document.dig('VM_POOL', 'VM').to_a.each_with_object([]) do |vm, acc| ip = [vm.dig('TEMPLATE', 'NIC')].flatten.dig(0, 'IP') next unless ip