In order to create a VM with Ansible, I first need to create an API user for Ansible to be able to interact with ProxmoxVE. Once I have a VM, I will install Plex Media Server on it.

ProxmoxVE users

I started by adding default (blank) lists for users and groups to the proxmox_virtual_environment role’s defaults/main.yaml:

pve_groups: []
pve_users: []

Creating the groups and users, in an idempotent way, requires querying the current list then adding the ones that do not exist. I did not remove ones that are not in Ansible’s list to allow for groups and users managed outside of its control (and I don’t have to worry about builtin root user this way):

- name: Proxmox groups exist
  # Cluster wide configuration
  run_once: true
  block:
    - name: Current groups are known
      become: true
      ansible.builtin.command: /usr/sbin/pveum group list --output-format=json
      changed_when: false # Always read-only
      register: pve_group_list_out
    - name: Group exists
      become: true
      ansible.builtin.command: "/usr/sbin/pveum group add {{ item.name | quote }}{% if 'comment' in item %} --comment={{ item.comment | quote }}{% endif %}"
      loop: '{{ pve_groups }}'
      when: item.name not in pve_group_list_out.stdout | from_json | map(attribute='groupid')
- name: Proxmox users exist
  # Cluster wide configuration
  run_once: true
  block:
    - name: Current users are known
      become: true
      ansible.builtin.command: /usr/sbin/pveum user list --output-format=json
      changed_when: false  # Always read-only
      register: pve_user_list_output
    - name: Required users exist
      become: true
      ansible.builtin.command: >-
        /usr/sbin/pveum
        user add {{ pve_user_name | quote }}
        {% if 'groups' in item %}
          --groups {{ item.groups | join(',') | quote }}
        {% endif %}
        {% if 'password' in item %}
          --password {{ item.password | quote }}
        {% endif %}
      loop: '{{ pve_users }}'
      loop_control:
        label: '{{ pve_user_name }}'
      vars:
        pve_user_name: '{{ item.name }}@{{ item.realm }}'
      when: >-
        pve_user_list_output.stdout
        | from_json
        | selectattr('userid', 'eq', pve_user_name)
        | length
        == 0
      no_log: true  # Don't log passwords
    - name: Group memberships are correct
      become: true
      ansible.builtin.command: >-
        /usr/sbin/pveum
        user modify {{ pve_user_name | quote }}
        --groups {{ item.groups | join(',') | quote }}
      loop: '{{ pve_users }}'
      loop_control:
        label: '{{ pve_user_name }}'
      vars:
        pve_user_name: '{{ item.name }}@{{ item.realm }}'
      when: >-
        pve_user_list_output.stdout
        | from_json
        | selectattr('userid', 'eq', pve_user_name)
        | map(attribute='groups')
        | map('split', ',')
        | flatten
        | ansible.builtin.symmetric_difference(item.groups)
        | length
        != 0

For API users, I also decided to use token access so need to create those as well (added to the user block, above):

- name: Ansible user tokens are known
  become: true
  ansible.builtin.command: "/usr/sbin/pveum user token list {{ pve_user_name }} --output-format=json"
  changed_when: false  # Always read-only
  register: pve_user_token_list_output
  loop: '{{ pve_users }}'
  vars:
    pve_user_name: '{{ item.name }}@{{ item.realm }}'
  loop_control:
    label: '{{ pve_user_name }}'
- name: Complete list of current user tokens is initialised (even if empty)
  ansible.builtin.set_fact:
    pve_current_user_tokens: []
- name: Complete list of current user tokens is known
  ansible.builtin.set_fact:
    pve_current_user_tokens: >-
      {{
        pve_current_user_tokens
        +
        [[item.item.name, item.item.realm]] | product(item.stdout | from_json)
      }}
  loop: '{{ pve_user_token_list_output.results }}'
- name: Incorrect Tokens are removed
  become: true
  ansible.builtin.command: '/usr/sbin/pveum user token remove {{ pve_user_name | quote }} {{ item[1].tokenid | quote }}'
  loop: '{{ pve_current_user_tokens }}'
  vars:
    pve_user_name: '{{ item[0][0] }}@{{ item[0][1] }}'
  when: >-
    pve_users
    | selectattr('name', 'eq', item[0][0])
    | selectattr('realm', 'eq', item[0][1])
    | selectattr('api_tokens', 'defined')
    | map(attribute='api_tokens')
    | flatten
    | selectattr('name', 'eq', item[1].tokenid)
    | length
    == 0
- name: Complete list of user tokens required is initialised (even if empty)
  ansible.builtin.set_fact:
    pve_user_tokens: []
- name: Complete list of user tokens required is known
  ansible.builtin.set_fact:
    pve_user_tokens: >-
      {{
        pve_user_tokens
        +
        [[item.name, item.realm]] | product(item.api_tokens | default([]))
      }}
  loop: '{{ pve_users }}'
  loop_control:
    label: '{{ item.name }}@{{ item.realm }}'
- name: User tokens exist
  become: true
  ansible.builtin.command: /usr/sbin/pveum user token add {{ pve_user_name | quote }} {{ item[1].name }} --output-format=json
  loop: '{{ pve_user_tokens }}'
  vars:
    pve_user_name: '{{ item[0][0] }}@{{ item[0][1] }}'
  when: >-
    pve_current_user_tokens
    | selectattr('0', 'eq', item[0])
    | selectattr('1.tokenid', 'eq', item[1].name)
    | length
    == 0
  register: pve_user_token_add_output
- name: New token secrets are known as facts
  # Based on https://stackoverflow.com/questions/38143647/set-fact-with-dynamic-key-name-in-ansible
  ansible.builtin.set_fact:
    "pve_user_{{ item.item[0][0] }}_{{ item.item[0][1] }}_token_{{ item.item.1.name }}": "{{ (item.stdout | from_json).value }}"
  loop: "{{ pve_user_token_add_output.results | selectattr('changed', 'eq', true) }}"

Authorisation

ProxmoxVE uses role and path based authorisation. For user permissions, I used groups with roles assigned to them and use group membership to delegate those rights to individual users. I did, however, assign roles directly to tokens to limit them to just the permissions they required.

It is important to note that users must have the same permissions (which can be via group membership), when assigning permissions directly to tokens:

Additionally, privilege separated tokens can never have permissions on any given path that their associated user does not have.

For now, I stuck to the built-in roles so just need to be able to add ACLs to groups and tokens by adding entries for them. I first added ACLs for the groups (to the group create block):

- name: Complete list of group ACLs is known
  ansible.builtin.set_fact:
    pve_group_acls: >-
      {{
        pve_group_acls
        +
        [item.name] | product(item.acls)
      }}
  loop: '{{ pve_groups }}'
- name: Current ACLs are known
  become: true
  ansible.builtin.command: /usr/sbin/pveum acl list --output-format=json
  register: pve_acl_list_output
  changed_when: false # Read only command
- name: Incorrect ACLs are removed
  become: true
  ansible.builtin.command: '/usr/sbin/pveum acl delete {{ item.path | quote }} --role {{ item.roleid | quote }} --group {{ item.ugid | quote }}'
  loop: >-
    {{
      pve_acl_list_output.stdout
      | from_json
      | selectattr('type', 'eq', 'group')
    }}
  when: >-
    pve_groups
    | selectattr('name', 'eq', item.ugid)
    | map(attribute='acls')
    | flatten
    | selectattr('path', 'eq', item.path)
    | selectattr('role', 'eq', item.roleid)
    | length
    == 0
- name: ACLs are set
  become: true
  ansible.builtin.command: /usr/sbin/pveum acl modify {{ item[1].path | quote }} -group {{ item[0] | quote }} -role {{ item[1].role | quote }}
  loop: '{{ pve_group_acls }}'
  when: >-
    pve_acl_list_output.stdout
    | from_json
    | selectattr('type', 'eq', 'group')
    | selectattr('ugid', 'eq', item[0])
    | selectattr('path', 'eq', item[1].path)
    | selectattr('roleid', 'eq', item[1].role)
    | length
    == 0

And then tokens (to the user creation block):

- name: Required token ACLs are initialised (even if none required)
  ansible.builtin.set_fact:
    pve_user_token_acls: []
- name: Required token ACLs are known
  ansible.builtin.set_fact:
    pve_user_token_acls: >-
      {{
        pve_user_token_acls
        +
        [item[0] + [item[1].name]] | product(item[1].acls | default([]))
      }}
  loop: '{{ pve_user_tokens }}'
  loop_control:
    label: '{{ item[0][0] }}@{{ item[0][1] }}'
# Refresh ACLs as may have been changed by deleting redundant
# tokens (which will automatically remove related ACLs).
- name: Current ACLs are known
  become: true
  ansible.builtin.command: /usr/sbin/pveum acl list --output-format=json
  register: pve_acl_list_output
  changed_when: false # Read only command
- name: Incorrect ACLs are removed
  become: true
  ansible.builtin.command: '/usr/sbin/pveum acl delete {{ item.path | quote }} --role {{ item.roleid | quote }} --token {{ item.ugid | quote }}'
  loop: >-
    {{
      pve_acl_list_output.stdout
      | from_json
      | selectattr('type', 'eq', 'token')
    }}
  vars:
    pve_token_tuple: "{{ item.ugid.split('@') | map('split', '!') | flatten }}"
  when: >-
    pve_user_token_acls
    | selectattr('0', 'eq', pve_token_tuple)
    | selectattr('1.path', 'eq', item.path)
    | selectattr('1.role', 'eq', item.roleid)
    | length
    == 0
- name: ACLs are set
  become: true
  ansible.builtin.command: /usr/sbin/pveum acl modify {{ item[1].path | quote }} -token {{ token | quote }} -role {{ item[1].role | quote }}
  loop: '{{ pve_user_token_acls }}'
  vars:
    token: '{{ item[0][0] }}@{{ item[0][1] }}!{{ item[0][2] }}'
  when: >-
    pve_acl_list_output.stdout
    | from_json
    | selectattr('type', 'eq', 'token')
    | selectattr('ugid', 'eq', token)
    | selectattr('path', 'eq', item[1].path)
    | selectattr('roleid', 'eq', item[1].role)
    | length
    == 0

Tidying up

Finally, the additional options are added to the proxmox_virtual_environment role’s meta/argument_specs.yaml:

pve_groups:
  description: List of groups to create in PVE
  type: list
  elements: dict
  default: []
  options:
    name:
      description: Name of the group
      required: true
      type: str
    comments:
      description: Comment for the group
      required: false
      type: str
    acls:
      description: ACLs for this group
      default: []
      type: list
      elements: dict
      options:
        path:
          description: Path for the ACL
          type: str
          required: true
        role:
          description: Role the group will have on this path
          type: str
          required: true
pve_users:
  description: List of users to create in PVE
  type: list
  elements: dict
  default: []
  options:
    name:
      description: Name of the user
      type: str
      required: true
    realm:
      description: Name of PVE role for the user
      type: str
      required: true
    password:
      description: Initial password for the user
      type: str
      required: false
    groups:
      description: List of groups this user should be in
      type: list
      required: false
      elements: str
    api_tokens:
      description: |
        List of API tokens that should exist for this user.

        New tokens will have the secret stored in a fact named:
          pve_user_{ user name }_{ realm }_token_{ token name }
      type: list
      required: false
      elements: dict
      options:
        name:
          description: Name for the token (also known as token id)
          type: str
          required: true
        acls:
          description: ACLs for this token
          default: []
          type: list
          elements: dict
          options:
            path:
              description: Path for the ACL
              type: str
              required: true
            role:
              description: Role the token will have on this path
              type: str
              required: true

I added groups and then users for myself and Ansible to the proxmox_virtual_enviornment_hosts group’s group_vars file:

pve_groups:
  - name: administrators
    comment: "System Administrators"
    acls:
      - path: /
        role: Administrator
  - name: vm_admins
    comment: "VM Administrators"
    acls:
      - path: /vms
        role: PVEVMAdmin
      # Required to use the network
      - path: /sdn/zones/localnetwork/vmbr0
        role: PVESDNUser
      # Required to be able to allocated new disks on VM creation
      - path: /storage/ceph-vm
        role: PVEDatastoreAdmin
pve_users:
  - name: ansible
    realm: pve
    groups:
      - vm_admins
    api_tokens:
      - name: vm_creation
        # Additionally, privilege separated tokens can never have
        # permissions on any given path that their associated user
        # does not have.
        acls:
          - path: /vms
            role: PVEVMAdmin
          - path: /sdn/zones/localnetwork/vmbr0
            role: PVESDNUser
          # Required to be able to allocated new disks on VM creation
          - path: /storage/ceph-vm
            role: PVEDatastoreAdmin
  - name: my_user
    realm: pve
    password: >-
      {{
        lookup(
          'community.hashi_vault.vault_read',
          'kv/proxmoxve/users/my_user'
          ).data.password
      }}
    groups:
      - administrators

I manually generated and stored a password for my user in the vault.

Finally, I added code to store the ansible user’s new token secret in the vault, when it is created:

- name: Ansible user's token is stored in vault
  run_once: true  # Only need to do this once
  delegate_to: localhost
  community.hashi_vault.vault_write:
    path: kv/proxmoxve/users/ansible/tokens/vm_creation
    data:
      token: '{{ pve_user_ansible_pve_token_vm_creation }}'
  when: pve_user_ansible_pve_token_vm_creation is defined

Building a Proxmox VM with Ansible

My initial thought was to use Terraform to deploy virtual machines (VMs) and Ansible to configure the deployed VM, as I did before with Azure. However, some cursory research online suggested that Terraform’s Proxmox providers are not good so Ansible is what most people use.

For Ansible to communicate with Proxmox, I had to add proxmoxer Python module. Added it to the requirements.txt and used pip to install it in my Ansible virtual environment:

# For Proxmox API (community.general.proxmox*)
proxmoxer

I added my new VM, holoship, to inventory (once the VM existed, I also added the MAC address to its interfaces variable manually looked up from Proxmox):

holoship:
  proxmox_vm:
    disk_size_gb: 30
    cores: 2
    memory_mb: 4096
    vlan: 20

There is a Proxmox inventory source for Ansible, I have not yet thought about whether I want to use this directly.

I added a group for Proxmox VMs, which will be how I target which hosts are to be created as VMs in Proxmox:

proxmox_vms:
  hosts:
    holoship:

For the actual creation, I created a new create-vms.yaml playbook. I empirically discovered the settings required to make this match one I created interactively in the Proxmox UI. For now, I use a specific host as the API target (this will eventually be a ha-proxy front end to the entire cluster, I hope). I also hardcoded the host the VM will be created on - it can be migrated but this could be more intelligent, perhaps working the least populated host out from querying the cluster?:

- hosts: proxmox_vms
  gather_facts: false  # Don't need them
  tasks:
    - name: Create Proxmox VM
      delegate_to: localhost
      community.general.proxmox_kvm:
        api_user: "ansible@pve"
        api_token_id: vm_creation
        api_token_secret: >-
          {{
            lookup(
              'community.hashi_vault.vault_read',
              'kv/proxmoxve/users/ansible/tokens/vm_creation'
            ).data.token
          }}
        # XXX Will be haproxy front-end, when I set one up
        api_host: "pve01"
        cpu: x86-64-v2-AES
        bios: ovmf
        cores: '{{ proxmox_vm.cores | default(1) }}'
        efidisk0:
          efitype: 4m
          format: raw
          # Setting this to true implicitly enables secure boot
          pre_enrolled_keys: true
          storage: ceph-vm
        ide:
          ide2: none,media=cdrom
        memory: '{{ proxmox_vm.memory_mb | default(2048) }}'
        name: '{{ inventory_hostname }}'
        net:
          net0: virtio,bridge=vmbr0,firewall=1{% if 'vlan' in proxmox_vm %},tag={{ proxmox_vm.vlan }}{% endif %}
        # XXX Hardcoded to create VMs on a single host
        node: "pve01"
        onboot: true
        ostype: l26
        scsi:
          scsi0: ceph-vm:{{ proxmox_vm.disk_size_gb | default(20) }},format=raw,iothread=1
        scsihw: virtio-scsi-single
        state: present
        storage: ceph-vm # Storage pool for the disk
        tpmstate0:
          storage: ceph-vm

Installing Plex on the VM

Once the VM was installed, I had to disable secure boot to enable it to PXE boot from my existing network (working on finding a secure-boot compatible alternative) then selected the pre-seeded Debian install from the menu. I then ran my existing install playbook against the new VM, which set it up including remotely-unlockable encryption, creating the ansible user and securing the root account, both with machine-specific random passwords.

I created a new group called plex_media_servers and added the new VM to it:

plex_media_servers:
  hosts:
    holoship:

In my site.yaml, I applied a new role (which I am about to create) called plex-media-server to this group:

- name: Plex media servers are setup
  hosts: plex_media_servers
  roles:
    - plex-media-server

Mounting the NAS storage

My media is stored on a NAS - I used to run Plex directly on the NAS but it kept running out of memory corrupting the NAS’s underlying BTRFS requiring a complete rebuild and restore from backup. Although I am moving Plex onto a different server, the media files are still on the NAS and need to be mounted for Plex to use.

I already had a task to add arbitrary custom mounts to hosts with Ansible, however it does not create the groups (specifically the store group I use for the NAS mounts) embedded in the mount options, so I added the creation of extra groups to the Linux.yaml task file in my existing common role:

- name: Additional groups exist
  become: true
  ansible.builtin.group:
    name: '{{ item[6:] }}'
  loop: "{{ q('ansible.builtin.varnames', '^group_.+') }}"
  # Skip the built in special variable
  when: item != 'group_names'

My common role is one of the first ones I write, and could do with some updating - for example, it was written when I was using my own account rather than a dedicated ansible account for Ansible to login and so copied my preferred .vimrc file to the home directory of whichever user Ansible logs in as, which is no longer ideal.

In my plex_media_server’s group_vars file I added an entry for the group, copied the mounts from another host and adjusted the /var volume to be larger (I discovered after building it, with the default size that Plex would run out of disk space on this filesystem while transcoding):

---
# (various mount_ variables, one for each the NAS shares)
group_store:
# Plex needs a large /var for its database and transcoding cache
filesystems_lvm_volume_groups:
  - name: vg_
    logical_volumes:
      - name: var
        size: 15G
...

plex-media-server role

Firstly, the Plex package repositories need to be available so I added them to the new role’s meta/main.yaml:

---
dependencies:
  - role: apt-source
    vars:
      name: plexmediaserver
      uri: "{{ local_mirror['plex'].uri | default('https://downloads.plex.tv/') }}repo/deb/"
      gpg_key:
        url: "{{ local_mirror['plex'].uri | default('https://downloads.plex.tv/') }}plex-keys/PlexSign.key"
      suite: public
      components:
        - main
      src:
        no_src: yes
    when: ansible_facts['os_family'] == 'Debian'
...

Installing Plex

The plex-media-servers role’s tasks/main.yaml begins with a simple package install:

- name: Handlers are flushed (so any new repos are synced)
  meta: flush_handlers
- name: Plex is installed
  become: true
  ansible.builtin.package:
    name: plexmediaserver
    state: present

I then added the plex user (created by the package) to the store group (I mentioned above) - as noted in the comment, this does not belong in the role as it is site-specific but I have not yet figured out the best way to sort this sort of thing (in some cases, users will need to be in groups for Ansible to do things and others, like this, Ansible will need to do something for the group to exist):

# XXX this doesn't belong here - site specific - how to do this "right"?
- name: Plex user is in store group
  become: true
  ansible.builtin.user:
    append: true # Adding to group, not removing existing groups
    name: plex
    groups:
      - store

The service needs to be running:

- name: Plex server is running
  become: true
  ansible.builtin.service:
    name: plexmediaserver
    enabled: true
    state: started

and the firewall configured to allow access:

# plex service come with firewalld
# XXX Want to make this better (properly zoned, not just opened to everything via default zone)
- name: firewalld is configured to allow connecting to plex
  block:
    - name: firewalld role is included (for handler)
      ansible.builtin.include_role:
        name: firewalld
    - name: Plex service is permitted
      become: yes
      ansible.posix.firewalld:
        service: plex
        permanent: true
        state: enabled
      notify: reload firewalld

Claiming the install

Finally, the install can be registered interactively (by navigating to the service’s web UI) or with a claim code - whether the install is registered or not can be determined by checking for the presence of PlexOnlineHome property on the Preferences tag in the Preferences.xml file:

- name: Plex preference file is read
  become: true
  ansible.builtin.slurp:
    src: /var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Preferences.xml
  register: plex_preferences_xml
- name: Plex preferences is converted to Ansible data structure
  ansible.builtin.set_fact:
    plex_preferences: >-
      {{
        plex_preferences_xml.content
        | ansible.builtin.b64decode
        | ansible.utils.from_xml
      }}
#- ansible.builtin.debug: var=plex_preferences
- name: Plex server is registered
  block:
    - name: Claim key is set
      ansible.builtin.assert:
        that: plex_claim_key is defined
        msg: plex_claim_key required to register system
    - name: Register server
      ansible.builtin.uri:
        method: POST
        url: http://127.0.0.1:32400/myplex/claim?token={{ plex_claim_key }}
        return_content: true
      register: plex_claim_output
    - ansible.builtin.debug: var=plex_claim_output
  when: "'@PlexOnlineHome' not in plex_preferences.Preferences"

The plex_claim_key option I added to the role’s meta/argument_specs.yaml file:

---
argument_specs:
  main:
    short_description: Installs and configures Plex Media Server
    author: Laurence Alexander Hurst
    options:
      plex_claim_key:
        description: Claim key to join Plex media server to account
        type: str
        # As they are only valid for a short time (5 minutes), will
        # only want to supply one when joining system initially.
        required: false
...

Post install configuration

I did consider scripting, e.g., adding the “Libraries” (media stores) to Plex, however I encountered a Catch-22 - according the Plex documentation finding a X-Plex-Token to interact with the API requires looking at a media file, which one cannot do if Plex cannot yet see any media files… So I just did it by hand.