--- # 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://:{{ 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."