Files
ansible/update_semaphore.yml
T

174 lines
6.4 KiB
YAML

# update_semaphore.yml
- name: Update Semaphore (self-update safe)
hosts: pve2_vm
gather_facts: false
vars:
# Compose sync (controller -> target)
compose_local_dir: "{{ playbook_dir }}/docker-compose"
compose_remote_base: "/home/{{ ansible_user }}/.ansible-compose"
compose_remote_dir: "{{ compose_remote_base }}/docker-compose"
compose_remote_archive: "{{ compose_remote_base }}/docker-compose.tar.gz"
# Semaphore settings
semaphore_project: semaphore
semaphore_container: semaphore
semaphore_compose_filename: "docker-compose-semaphore.yml"
# Published port on the VM. NOT 3000 — that only exists in the container.
semaphore_port: 3008
semaphore_update_log: "{{ compose_remote_base }}/semaphore-update.log"
tasks:
# This job gets killed when the container is recreated, so the final
# verification lives in the log and is reported on the next run.
- name: Show result of the previous update
ansible.builtin.command:
argv: [tail, -n, "12", "{{ semaphore_update_log }}"]
register: prev_log
changed_when: false
failed_when: false
- name: Previous update result
ansible.builtin.debug:
var: prev_log.stdout_lines
# Env vars override /etc/semaphore/config.json. A stray SEMAPHORE_DB_*
# points Semaphore at a different database; a stray
# SEMAPHORE_ACCESS_KEY_ENCRYPTION makes every credential undecryptable.
- name: Refuse compose that overrides DB or encryption config
ansible.builtin.assert:
that:
- forbidden | length == 0
fail_msg: "Compose sets forbidden env vars: {{ forbidden }}. Remove them."
success_msg: "Compose does not override DB or encryption config."
vars:
forbidden: >-
{{ lookup('file', compose_local_dir ~ '/' ~ semaphore_compose_filename)
| regex_findall('(?m)^\s*(SEMAPHORE_DB_\w+|SEMAPHORE_ACCESS_KEY_ENCRYPTION)\s*:') }}
delegate_to: localhost
run_once: true
- name: Ensure remote base directory exists
ansible.builtin.file:
path: "{{ compose_remote_base }}"
state: directory
mode: "0755"
- name: Create local archive of docker-compose directory (controller)
ansible.builtin.archive:
path: "{{ compose_local_dir }}/"
dest: "/tmp/docker-compose.tar.gz"
format: gz
delegate_to: localhost
run_once: true
- name: Upload archive to remote host
ansible.builtin.copy:
src: "/tmp/docker-compose.tar.gz"
dest: "{{ compose_remote_archive }}"
mode: "0644"
- name: Recreate remote compose directory
ansible.builtin.file:
path: "{{ compose_remote_dir }}"
state: absent
- name: Ensure remote compose directory exists
ansible.builtin.file:
path: "{{ compose_remote_dir }}"
state: directory
mode: "0755"
- name: Extract archive on remote host
ansible.builtin.unarchive:
src: "{{ compose_remote_archive }}"
dest: "{{ compose_remote_dir }}"
remote_src: true
# Online snapshot via the sqlite backup API — consistent without stopping
# the server. Reads the DB path from the live config instead of guessing.
- name: Back up Semaphore database
ansible.builtin.command:
argv:
- docker
- exec
- "{{ semaphore_container }}"
- python3
- -c
- |
import json, os, sqlite3, datetime, glob
cfg = json.load(open('/etc/semaphore/config.json'))
out = '/etc/semaphore/backups'
os.makedirs(out, exist_ok=True)
ts = datetime.datetime.utcnow().strftime('%Y%m%d-%H%M%S')
dst = f'{out}/database-{ts}.sqlite'
a = sqlite3.connect('file:' + cfg['sqlite']['host'] + '?mode=ro', uri=True)
b = sqlite3.connect(dst)
with b:
a.backup(b)
b.close(); a.close()
json.dump(cfg, open(f'{out}/config-{ts}.json', 'w'), indent=2)
print(dst, os.path.getsize(dst), 'bytes')
for old in sorted(glob.glob(f'{out}/database-*.sqlite'), reverse=True)[10:]:
os.remove(old)
register: sem_db_backup
changed_when: true
- name: Database backup result
ansible.builtin.debug:
var: sem_db_backup.stdout_lines
- name: Pull latest Semaphore image
community.docker.docker_compose_v2:
project_name: "{{ semaphore_project }}"
project_src: "{{ compose_remote_dir }}"
files:
- "{{ semaphore_compose_filename }}"
pull: always
# Cannot use docker_compose_v2 here: recreating the container kills this
# very Ansible process. Detached so the update outlives the job.
# No `recreate: always` — compose recreates only when the image or the
# resolved config actually changed, so unchanged runs finish green.
- name: Recreate Semaphore in the background
ansible.builtin.shell: |
cat > "{{ compose_remote_base }}/semaphore-selfupdate.sh" <<'SH'
#!/usr/bin/env bash
set -uo pipefail
exec 2>&1
cd "{{ compose_remote_dir }}" || exit 1
echo "=== $(date -Is) update starting ==="
docker compose -p "{{ semaphore_project }}" \
-f "{{ semaphore_compose_filename }}" \
up -d --remove-orphans --wait --wait-timeout 300
for _ in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' \
"http://127.0.0.1:{{ semaphore_port }}/api/ping" || true)
if [ "$code" = "200" ]; then
docker logs --tail 40 "{{ semaphore_container }}" 2>&1 \
| grep -m1 'SQLite @' || echo "WARN: no 'SQLite @' line"
echo "OK: semaphore healthy at $(date -Is)"
exit 0
fi
sleep 5
done
echo "FAIL: no 200 from /api/ping after 300s" >&2
exit 1
SH
chmod +x "{{ compose_remote_base }}/semaphore-selfupdate.sh"
setsid nohup "{{ compose_remote_base }}/semaphore-selfupdate.sh" \
> "{{ semaphore_update_log }}" 2>&1 < /dev/null &
echo launched
args:
executable: /bin/bash
changed_when: true
- name: What happens next
ansible.builtin.debug:
msg:
- "Update launched detached. If the image changed, this job dies here."
- "That is expected — the log is the success signal, not the task status."
- "tail -n 20 {{ semaphore_update_log }}"