Files
ansible_fencl/update_valheim.yml
T

381 lines
15 KiB
YAML

---
# Deploy or update existing Valheim plus a pinned server-side WebMap.
# WARNING: always restarts the game and may update Steam game binaries.
# Never run concurrently with Portainer redeployments or other maintenance.
- name: Deploy or update Valheim with WebMap
hosts: pve2_vm
gather_facts: false
become: true
vars:
compose_local_dir: "{{ playbook_dir }}/docker-compose"
compose_remote_base: "/home/{{ ansible_user }}/.ansible-compose"
compose_remote_dir: "{{ compose_remote_base }}/valheim"
valheim_compose_filename: docker-compose-valheim.yml
valheim_project: valheim
valheim_service: valheim
valheim_wait_timeout: 1800
valheim_preupdate_dir: /backups/valheim/pre-update
valheim_preupdate_keep_days: 14
webmap_version: "1.4.0"
webmap_url: "https://thunderstore.io/package/download/koenhendriks/ValheimWebMap/1.4.0/"
webmap_listen_port: 3696
webmap_host_port: 8095
valheim_compose: >-
{{ lookup('ansible.builtin.file', compose_local_dir ~ '/' ~ valheim_compose_filename) | from_yaml }}
valheim_spec: "{{ valheim_compose.services[valheim_service] }}"
valheim_container: "{{ valheim_spec.container_name }}"
valheim_uid: "{{ valheim_spec.environment.PUID }}"
valheim_gid: "{{ valheim_spec.environment.PGID }}"
valheim_password_file: >-
{{ (valheim_spec.volumes | selectattr('target', 'equalto', '/run/secrets/valheim_server_pass') | first).source }}
valheim_config_dir: >-
{{ (valheim_spec.volumes | selectattr('target', 'equalto', '/config') | first).source }}
webmap_plugin_dir: "{{ valheim_config_dir }}/bepinex/plugins/ValheimWebMap"
webmap_config_file: "{{ valheim_config_dir }}/bepinex/com.valheimwebmap.cfg"
valheim_compose_argv:
- docker
- compose
- --project-name
- "{{ valheim_project }}"
- --project-directory
- "{{ compose_remote_dir }}"
- --file
- "{{ compose_remote_dir }}/{{ valheim_compose_filename }}"
pre_tasks:
- name: Reject check mode
ansible.builtin.assert:
that: [not ansible_check_mode]
fail_msg: "Maintenance does not support --check; use --syntax-check."
- name: Validate desired service settings before touching the VM
ansible.builtin.assert:
that:
- valheim_spec.environment.BEPINEX | bool
- not (valheim_spec.environment.VALHEIM_PLUS | bool)
- valheim_spec.environment.WORLD_NAME == 'Fenheilm'
- "'8095:3696/tcp' in valheim_spec.ports"
fail_msg: "Expected unchanged world Fenheilm, BepInEx enabled and TCP 8095:3696."
- name: Check Docker Compose version
ansible.builtin.command:
argv: [docker, compose, version, --short]
register: valheim_compose_version
changed_when: false
- name: Require Docker Compose 2.18+
ansible.builtin.assert:
that:
- >-
(valheim_compose_version.stdout | regex_search('[0-9]+\.[0-9]+\.[0-9]+'))
is version('2.18.0', '>=')
- name: Inspect persistent password without reading it
ansible.builtin.stat:
path: "{{ valheim_password_file }}"
get_checksum: false
get_mime: false
register: valheim_password_stat
- name: Require existing regular password file
ansible.builtin.assert:
that:
- valheim_password_stat.stat.isreg | default(false)
- not (valheim_password_stat.stat.islnk | default(false))
fail_msg: "Create {{ valheim_password_file }} first; it must contain the password on one line."
- name: Validate password without disclosing it
ansible.builtin.command:
argv:
- python3
- -c
- |
from pathlib import Path
import sys
try:
password = Path(sys.argv[1]).read_text(encoding="utf-8").rstrip("\r\n")
except (OSError, UnicodeError):
raise SystemExit("Invalid UTF-8 password file")
if (len(password) < 5 or "\n" in password or "\r" in password
or "\x00" in password or password.startswith("SERVER_PASS=")):
raise SystemExit("Invalid password file format")
- "{{ valheim_password_file }}"
changed_when: false
no_log: true
- name: Inspect existing container
ansible.builtin.command:
argv: [docker, container, inspect, "{{ valheim_container }}"]
register: valheim_inspect
changed_when: false
failed_when: >-
valheim_inspect.rc != 0 and 'No such' not in valheim_inspect.stderr
no_log: true
- name: Keep existing metadata in memory
ansible.builtin.set_fact:
valheim_existing: "{{ (valheim_inspect.stdout | from_json | first) if valheim_inspect.rc == 0 else {} }}"
no_log: true
- name: Do not take over a container belonging to another Compose service
ansible.builtin.assert:
that:
- >-
(valheim_existing.get('Config', {}).get('Labels') or {}).get('com.docker.compose.project') == valheim_project
- >-
(valheim_existing.get('Config', {}).get('Labels') or {}).get('com.docker.compose.service') == valheim_service
fail_msg: "Existing container belongs to a different project or service."
when: valheim_existing | length > 0
- name: Refuse switching existing bind paths
ansible.builtin.assert:
that:
- >-
(valheim_existing.Mounts | selectattr('Destination', 'equalto', item.target)
| map(attribute='Source') | list) == [item.source]
fail_msg: "Existing mount {{ item.target }} does not match {{ item.source }}."
loop: "{{ valheim_spec.volumes }}"
loop_control:
label: "{{ item.target }}"
when: valheim_existing | length > 0
tasks:
- name: Restrict password ownership and permissions
ansible.builtin.file:
path: "{{ valheim_password_file }}"
owner: "{{ valheim_uid }}"
group: "{{ valheim_gid }}"
mode: '0600'
- name: Ensure isolated remote Compose directory exists
ansible.builtin.file:
path: "{{ compose_remote_dir }}"
state: directory
owner: "{{ ansible_user }}"
mode: '0755'
- name: Ensure writable bind directories exist without replacing their contents
ansible.builtin.file:
path: "{{ item.source }}"
state: directory
owner: "{{ valheim_uid }}"
group: "{{ valheim_gid }}"
mode: '0750'
loop: "{{ valheim_spec.volumes }}"
when: not (item.read_only | default(false) | bool)
loop_control:
label: "{{ item.source }}"
- name: Ensure offline backup directory exists
ansible.builtin.file:
path: "{{ valheim_preupdate_dir }}"
state: directory
mode: '0755'
- name: Upload only Valheim Compose file
ansible.builtin.copy:
src: "{{ compose_local_dir }}/{{ valheim_compose_filename }}"
dest: "{{ compose_remote_dir }}/{{ valheim_compose_filename }}"
owner: "{{ ansible_user }}"
mode: '0644'
- name: Validate Compose configuration before stopping the game
ansible.builtin.command:
argv: "{{ valheim_compose_argv + ['config', '--quiet'] }}"
changed_when: false
- name: Pull the image while the old server is still running
ansible.builtin.command:
argv: "{{ valheim_compose_argv + ['pull', valheim_service] }}"
changed_when: false
- name: Create staging directory outside existing game data
ansible.builtin.tempfile:
state: directory
prefix: valheim-webmap-
register: webmap_stage
- name: Download fixed version of the server-side WebMap package
ansible.builtin.get_url:
url: "{{ webmap_url }}"
dest: "{{ webmap_stage.path }}/ValheimWebMap-{{ webmap_version }}.zip"
mode: '0600'
timeout: 90
- name: Copy the safe extraction helper to staging
ansible.builtin.copy:
src: "{{ playbook_dir }}/scripts/stage_webmap.py"
dest: "{{ webmap_stage.path }}/stage_webmap.py"
mode: '0700'
- name: Validate archive contents and stage only the plugin DLL
ansible.builtin.command:
argv:
- python3
- "{{ webmap_stage.path }}/stage_webmap.py"
- "{{ webmap_stage.path }}/ValheimWebMap-{{ webmap_version }}.zip"
- "{{ webmap_stage.path }}/ValheimWebMap.dll"
register: webmap_dll_hash
changed_when: false
- name: Stop and back up existing config/world before changing plugins
block:
- name: Gracefully stop only existing Valheim container
ansible.builtin.command:
argv: [docker, stop, --time, '120', "{{ valheim_container }}"]
when: valheim_existing.get('State', {}).get('Running', false)
changed_when: true
- name: Allocate a unique offline pre-update archive path
ansible.builtin.tempfile:
state: file
path: "{{ valheim_preupdate_dir }}"
prefix: "config-{{ now(utc=true, fmt='%Y%m%dT%H%M%SZ') }}-"
suffix: .tar.gz
register: valheim_backup
- name: Back up the config and world directory without automatic backup mount
ansible.builtin.command:
argv:
- tar
- --create
- --gzip
- --file
- "{{ valheim_backup.path }}"
- --exclude=./backups
- --directory
- "{{ valheim_config_dir }}"
- .
changed_when: true
rescue:
- name: Fail without deploying a new container on backup failure
ansible.builtin.fail:
msg: >-
Stop or backup failed; no replacement deployed. Existing container may be stopped.
It is not restarted automatically because a start can trigger a Steam update.
Check disk space and repair the failure; an incomplete archive might remain.
- name: Install WebMap and recreate Valheim
block:
- name: Create persistent WebMap plugin directory
ansible.builtin.file:
path: "{{ webmap_plugin_dir }}"
state: directory
owner: "{{ valheim_uid }}"
group: "{{ valheim_gid }}"
mode: '0750'
- name: Install only the staged plugin DLL, not a full ZIP tree
ansible.builtin.copy:
src: "{{ webmap_stage.path }}/ValheimWebMap.dll"
dest: "{{ webmap_plugin_dir }}/ValheimWebMap.dll"
remote_src: true
owner: "{{ valheim_uid }}"
group: "{{ valheim_gid }}"
mode: '0644'
- name: Upload conservative WebMap config editor
ansible.builtin.copy:
src: "{{ playbook_dir }}/scripts/set_webmap_config.py"
dest: "{{ webmap_stage.path }}/set_webmap_config.py"
mode: '0700'
- name: Set WebMap's INTERNAL port to 3696 and disable player history
ansible.builtin.command:
argv:
- python3
- "{{ webmap_stage.path }}/set_webmap_config.py"
- "{{ webmap_config_file }}"
- "{{ webmap_listen_port | string }}"
register: webmap_cfg_result
changed_when: "'changed' in webmap_cfg_result.stdout"
- name: Ensure WebMap config belongs to the game user
ansible.builtin.file:
path: "{{ webmap_config_file }}"
owner: "{{ valheim_uid }}"
group: "{{ valheim_gid }}"
mode: '0640'
- name: Recreate Valheim with BepInEx and wait for existing game healthcheck
ansible.builtin.command:
argv: >-
{{ valheim_compose_argv + ['up', '--detach', '--force-recreate', '--no-deps',
'--pull', 'never', '--wait', '--wait-timeout', valheim_wait_timeout | string,
valheim_service] }}
changed_when: true
- name: Read startup logs privately
ansible.builtin.command:
argv: [docker, logs, "{{ valheim_container }}"]
register: valheim_start_logs
changed_when: false
no_log: true
- name: Confirm successful Steam check
ansible.builtin.assert:
that:
- >-
'Valheim Server was updated - restarting' in (valheim_start_logs.stdout ~ valheim_start_logs.stderr)
or 'Valheim Server is already the latest version' in (valheim_start_logs.stdout ~ valheim_start_logs.stderr)
fail_msg: "No successful Steam check found; inspect docker logs privately on the VM."
- name: Wait for WebMap's actual info endpoint on the host port
ansible.builtin.uri:
url: "http://127.0.0.1:{{ webmap_host_port }}/api/info"
method: GET
status_code: 200
timeout: 10
return_content: true
register: webmap_http
until: >-
webmap_http.status | default(0) == 200 and
'Fenheilm' in (webmap_http.content | default(''))
retries: 18
delay: 10
changed_when: false
rescue:
- name: Fail safely without attempting an automatic downgrade
ansible.builtin.fail:
msg: >-
WebMap installation or readiness failed. No automatic restore attempted.
The offline config/world archive is {{ valheim_backup.path }}.
Inspect 'docker ps -a --filter name=valheim', 'docker logs --tail 150 valheim'
and /config/bepinex logs. Existing world bind mounts were not deleted.
always:
- name: Remove staged download and scripts
ansible.builtin.file:
path: "{{ webmap_stage.path }}"
state: absent
- name: Find expired pre-update archives after successful deployment only
ansible.builtin.find:
paths: "{{ valheim_preupdate_dir }}"
patterns: config-*.tar.gz
age: "{{ valheim_preupdate_keep_days }}d"
age_stamp: mtime
file_type: file
recurse: false
register: valheim_old_backups
- name: Remove expired Valheim pre-update archives
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ valheim_old_backups.files }}"
loop_control:
label: "{{ item.path }}"
- name: Report successful deployment
ansible.builtin.debug:
msg:
- "Valheim game health + Steam check passed; WebMap /api/info returned Fenheilm."
- "WebMap URL: http://<pve2_vm>:{{ webmap_host_port }}/"
- "Internal WebMap port: {{ webmap_listen_port }}"
- "Offline config/world backup: {{ valheim_backup.path }}"
- "WebMap DLL SHA256: {{ webmap_dll_hash.stdout }}"
- "No external reachability test performed."