The previous fix made the install convergent but not idempotent: it keyed "changed" off uv printing "Installed", and uv prints that every single run. musicmouse is a local path dependency, so uv rebuilds and reinstalls it every time regardless. Every ansible run therefore restarted the service and made it rescan the whole library for nothing. Decide from the device instead: the commit the checkout landed on, recorded in the venv, plus whether the venv can import the package at all. That keeps the convergence the handler lacked - an empty venv reinstalls even when the commit matches - without touching anything on a run where nothing moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
221 lines
8.4 KiB
YAML
221 lines
8.4 KiB
YAML
---
|
|
- name: Packages
|
|
ansible.builtin.apt:
|
|
name:
|
|
- git
|
|
- libvlc5 # runtime lib python-vlc (installed into the venv by uv) binds against
|
|
- vlc-plugin-base
|
|
- alsa-utils
|
|
- samba
|
|
# Pillow ships no armv7 wheel and Raspberry Pi OS's userland is 32-bit, so it is
|
|
# compiled from source here and needs its image headers. (piwheels would have a
|
|
# prebuilt one, but only for the system Python, not the 3.13 uv installs.)
|
|
- libjpeg-dev
|
|
- zlib1g-dev
|
|
cache_valid_time: 7200
|
|
|
|
- name: Ensure uv install directory exists
|
|
ansible.builtin.file:
|
|
path: "/opt/uv-{{ pi_musicmouse_uv_version }}-{{ pi_musicmouse_uv_target }}"
|
|
state: directory
|
|
mode: "0755"
|
|
|
|
- name: Download and extract uv {{ pi_musicmouse_uv_version }}
|
|
ansible.builtin.unarchive:
|
|
src: "https://github.com/astral-sh/uv/releases/download/{{ pi_musicmouse_uv_version }}/uv-{{ pi_musicmouse_uv_target }}.tar.gz"
|
|
dest: "/opt/uv-{{ pi_musicmouse_uv_version }}-{{ pi_musicmouse_uv_target }}"
|
|
remote_src: true
|
|
extra_opts: ["--strip-components=1"]
|
|
creates: "/opt/uv-{{ pi_musicmouse_uv_version }}-{{ pi_musicmouse_uv_target }}/uv"
|
|
|
|
- name: Symlink uv/uvx into /usr/local/bin
|
|
ansible.builtin.file:
|
|
src: "/opt/uv-{{ pi_musicmouse_uv_version }}-{{ pi_musicmouse_uv_target }}/{{ item }}"
|
|
dest: "/usr/local/bin/{{ item }}"
|
|
state: link
|
|
force: true
|
|
loop:
|
|
- uv
|
|
- uvx
|
|
|
|
- name: Verify uv runs on this host
|
|
ansible.builtin.command:
|
|
cmd: /usr/local/bin/uv --version
|
|
register: pi_musicmouse_uv_version_out
|
|
changed_when: false
|
|
|
|
- name: Checkout Musicmouse repo
|
|
ansible.builtin.git:
|
|
repo: "{{ pi_musicmouse_repo }}"
|
|
dest: /opt/musicmouse
|
|
version: "{{ musicmouse_version }}"
|
|
accept_hostkey: true
|
|
force: true
|
|
register: pi_musicmouse_checkout
|
|
|
|
- name: Check the checkout has the layout this role knows
|
|
ansible.builtin.stat:
|
|
path: /opt/musicmouse/python-backend/pyproject.toml
|
|
register: pi_musicmouse_pyproject
|
|
# Meaningless under --check on a host that has never been deployed: the git task
|
|
# above reports "changed" without writing anything, so there is nothing to stat.
|
|
when: not ansible_check_mode
|
|
|
|
- name: Fail on a pre-rearchitecture checkout
|
|
ansible.builtin.fail:
|
|
msg: >-
|
|
{{ musicmouse_version }} has no python-backend/pyproject.toml, so it predates the
|
|
rearchitecture and this role cannot install it - the venv, the systemd unit and
|
|
the web/ build all live somewhere else on those commits. Point
|
|
musicmouse_version at a branch that has it (deploy/musicdolphin), or set
|
|
mediapi_install_kidsmusic to false for this host and leave whatever is installed
|
|
alone. Stopping here rather than failing three tasks deeper with the real reason
|
|
buried.
|
|
when: not ansible_check_mode and not pi_musicmouse_pyproject.stat.exists
|
|
|
|
- name: Create virtualenv with Python {{ pi_musicmouse_python_version }}
|
|
ansible.builtin.command:
|
|
cmd: "/usr/local/bin/uv venv --python {{ pi_musicmouse_python_version }} /opt/musicmouse/.venv"
|
|
creates: /opt/musicmouse/.venv/bin/python
|
|
|
|
# Installing the backend is deliberately not a handler hanging off the checkout.
|
|
# Tied to the checkout, one failed run strands the device: the notification is dropped
|
|
# when the play fails, the next run finds the checkout already current and notifies
|
|
# nothing, and the venv stays empty while the service crash-loops on
|
|
# ModuleNotFoundError. Instead, decide from what is actually on the device - the commit
|
|
# it has checked out, and whether the venv can import the package at all.
|
|
#
|
|
# uv's own output cannot answer this: `musicmouse` is a local path dependency, so every
|
|
# run rebuilds and reinstalls it and always prints "Installed 1 package". Keying off
|
|
# that restarts the service on every run and rescans the whole library for nothing.
|
|
- name: Read which commit the venv was built from
|
|
ansible.builtin.command:
|
|
cmd: cat /opt/musicmouse/.venv/.installed-commit
|
|
register: pi_musicmouse_stamp
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Check the venv can actually import the backend
|
|
ansible.builtin.command:
|
|
cmd: /opt/musicmouse/.venv/bin/python -c "import musicmouse"
|
|
register: pi_musicmouse_importable
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Install the backend into the venv
|
|
ansible.builtin.command:
|
|
cmd: "/usr/local/bin/uv pip install --python /opt/musicmouse/.venv/bin/python /opt/musicmouse/python-backend"
|
|
when: >-
|
|
pi_musicmouse_importable.rc != 0
|
|
or pi_musicmouse_stamp.stdout | trim != pi_musicmouse_commit
|
|
changed_when: true # guarded by `when`, so reaching this means it installed something
|
|
notify: Restart musicmouse
|
|
|
|
- name: Record which commit the venv was built from
|
|
ansible.builtin.copy:
|
|
content: "{{ pi_musicmouse_commit }}\n"
|
|
dest: /opt/musicmouse/.venv/.installed-commit
|
|
mode: "0644"
|
|
|
|
- name: Create media directory
|
|
ansible.builtin.file:
|
|
path: /media/musicmouse
|
|
state: directory
|
|
mode: "0755"
|
|
|
|
- name: Install config file
|
|
ansible.builtin.copy:
|
|
src: "{{ pi_musicmouse_config_file }}"
|
|
dest: /media/musicmouse/config.yml
|
|
mode: "0644"
|
|
# Install-once: the app writes this file itself (parent mode's volume keys, the
|
|
# remote-control mapping). See pi_musicmouse_force_config in defaults.
|
|
force: "{{ pi_musicmouse_force_config }}"
|
|
notify: Restart musicmouse
|
|
|
|
- name: Install tippen curriculum
|
|
ansible.builtin.copy:
|
|
src: "{{ pi_musicmouse_curriculum_file }}"
|
|
dest: /media/musicmouse/tippen-curriculum.yml
|
|
mode: "0644"
|
|
when: pi_musicmouse_curriculum_file | length > 0
|
|
notify: Restart musicmouse
|
|
|
|
- name: Install systemd service file (from the checked-out repo, not a copy kept here)
|
|
ansible.builtin.copy:
|
|
src: /opt/musicmouse/python-backend/musicmouse.service
|
|
dest: /etc/systemd/system/musicmouse.service
|
|
mode: "0644"
|
|
remote_src: true
|
|
notify: Restart musicmouse
|
|
|
|
- name: Add script to autostart and start now
|
|
ansible.builtin.systemd:
|
|
name: musicmouse
|
|
state: started
|
|
enabled: "yes"
|
|
daemon_reload: "yes"
|
|
|
|
- name: Ensure local frontend build cache directory exists
|
|
ansible.builtin.file:
|
|
path: "{{ pi_musicmouse_build_cache }}/{{ musicmouse_version | regex_replace('/', '_') }}"
|
|
state: directory
|
|
mode: "0755"
|
|
delegate_to: localhost
|
|
become: false
|
|
vars:
|
|
# `become: false` above is not enough on its own. pi_standard_setup's defaults set
|
|
# `ansible_become: true`, role defaults are play-wide host variables, and the
|
|
# `ansible_become` *variable* outranks the `become` *keyword* in Ansible's
|
|
# precedence - so every task here tried to sudo on the control machine. Those
|
|
# defaults are needed to bootstrap a fresh Pi (connect as `pi`, become root, enable
|
|
# root login), so override the variable here rather than removing them.
|
|
ansible_become: false
|
|
|
|
- name: Checkout Musicmouse repo for frontend build (control machine)
|
|
ansible.builtin.git:
|
|
repo: "{{ pi_musicmouse_repo }}"
|
|
dest: "{{ pi_musicmouse_build_cache }}/{{ musicmouse_version | regex_replace('/', '_') }}"
|
|
version: "{{ musicmouse_version }}"
|
|
accept_hostkey: true
|
|
force: true
|
|
delegate_to: localhost
|
|
become: false
|
|
vars:
|
|
ansible_become: false # see the first delegated task in tasks/main.yml
|
|
notify: Build frontend
|
|
|
|
- name: Check whether the device has a built frontend
|
|
ansible.builtin.stat:
|
|
path: /opt/musicmouse/web/dist/index.html
|
|
register: pi_musicmouse_dist
|
|
|
|
- name: Queue a frontend build when the device has none
|
|
# The build chain hangs off the control machine's checkout changing, which has the
|
|
# same hole the backend install had: a run that fails after that checkout leaves the
|
|
# Pi with no dist, and every later run finds the checkout current and rebuilds
|
|
# nothing. Cheap to check, and it only fires when the device really has no UI.
|
|
ansible.builtin.debug:
|
|
msg: "No frontend at /opt/musicmouse/web/dist - queueing a build"
|
|
when: not pi_musicmouse_dist.stat.exists
|
|
changed_when: true
|
|
notify: Build frontend
|
|
|
|
- name: Samba setup
|
|
ansible.builtin.copy:
|
|
src: smb.conf
|
|
dest: /etc/samba/
|
|
mode: "0644"
|
|
|
|
- name: Restart samba
|
|
ansible.builtin.systemd:
|
|
name: smbd
|
|
state: restarted
|
|
enabled: "yes"
|
|
daemon_reload: "yes"
|
|
# manual steps:
|
|
# - set samba passwords with smbpasswd
|
|
# - copy music into /media/musicmouse: scripts/sync-library.sh in the app repo, which
|
|
# also carries the cover and analysis cache over (or use the samba share, and let
|
|
# the device spend the DSP time itself)
|