diff --git a/update_semaphore.yml b/update_semaphore.yml index 22bae85..ee19a3a 100644 --- a/update_semaphore.yml +++ b/update_semaphore.yml @@ -15,14 +15,12 @@ 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_port: 3000 semaphore_update_log: "{{ compose_remote_base }}/semaphore-update.log" + semaphore_update_delay: 20 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 }}"] @@ -34,9 +32,6 @@ 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: @@ -87,8 +82,6 @@ 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: @@ -102,7 +95,7 @@ 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') + ts = datetime.datetime.now(datetime.timezone.utc).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) @@ -113,6 +106,10 @@ print(dst, os.path.getsize(dst), 'bytes') for old in sorted(glob.glob(f'{out}/database-*.sqlite'), reverse=True)[10:]: os.remove(old) + stale = f"{out}/config-{os.path.basename(old)[9:-7]}.json" + if os.path.exists(stale): + os.remove(stale) + print('pruned', old) register: sem_db_backup changed_when: true @@ -120,15 +117,6 @@ 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 - - - name: Recreate Semaphore in the background ansible.builtin.shell: | cat > "{{ compose_remote_base }}/semaphore-selfupdate.sh" <<'SH' @@ -137,17 +125,27 @@ exec 2>&1 cd "{{ compose_remote_dir }}" || exit 1 COMPOSE=(docker compose -p "{{ semaphore_project }}" -f "{{ semaphore_compose_filename }}") + + # Let the calling job finish and its status get written to the DB + # before we potentially replace the container running it. + sleep {{ semaphore_update_delay }} + echo "=== $(date -Is) update starting ===" "${COMPOSE[@]}" pull || { echo "FAIL: pull failed" >&2; exit 1; } + + # No --force-recreate: compose replaces the container only when the + # image or the resolved config (including env_file) actually changed, + # so unchanged runs cause no downtime. "${COMPOSE[@]}" 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 echo "image: $(docker inspect "{{ semaphore_container }}" \ --format '{{ '{{' }}.Config.Image{{ '}}' }}')" - # --tail 40 misses the startup line when the container was not - # recreated; grep the whole log instead + # Grep the whole log: when the container was not replaced, the + # startup line is far outside the last few lines. docker logs "{{ semaphore_container }}" 2>&1 \ | grep -m1 'SQLite @' || echo "WARN: no 'SQLite @' line" echo "OK: semaphore healthy at $(date -Is)" @@ -169,6 +167,6 @@ - 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 }}" + - "Update starts in {{ semaphore_update_delay }}s, after this job ends." + - "If the image changed, the container is replaced — expected." + - "The log is the success signal: tail -n 20 {{ semaphore_update_log }}"