This post is about setting up my “new” desktop, an old HP 600 G4 I’ve had since 2023 but not got around to setting up, as a replacement for a 1st generation P4 desktop that my brother-in-law gave me but draws more power than everything else on my desk combined. This system will inherit the name Galaxy. I already have a fully automated process for (re)installing systems, although it needed some tweaks to work for dynamic IP addressed hosts. It was developed for my servers, and although I want to make them more dynamic the current version makes some assumptions about the host being in DNS (which my dynamic hosts are not, currently). Fortunately a lot of the groundwork for this was already done, just not backported to the (re)installation playbooks.

Fixing reinstall process for dynamic hosts

The Desktop does not have a static IP, so I had to make some changes to the (re)installation process that has previously only been used for hosts with static IP assignments and DNS entries. Thanks to other work to dynamically connect to hosts there were actually only two changes needed to support this, but I’ll explain the full reinstall process to show why only two little changes were needed…

The reinstall process uses three playbooks that are imported in sequence:

  1. reinstall.yaml

    This playbook destroys the existing partition table (on all disks with partitions), reboots the system and removes existing keys from the local (as in the host Ansible is running on) user’s SSH known_hosts file (as these will be invalid when the install has generated new ones). It then imports the install.yaml playbook as it’s last item.

    Removing the partitions forces the host to fall-through to the next boot method (as disk boot will fail) with the system’s disks in a state that will be seen as uninitialised, and hence available, by the OS installer.

    The only change to this playbook was to add the existing host discovery playbook (which also gets the SUDO password for the ansible user) that is already used by most other playbooks for finding the address of dynamic hosts with no DNS entry:

     # Get connection information for dynamic hosts
     - name: Host connection information is available
       import_playbook: discover-hosts.yaml
    

    The reinstall playbook uses the existing credentials to login to the host and prepare it for reinstallation, which are valid (as the old OS is still in place) so this all worked fine as-is.

  2. install.yaml

    This playbook configures the auto-install.ipxe configuration file for iPXE and sets up a host-specific symlink using the host’s MAC address, which it checks is specified as a variable (e.g. via the inventory) with an assert at the start, to ensure the host boots from it.

    Next, it configures the DHCP server to temporarily ignore the client ID when handing out leases to the host’s MAC address (so the IP doesn’t keep changing between the EFI PXE/iPXE/installer/installed system) and clears out all but the latest existing lease for the host’s MAC. It then waits until SSH is available on the host’s current IP (indicating the install has completed).

    Once SSH is up, it gets the host’s new SSH keys and puts them into the local (as in the host Ansible is being run on) user’s SSH known_hosts before removing the host-specific iPXE configuration symlink. It then imports the bootstrap playbook (bootstrap.yaml, see below) before, finally, removing the DHCP configuration to ignore client ID and the dynamic IP from the known_hosts (which may change after the DHCP configuration is restored).

    The install playbook doesn’t actually login to the host being installed at all (bootstrap.yaml does), it logs into the DHCP and PXE servers, so no changes were needed at all.

  3. bootstrap.yaml

    This is the one that does the initial post-install setup on the newly installed host. This one does need to login to the host, so it also needs to include the host discovery playbook. However, the discover playbook uses host facts (specifically, user_id to determine the username Ansible is logging in as) but at this stage of the (re)installation process it cannot gather facts - Python has not yet been installed and even if it had, the default login settings will probably use a username that has not yet been created (which happens later in the bootstrap playbook). It uses the fact as the most reliable way to determine the user, as there are so many ways (within and outside of Ansible’s configuration, e.g. via inventory variables, environment variables, SSH client configuration files etc.) the remote user might be set locally.

    To work around this, I added a new variable to the discover-hosts.yaml playbook (DISCOVER_SUDO_HOSTS) to allow overriding the set of hosts to lookup SUDO passwords for in that play:

     - name: sudo password is set
       # This requires fact gathering, and it's not guaranteed that we can connect
       # in some playbook workflows so allow separate skipping of this step with a
       # different hosts variable.
       hosts: "{{ DISCOVER_SUDO_HOSTS | default(DISCOVER_HOSTS) | default('all') }}:!dummy"
    

    Then, I just needed to set that variable to “not all” hosts (i.e. no hosts) when the discover playbook is imported in bootstrap.yaml:

     - name: Host connection information is available
       import_playbook: discover-hosts.yaml
       vars:
         # Ansible user won't be setup to discover facts yet, which is required
         # for sudo password lookup.
         DISCOVER_SUDO_HOSTS: '!all'
    

Installing the new host

My previous host was setup mostly manually.

I created host_vars/galaxy.yaml, moved existing settings (which consisted of just the interfaces that had the one entry with the MAC address of the network interface), added some desktop profiles (copied from my old laptop’s variables), SSH hostkey and temporarily hardcoded the DHCP IP for backup purposes:

interfaces:
  - mac_address: aa:bb:cc:dd:ee:ff  # Real MAC redacted
# Copied and pasted from defiant
desktop_profiles:
  - administration_tools
  - audio-visual
  - base
  - development
  - office
  - remote-desktop-client
  - virtualisation
  - linux_desktop_environment_awesome
backuppc_host_settings:
  # Hardcoded IP until some sort of DNS arrangement sorted out
  ClientNameAlias: "'192.168.20.158'"

My desktop profile was originally developed to be run directly as my “normal” user, on my Dell XPS 13 laptop in 2022.. As a result it, naïvely, used ansible_facts['env'] to lookup up various environment variables (like HOME and XDG_CONFIG_HOME). The problem is that this reports the environment variable’s value at the time the facts were gathered - if (as is the case now) the facts were gathered as a different user to the one we are setting up a desktop for, then the values will be incorrect - in the case of these examples, the HOME and XDG_CONFIG_HOME of the ansible user. To get around this, I added a command to find the XDG_CONFIG_HOME of each desktop user being configured and populate a fact that holds a dictionary mapping user to their directory in my desktop role’s tasks/Linux_base.yaml task file:

- name: XDG config directory is known
  become: yes
  become_user: '{{ desktop_user }}'
  ansible.builtin.shell: '[ -z "$XDG_CONFIG_HOME" ] && echo $HOME/.config || echo $XDG_CONFIG_HOME'
  register: xdg_config_dir_output
  loop: '{{ desktop_users | default([]) }}'
  loop_control:
    loop_var: desktop_user
  changed_when: false # Read operation
  check_mode: no # Run even when in check mode
- name: Dictionary of XDG config directories is initialised
  ansible.builtin.set_fact:
    desktop_xdg_config_directories: {}
- name: Dictionary of XDG config directories is populated
  ansible.builtin.set_fact:
    desktop_xdg_config_directories: >-
      {{
        desktop_xdg_config_directories
        |
        combine({
          desktop_user: xdg_config_dir_output.results | selectattr('desktop_user', 'eq', desktop_user) | map(attribute='stdout') | first
        })
      }}
  loop: '{{ desktop_users | default([]) }}'
  loop_control:
    loop_var: desktop_user

The remaining changes were all typos, a few URLs that had changed for source packages/repositories and using this new fact. The most complete example is pushing out my awesome window manager configuration files in tasks/linux_desktop_environment_awesome.yaml:

- name: "my awesome configuration files are deployed"
  become: true
  become_user: '{{ desktop_user }}'
  ansible.builtin.copy:
    dest: "{{ desktop_xdg_config_directories[desktop_user] }}/awesome/"
    src: awesome/config/
    force: yes # Overwrite existing files with server copies
  tags: ['awesome-rc']
  loop: '{{ desktop_users | default([]) }}'
  loop_control:
    loop_var: desktop_user

For replacing the lookup of HOME, I did not have anywhere where ~ did not work as an alternative - for example (from tasks/linux_lightweight_desktop_utils.yaml):

- name: Set urxvt configuration options
  become: true
  become_user: '{{ desktop_user }}'
  ansible.builtin.copy:
    dest: "~/.Xresources"
    content: |
      URxvt*font: xft:Terminus (TTF):size=10
      ! See salt-home/states/linux/desktop/utils/urxvt for transparancy options
      URxvt*background: black
      URxvt*foreground: white
      URxvt*fading: 40
      URxvt*faceColor: black
      URxvt*scrollstyle: plain
      URxvt*visualBell: True
      URxvt.saveLines: 2000
      URxvt.loginShell: True
    mode: 0o400
  loop: '{{ desktop_users | default([]) }}'
  loop_control:
    loop_var: desktop_user

I am aware there’s an inconsistency between the capitalisation of linux and Linux (e.g. linux_desktop_environment_awesome.yaml and Linux_base.yaml) - the original task files were all lowercase, then I started using (e.g.) ansible.builtin.include_tasks: '{{ ansible_facts.system }}_base.yaml' to dynamically include Linux_base.yaml and Win32NT_base.yaml on Linux and Windows systems respectively and ansible_facts.system has the first letter capitalised.

Containers

I have a number of Docker containers, which replaced manually building and installing software outside the system’s package manager, particularly for software that updated frequently. My intention was to migrate all of this to Podman, however I encountered some problems passing through DVD drives (I think it’s related to Podman’s more robust security model) so I ended up with a mixed Podman/Docker setup.

Building containers requires a large /var partition, so I added it to the list of logical_volumes overrides in group_vars/desktops.yaml. As the comment notes, I think I should restructure this variable - as the /var override is specific to containers (and so really should be in a specific group for machines expecting to be used to build containers) but the desktops group already has an override to ensure a large /home filesystem:

filesystems_lvm_volume_groups:
  - name: vg_{{ inventory_hostname }}
    logical_volumes:
      # [...]
      # XXX this is specifically for a large /var/tmp for podman builds - should be applied to that group only?
      - name: var
        size: 30G

In order to manage Docker containers, I added community.docker collection to the requirements.yaml (containers.podman was already present):

collections:
  # [...]
  - community.docker
  # [...]

Docker role

My existing Podman role is published on GitHub but my new Docker role involves adding repositories, which creates a dependency on one of my other roles for managing Debian repositories that I have not yet published - so for now, it is not published somewhere publicly.

It is, however, very straight-forward.

Argument specs and defaults

It’s only argument is the mirror to use, as my air-gapped lab requires using a local mirror so this needs to be configurable. So, the meta/argument_specs.yaml is very simple:

---
argument_specs:
  main:
    short_description: Install and setup Docker
    author: Laurence Alexander Hurst
    options:
      docker_mirror_debian_url_base:
        description: Url to use as the base for Docker Debian repositories
        type: str
        default: https://download.docker.com/linux/debian
...

The defaults/main.yaml is trivial:

---
docker_mirror_debian_url_base: https://download.docker.com/linux/debian
...

Dependency - apt-source

Configuring the meta/main.yaml to include my existing Debian repository role (recently updated to use the newer deb822 format), which will configure the repository including fetching and de-armouring the GPG key for validation, worked like this:

---
dependencies:
  - role: apt-source
    vars:
      name: docker
      uri: "{{ docker_mirror_debian_url_base }}"
      gpg_key:
        url: "{{ docker_mirror_debian_url_base }}/gpg"
      suite: '{{ ansible_facts.distribution_release }}'
      components:
        - stable
      src:
        no_src: yes
    when: ansible_facts['os_family'] == 'Debian'
...

Task file

The tasks/main.yaml file is very straight-forward, just installing Docker. Unlike Podman, I have not configured it properly for rootless use (as I’m using it for one rootful container that I am having difficulty getting Podman to run, as root or not). This is very lazy of me, really, since it should be straight-forward to port setting up the subuid/subgid from my Podman role:

---
# Currently only sets up rootful Docker - see podman role for setting up subuid/subgids for rootless access
- name: Handlers are flushed (so any new repos are synced)
  meta: flush_handlers
- name: Docker is installed
  become: true
  ansible.builtin.package:
    name:
      - docker-ce
      - docker-ce-cli
      - containerd.io
- name: containers-storage is installed on Debian
  become: true
  ansible.builtin.package:
    name: containers-storage
  when: ansible_facts.distribution == 'Debian'
...

Container building tasks

I created Podman and Docker versions of a tasks file for building images, called build-image.yaml, that pulls the base image if it is missing (unless told not to) and builds the image if the image is missing, tha base image is pulled or the base image is newer than the existing image. These were originally placed in a role that used the generated containers, but I extracted and generalised them so I could place them in the respective roles. The Podman version I added to my existing role on GitHub, the Docker version was added to the new role I just created.

You’ll see Podman tasks file has a copyright and licence header missing from my Docker version, I will add that when I publish it - it is released under the same licence (GPLv3). I also, but have not reproduced here, updated the README.md in my Podman role to document the new entry point and variables.

Argument specs and defaults

The meta/argument_specs.yaml and defaults/main.yaml files were updated for each with the arguments for these entry points. These are almost identical to give me a nice consistent interface to use regardless of which I need to use, despite (as you will see) the tasks being different:

Docker

Extra arguments that needed adding to the defaults file:

docker_image_build_user: root
docker_image_fetch_base: true
docker_image_build_args: []

and the additional section for this new entry-point to argument_specs.yaml:

build-image:
  short_description: Build images for use by docker
  author: Laurence Alexander Hurst
  options:
    docker_image_build_user:
      description: User to build container as
      type: str
      default: root
    docker_image_base_image:
      description: Name of the base image for building from
      type: str
      required: true
    docker_image_fetch_base:
      description: Fetch the base image if it does not exist (set to false if base image should have been previously built locally)
      type: bool
      default: true
    docker_image_name:
      description: Name of the image to build
      type: str
      required: true
    docker_image_container_file:
      description: Dockerfile for building the image
      type: str
      required: true
    docker_image_build_args:
      description: List of build args to pass to the build command with `--build-arg`
      type: list
      elements: dict
      options:
        name:
          description: The argument name
          type: str
          required: true
        value:
          description: The argument value
          type: str
          required: true
      default: []
Podman

Extra arguments that needed adding to the defaults file:

podman_image_fetch_base: true
podman_image_build_args: []

and the additional section for this new entry-point to argument_specs.yaml:

build-image:
  short_description: Build images for use by Podman
  author: Laurence Alexander Hurst
  options:
    podman_image_build_user:
      description: User to build container as
      type: str
      required: true
    podman_image_base_image:
      description: Name of the base image for building from
      type: str
      required: true
    podman_image_fetch_base:
      description: Fetch the base image if it does not exist (set to false if base image should have been previously built locally)
      type: bool
      default: true
    podman_image_name:
      description: Name of the image to build
      type: str
      required: true
    podman_image_container_file:
      description: Container for building the image
      type: str
      required: true
    podman_image_build_args:
      description: List of build args to pass to the build command with `--build-arg`
      type: list
      elements: dict
      options:
        name:
          description: The argument name
          type: str
          required: true
        value:
          description: The argument value
          type: str
          required: true
      default: []

build-image.yaml tasks files

These were different, as the community.docker collection and containers.podman collection have very different interfaces - particularly when it comes to building images.

However, they work the same way:

  1. Check if the base image exists
  2. If the base image does not exit, fetch it (unless <docker|podman>_image_fetch_base variable is set to false)
  3. (re)build the image if any of these are true:

    • It is missing
    • The base image was fetched
    • The base image is newer than the existing image
Docker

The Docker version of tasks/build-image.yaml, to implement the above logic using my interface (variables):

---
- name: Info is gathered on base image {{ docker_image_base_image }}
  become: true
  become_user: '{{ docker_image_build_user }}'
  community.docker.docker_image_info:
    name: '{{ docker_image_base_image }}'
  register: docker_image_base_info
- name: Base image {{ docker_image_base_image }} is fetched if missing
  become: true
  become_user: '{{ docker_image_build_user }}'
  community.docker.docker_image_pull:
    name: '{{ docker_image_base_image }}'
    pull: always
  when: >-
    docker_image_fetch_base
    and
    docker_image_base_info.images | length == 0
  register: docker_image_base_image_updated
- name: Info is gathered on base image {{ docker_image_base_image }} again (in case it was fetched)
  become: true
  become_user: '{{ docker_image_build_user }}'
  community.docker.docker_image_info:
    name: '{{ docker_image_base_image }}'
  register: docker_image_base_info
- name: Base image {{ docker_image_base_image }} exists
  ansible.builtin.assert:
    that: docker_image_base_info.images | length > 0
    fail_msg: Base image must exist to check if we need to build a new one!
- name: Info is gathered on image {{ docker_image_name }}
  become: true
  become_user: '{{ docker_image_build_user }}'
  community.docker.docker_image_info:
    name: '{{ docker_image_name }}'
  register: docker_image_info
- name: Image {{ docker_image_name }} is correct
  block:
    - name: Context folder exists
      become: true
      become_user: '{{ docker_image_build_user }}'
      ansible.builtin.tempfile:
        state: directory
      register: build_context_path
    - name: Dockerfile exists
      become: true
      become_user: '{{ docker_image_build_user }}'
      ansible.builtin.copy:
        dest: '{{ build_context_path.path }}/Dockerfile'
        content: '{{ docker_image_container_file }}'
        mode: '0600'
    - name: New {{ docker_image_name }} image is built
      become: true
      become_user: '{{ docker_image_build_user }}'
      community.docker.docker_image_build:
        name: '{{ docker_image_name }}'
        path: '{{ build_context_path.path }}'
        rebuild: always
        nocache: true
        args: "{{ dict( docker_image_build_args | map(attribute='name') | zip( docker_image_build_args | map(attribute='value') ) ) }}"
      register: docker_image_new_image_built
  always:
    - name: Build context is absent
      become: true
      become_user: '{{ docker_image_build_user }}'
      ansible.builtin.file:
        path: '{{ build_context_path.path }}'
        state: absent
      when: build_context_path is defined
  # In some edge cases (e.g. changing the base image), the base might be
  # changed but older then the previously built custom image.
  when: >-
    docker_image_base_image_updated.changed | default(False)
    or
    docker_image_info.images | length == 0
    or
    base_created_short | ansible.builtin.to_datetime(iso8601format) > image_created_short | ansible.builtin.to_datetime(iso8601format)
  vars:
    # Taken from the example at:
    # https://docs.ansible.com/ansible/latest/collections/ansible/builtin/to_datetime_filter.html
    iso8601format: '%Y-%m-%dT%H:%M:%S.%fZ'
    # shorten to microseconds, make sure there's a fractional
    # component to match format string.
    base_created_short: >-
      {{
        docker_image_base_info.images[0].Created
        | regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")
        | regex_replace("(:[0-9]+)Z$", "\1.0Z")
      }}
    image_created_short: >-
      {{
        docker_image_info.images[0].Created
        | regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")
        | regex_replace("(:[0-9]+)Z$", "\1.0Z")
      }}
...
Podman

The Podman version of tasks/build-image.yaml, to implement the above logic using my interface (variables):

---
# Copyright 2025 Laurence Alexander Hurst
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
- name: Info is gathered on base image {{ podman_image_base_image }}
  become: true
  become_user: '{{ podman_image_build_user }}'
  containers.podman.podman_image_info:
    name: '{{ podman_image_base_image }}'
  register: podman_image_base_info
- name: Base image {{ podman_image_base_image }} is fetched if missing
  become: true
  become_user: '{{ podman_image_build_user }}'
  containers.podman.podman_image:
    name: '{{ podman_image_base_image }}'
    force: true
  when: >-
    podman_image_fetch_base
    and
    podman_image_base_info.images | length == 0
  register: podman_image_base_image_updated
- name: Info is gathered on base image {{ podman_image_base_image }} again (in case it was fetched)
  become: true
  become_user: '{{ podman_image_build_user }}'
  containers.podman.podman_image_info:
    name: '{{ podman_image_base_image }}'
  register: podman_image_base_info
- name: Base image {{ podman_image_base_image }} exists
  ansible.builtin.assert:
    that: podman_image_base_info.images | length > 0
    fail_msg: Base image must exist to check if we need to build a new one!
- name: Info is gathered on image {{ podman_image_name }}
  become: true
  become_user: '{{ podman_image_build_user }}'
  containers.podman.podman_image_info:
    name: '{{ podman_image_name }}'
  register: podman_image_info
- name: Image {{ podman_image_name }} is correct
  block:
    - name: Context folder exists
      become: true
      become_user: '{{ podman_image_build_user }}'
      ansible.builtin.tempfile:
        state: directory
      register: build_context_path
    - name: New {{ podman_image_name }} image is built
      become: true
      become_user: '{{ podman_image_build_user }}'
      containers.podman.podman_image:
        name: '{{ podman_image_name }}'
        path: '{{ build_context_path.path }}'
        state: build
        force: true
        build:
          cache: false
          force_rm: true
          container_file: '{{ podman_image_container_file }}'
          extra_args: "{% for item in podman_image_build_args %}\
            --build-arg \
            {{ item.name | ansible.builtin.quote }}\
            =\
            {{ item.value | ansible.builtin.quote }}\
            {% if not loop.last %} {% endif %}\
            {% endfor %}"
      register: podman_image_new_image_built
  always:
    - name: Build context is absent
      become: true
      become_user: '{{ podman_image_build_user }}'
      ansible.builtin.file:
        path: '{{ build_context_path.path }}'
        state: absent
      when: build_context_path is defined
  # In some edge cases (e.g. changing the base image), the base might be
  # changed but older then the previously built custom image.
  when: >-
    podman_image_base_image_updated.changed | default(False)
    or
    podman_image_info.images | length == 0
    or
    base_created_short | ansible.builtin.to_datetime(iso8601format) > image_created_short | ansible.builtin.to_datetime(iso8601format)
  vars:
    # Taken from the example at:
    # https://docs.ansible.com/ansible/latest/collections/ansible/builtin/to_datetime_filter.html
    iso8601format: '%Y-%m-%dT%H:%M:%S.%fZ'
    # shorten to microseconds, make sure there's a fractional
    # component to match format string.
    base_created_short: >-
      {{
        podman_image_base_info.images[0].Created
        | regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")
        | regex_replace("(:[0-9]+)Z$", "\1.0Z")
      }}
    image_created_short: >-
      {{
        podman_image_info.images[0].Created
        | regex_replace("([^.]+)(\.\d{6})(\d*)(.+)", "\1\2\4")
        | regex_replace("(:[0-9]+)Z$", "\1.0Z")
      }}
...