Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ jobs:
name: manifest

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
# Stay out of /releases/latest until publish-manifest has uploaded
# the per-arch images + rpi-imager-manifest (~30 min). odio.love
Expand Down Expand Up @@ -170,7 +170,7 @@ jobs:
name: manifest

- name: Create GitHub Pre-Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: pr-${{ github.event.pull_request.number }}
name: "PR #${{ github.event.pull_request.number }} — ${{ needs.build.outputs.version }}"
Expand Down Expand Up @@ -231,16 +231,24 @@ jobs:
test-playbook:
needs: build-test-image
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- action: test
rerun: rerun
- action: test-as-other-user
rerun: rerun-as-other-user
steps:
- uses: actions/checkout@v5

- name: Run playbook test
run: ./tests/test.sh
- name: Run playbook (${{ matrix.action }})
run: ./tests/test.sh ${{ matrix.action }}
env:
REMOTE_IMAGE: ${{ needs.build-test-image.outputs.image }}

- name: Re-run playbook (idempotence)
run: ./tests/test.sh rerun
run: ./tests/test.sh ${{ matrix.rerun }}

- name: Clean
if: always()
Expand Down Expand Up @@ -291,7 +299,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
mode: [install, install-root]
mode: [install, install-root, install-as-other-user]
steps:
- uses: actions/checkout@v5

Expand Down Expand Up @@ -540,7 +548,7 @@ jobs:
merge-multiple: true

- name: Upload images and per-arch manifests to release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.tag.outputs.value }}
files: |
Expand Down Expand Up @@ -581,7 +589,7 @@ jobs:
run: ./scripts/build-rpi-manifests.py odio.rpi-imager-manifest entries/manifest-*/*.json

- name: Upload combined manifest to release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.tag.outputs.value }}
files: odio.rpi-imager-manifest
Expand Down
8 changes: 8 additions & 0 deletions image-builder/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export ODIOS_USER="odio"
export XZ_COMPRESSION_LEVEL=9
export XZ_THREADS=0 # 0 = use all available cores

# Extra packages to install on the image (beyond what Ansible deploys).
# Currently: yq (Python kislyuk, jq-syntax) — used by odios-firstboot-network.sh
# to inject the discovered snapweb URL into odio-api's config.yaml.
# shellcheck disable=SC2034 # sourced by build.sh
INSTALL_PACKAGES=(
yq
)

# Packages to purge from the base image (not needed for headless audio)
# shellcheck disable=SC2034 # sourced by build.sh
PURGE_PACKAGES=(
Expand Down
15 changes: 15 additions & 0 deletions image-builder/files/odios-firstboot-network.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[Unit]
Description=odios first-boot network-dependent fixups
After=network-online.target avahi-daemon.service
Wants=network-online.target avahi-daemon.service
ConditionPathExists=!/var/lib/odios/firstboot-network-done

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/odios-firstboot-network.sh
StandardOutput=append:/var/log/odios-firstboot-network.log
StandardError=append:/var/log/odios-firstboot-network.log

[Install]
WantedBy=multi-user.target
52 changes: 52 additions & 0 deletions image-builder/files/odios-firstboot-network.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# odios-firstboot-network.sh — Network-dependent post cloud-init fixups
# Runs once after network-online + avahi-daemon are up. Discovers the
# snapcast server via snapclientmpris and injects the snapweb URL into
# odio-api's config so the UI can link to it.
set -euo pipefail

MARKER="/var/lib/odios/firstboot-network-done"
ODIOS_USER="odio"
ODIOS_HOME="/home/${ODIOS_USER}"
ODIO_API_CONF="${ODIOS_HOME}/.config/odio-api/config.yaml"

echo "odios-firstboot-network: started at $(date)"

# ─── Discover snapweb URL and inject into odio-api config ───────────────────

if ! command -v snapclientmpris &>/dev/null; then
echo "odios-firstboot-network: snapclientmpris not installed, skipping snapweb discovery"
elif [[ ! -f "$ODIO_API_CONF" ]]; then
echo "odios-firstboot-network: ${ODIO_API_CONF} not found, skipping snapweb discovery"
else
echo "odios-firstboot-network: discovering snapweb URL via snapclientmpris..."
discover_output=$(runuser -u "$ODIOS_USER" -- snapclientmpris --discover 2>&1 || true)
# Match `snapweb:` anchored at line start, allow zero-or-more spaces (mirrors
# the ansible-side guard in roles/odio_api/tasks/main.yml), keep only the
# first match (-m1) so multiple snapcast servers can't produce a multi-line
# value that would later corrupt the YAML edit.
snapweb_url=$(printf '%s\n' "$discover_output" \
| grep -m1 -oE '^snapweb:[[:space:]]*[^[:space:]]+' \
| sed -E 's/^snapweb:[[:space:]]*//' || true)
if [[ -z "$snapweb_url" ]]; then
echo "odios-firstboot-network: no snapweb URL discovered, output was:"
echo "$discover_output" | sed 's/^/ /'
elif [[ ! "$snapweb_url" =~ ^https?://[^[:space:]]+$ ]]; then
echo "odios-firstboot-network: discarding invalid snapweb URL '${snapweb_url}', output was:"
echo "$discover_output" | sed 's/^/ /'
snapweb_url=""
fi
if [[ -n "$snapweb_url" ]]; then
echo "odios-firstboot-network: snapweb URL = ${snapweb_url}"
runuser -u "$ODIOS_USER" -- env SNAPWEB_URL="$snapweb_url" \
yq -yi '(.systemd.user[] | select(.name == "snapclient.service")).url = env.SNAPWEB_URL' \
"$ODIO_API_CONF"
echo "odios-firstboot-network: restarting odio-api..."
runuser -u "$ODIOS_USER" -- env XDG_RUNTIME_DIR="/run/user/$(id -u "$ODIOS_USER")" \
systemctl --user restart odio-api.service || true
fi
fi

mkdir -p "$(dirname "$MARKER")"
touch "$MARKER"
echo "odios-firstboot-network: done"
10 changes: 10 additions & 0 deletions image-builder/lib/provision.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,21 @@ PYTHONPATH="vendor" python3 vendor/bin/ansible-playbook \\
--connection=local
PROVISION

log_info "Installing extra packages: ${INSTALL_PACKAGES[*]}"
chroot "$rootfs" apt-get install -y --no-install-recommends "${INSTALL_PACKAGES[@]}"

log_info "Installing firstboot script and vendor-data..."
cp "$SCRIPT_DIR/files/odios-firstboot.sh" "$rootfs/usr/local/bin/odios-firstboot.sh"
chmod 755 "$rootfs/usr/local/bin/odios-firstboot.sh"
cp "$SCRIPT_DIR/files/vendor-data" "$rootfs/boot/firmware/vendor-data"

log_info "Installing network-dependent firstboot service..."
cp "$SCRIPT_DIR/files/odios-firstboot-network.sh" "$rootfs/usr/local/bin/odios-firstboot-network.sh"
chmod 755 "$rootfs/usr/local/bin/odios-firstboot-network.sh"
cp "$SCRIPT_DIR/files/odios-firstboot-network.service" \
"$rootfs/etc/systemd/system/odios-firstboot-network.service"
chroot "$rootfs" systemctl enable odios-firstboot-network.service

log_info "Purging unnecessary packages..."
local purge_list
purge_list=$(chroot "$rootfs" dpkg -l "${PURGE_PACKAGES[@]}" 2>/dev/null \
Expand Down
3 changes: 3 additions & 0 deletions installer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,9 @@ Tests `install.sh` from a GitHub release inside a systemd container:
# As root with TARGET_USER=odio — system installation case
./tests/test.sh install-root pr-5

# As bob (NOPASSWD sudoer) with TARGET_USER=odio — second-admin case
./tests/test.sh install-as-other-user pr-5

# Against the latest stable release
./tests/test.sh install latest
```
Expand Down
3 changes: 3 additions & 0 deletions installer/ansible/group_vars/all/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ gpg_file: "/usr/share/keyrings/odio.gpg"
odio_version: "unknown"
mpd_music_directory: "/media/USB"
mpd_mympd_http_port: "8080"
# Standard MPD user socket (matches the packaged mpd.socket unit's
# ListenStream=%t/mpd/socket); consumed by mpDris2 and mpd_discplayer.
mpd_socket_path: "/run/user/{{ target_user_uid }}/mpd/socket"

# Core components (default: enabled)
install_pulseaudio: true
Expand Down
8 changes: 8 additions & 0 deletions installer/ansible/playbook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
msg: "target_user must not be root"
when: target_user == "root"

# Computed early so pre_tasks (remote_tmp) and every role can gate become
# on it. False when the invoker already is target_user — no become needed.
- name: Detect become requirement for target user
ansible.builtin.set_fact:
become_for_target_user: "{{ target_user != ansible_user_id }}"

- name: Ensure dbus running
ansible.builtin.systemd:
name: dbus.service
Expand Down Expand Up @@ -44,6 +50,7 @@
group: "{{ target_user }}"
mode: '0700'
become: true
when: become_for_target_user

- name: Get target user UID
ansible.builtin.command:
Expand Down Expand Up @@ -108,6 +115,7 @@
path: "/home/{{ target_user }}/.ansible/tmp"
state: absent
become: true
when: become_for_target_user

- name: Display installation summary
ansible.builtin.debug:
Expand Down
8 changes: 4 additions & 4 deletions installer/ansible/roles/branding/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
become: true
become: "{{ become_for_target_user }}"

- name: Install odio-motd script
ansible.builtin.copy:
Expand All @@ -15,7 +15,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
become: true
become: "{{ become_for_target_user }}"

- name: Hook odio-motd into ~/.profile
ansible.builtin.blockinfile:
Expand All @@ -29,7 +29,7 @@
case $- in
*i*) [ -x "$HOME/.local/bin/odio-motd" ] && "$HOME/.local/bin/odio-motd" ;;
esac
become: true
become: "{{ become_for_target_user }}"

- name: Suppress system motd for odio user
ansible.builtin.file:
Expand All @@ -40,4 +40,4 @@
mode: '0644'
modification_time: preserve
access_time: preserve
become: true
become: "{{ become_for_target_user }}"
2 changes: 1 addition & 1 deletion installer/ansible/roles/branding/vars/main.yml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
---
branding_version: "2026.5.0b1"
branding_version: "2026.5.0b3"
10 changes: 6 additions & 4 deletions installer/ansible/roles/common/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,14 @@
become: true

# acl is required by ansible-core's `become_user` when the invoking user, root,
# and target_user are three different identities — ansible drops tmp files via
# setfacl, and falls back to the BSD `chmod +a` syntax (invalid on Linux) when
# the package is absent.
# and target_user are three different identities: ansible drops tmp files via
# setfacl.
- name: Install acl (needed by become_user)
ansible.builtin.apt:
name: acl
state: present
become: true
when: target_user != ansible_user_id

- name: Ensure required groups exist
ansible.builtin.group:
Expand Down Expand Up @@ -91,10 +91,12 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
become: true
become: "{{ become_for_target_user }}"
loop:
- "/home/{{ target_user }}/.config/systemd/user"
- "/home/{{ target_user }}/.config/systemd/user/default.target.wants"
- "/home/{{ target_user }}/.config/systemd/user/sockets.target.wants"
- "/home/{{ target_user }}/.config/systemd/user/timers.target.wants"

- name: Check linger status
ansible.builtin.stat:
Expand Down
2 changes: 1 addition & 1 deletion installer/ansible/roles/common/vars/main.yml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
---
common_version: "2026.5.0b1"
common_version: "2026.5.0b3"
Binary file added installer/ansible/roles/mpd/files/odio-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions installer/ansible/roles/mpd/files/odio.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>odio</title>
<script>location.replace("http://" + location.hostname + ":8018/ui")</script>
6 changes: 3 additions & 3 deletions installer/ansible/roles/mpd/handlers/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
become: true
become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"

Expand All @@ -17,7 +17,7 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
become: true
become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"

Expand All @@ -28,6 +28,6 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
become: true
become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
Loading