From 938cdd7db343b212282fa30e7d799524f58e4e22 Mon Sep 17 00:00:00 2001 From: "martin.fencl" Date: Mon, 19 Oct 2026 14:35:32 +0200 Subject: [PATCH] feat: update Valheim deployment playbook to include WebMap installation and configuration --- docker-compose/docker-compose-valheim.yml | 32 +-- update_valheim.yml | 257 +++++++++++++++------- 2 files changed, 177 insertions(+), 112 deletions(-) diff --git a/docker-compose/docker-compose-valheim.yml b/docker-compose/docker-compose-valheim.yml index 82a993c..4390f4d 100644 --- a/docker-compose/docker-compose-valheim.yml +++ b/docker-compose/docker-compose-valheim.yml @@ -1,43 +1,32 @@ # Portainer stack name: valheim # Target: pve2_vm / vm-portainer-services -# Create the host directories and password file before the first deployment. -# Configuration lives here; the password is NOT stored in Git or Portainer ENV. +# The game service, data paths, secrets, backups and UDP ports are unchanged. name: valheim - services: valheim: image: ghcr.io/community-valheim-tools/valheim-server:latest container_name: valheim restart: unless-stopped stop_grace_period: 2m - cap_add: - SYS_NICE - ports: - "2456-2457:2456-2457/udp" - + # Host TCP 8095 -> ValheimWebMap internal TCP 3696. + # This is an unauthenticated map; restrict ingress via firewall/proxy. + - "8095:3696/tcp" environment: TZ: "Europe/Prague" - # The game process uses these numeric IDs, not the bootstrap process. - # They need not be the host login user's IDs. PUID: "1000" PGID: "1000" - SERVER_NAME: "Fencl Valheim" WORLD_NAME: "Fenheilm" SERVER_PORT: "2456" SERVER_PUBLIC: "false" CROSSPLAY: "false" SERVER_PASS_FILE: "/run/secrets/valheim_server_pass" - - # No periodic game updates/restarts inside the container. - # A container start still checks/downloads the game through SteamCMD. - # Run update_valheim.yml in your chosen maintenance window. UPDATE_CRON: "" RESTART_CRON: "" - - # Built-in world backups, hourly at minute 05, retained for 7 days. BACKUPS: "true" BACKUPS_CRON: "5 * * * *" BACKUPS_DIRECTORY: "/config/backups" @@ -45,13 +34,10 @@ services: BACKUPS_MAX_COUNT: "168" BACKUPS_IF_IDLE: "true" BACKUPS_ZIP: "true" - VALHEIM_PLUS: "false" - BEPINEX: "false" - # Do not publish 9001: the Portainer agent already uses it on this VM. + BEPINEX: "true" SUPERVISOR_HTTP: "false" STATUS_HTTP: "false" - volumes: - type: bind source: /data/compose/valheim/config @@ -74,10 +60,6 @@ services: read_only: true bind: create_host_path: false - - # Local readiness: supervised game process AND an open game/query UDP socket. - # This does not test the router/firewall or an actual player connection. - # The helpers ship in this image and also support non-public servers. healthcheck: test: - CMD @@ -91,15 +73,11 @@ services: timeout: 10s retries: 5 start_period: 30m - logging: driver: json-file options: max-size: "10m" max-file: "3" - -# Docker allocates this bridge network's subnet and the container address. -# Clients use the VM's LAN/public address and UDP port 2456, not this private IP. networks: default: driver: bridge diff --git a/update_valheim.yml b/update_valheim.yml index fd4c100..6754837 100644 --- a/update_valheim.yml +++ b/update_valheim.yml @@ -1,10 +1,8 @@ --- -# update_valheim.yml -# Requires Docker Compose >= 2.18 and Python 3 on pve2_vm. -# Uses ansible.builtin only, like the command-based Minecraft update workflow. -# A run ALWAYS restarts Valheim, even if the image did not change. -# Do not run concurrently with another update or a Portainer redeployment. -- name: Deploy or update Valheim +# 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 @@ -12,16 +10,18 @@ vars: compose_local_dir: "{{ playbook_dir }}/docker-compose" compose_remote_base: "/home/{{ ansible_user }}/.ansible-compose" - # NOT the shared /docker-compose directory removed by other playbooks. compose_remote_dir: "{{ compose_remote_base }}/valheim" - valheim_compose_filename: "docker-compose-valheim.yml" + 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_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 - # Read paths/UID/GID from the controller's compose file to avoid duplication. valheim_compose: >- {{ lookup('ansible.builtin.file', compose_local_dir ~ '/' ~ valheim_compose_filename) | from_yaml }} valheim_spec: "{{ valheim_compose.services[valheim_service] }}" @@ -32,6 +32,8 @@ {{ (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/config/com.valheimwebmap.cfg" valheim_compose_argv: - docker - compose @@ -43,11 +45,19 @@ - "{{ compose_remote_dir }}/{{ valheim_compose_filename }}" pre_tasks: - - name: Reject check mode before making changes + - 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: - - not ansible_check_mode - fail_msg: "This maintenance playbook does not support --check. Use --syntax-check instead." + - 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: @@ -55,32 +65,28 @@ register: valheim_compose_version changed_when: false - - name: Require Compose with wait support + - 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', '>=') - fail_msg: "Install/update the Docker Compose plugin on pve2_vm (minimum 2.18.0)." - - name: Inspect the persistent password file without reading its contents + - 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 a real password file on the VM + - 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 }} on pve2_vm first. - It must contain ONLY the server password on one line, at least 5 characters, - not SERVER_PASS=... . See README-valheim.md. The playbook never generates or overwrites it. + fail_msg: "Create {{ valheim_password_file }} first; it must contain the password on one line." - - name: Validate the password without logging it + - name: Validate password without disclosing it ansible.builtin.command: argv: - python3 @@ -91,22 +97,15 @@ try: password = Path(sys.argv[1]).read_text(encoding="utf-8").rstrip("\r\n") except (OSError, UnicodeError): - raise SystemExit("Cannot read the password file as UTF-8.") + 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("Use a single plain password of at least 5 characters.") + raise SystemExit("Invalid password file format") - "{{ valheim_password_file }}" changed_when: false no_log: true - - name: Restrict password ownership and permissions to the configured game UID - ansible.builtin.file: - path: "{{ valheim_password_file }}" - owner: "{{ valheim_uid }}" - group: "{{ valheim_gid }}" - mode: "0600" - - - name: Inspect an existing Valheim container + - name: Inspect existing container ansible.builtin.command: argv: [docker, container, inspect, "{{ valheim_container }}"] register: valheim_inspect @@ -115,71 +114,74 @@ valheim_inspect.rc != 0 and 'No such' not in valheim_inspect.stderr no_log: true - - name: Save only the existing container metadata in Ansible memory + - 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: Refuse to take over a container from a different project or service + - 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.project') == valheim_project - >- - (valheim_existing.get('Config', {}).get('Labels') or {}).get('com.docker.compose.service') - == valheim_service - fail_msg: "Container name is occupied. The existing container must belong to stack/service valheim." + (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 a silent switch to different persistent data or password paths + - 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 }}. - Migrate the data deliberately before changing the compose bind paths. + 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: Ensure the dedicated remote compose directory exists + - 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" + mode: '0755' - - name: Ensure the writable host bind directories exist + - 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" + mode: '0750' loop: "{{ valheim_spec.volumes }}" when: not (item.read_only | default(false) | bool) loop_control: label: "{{ item.source }}" - - name: Ensure the offline pre-update backup directory exists + - name: Ensure offline backup directory exists ansible.builtin.file: path: "{{ valheim_preupdate_dir }}" state: directory - mode: "0755" + mode: '0755' - - name: Upload only the Valheim compose file + - 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" + mode: '0644' - - name: Validate the compose configuration before interrupting the game + - name: Validate Compose configuration before stopping the game ansible.builtin.command: argv: "{{ valheim_compose_argv + ['config', '--quiet'] }}" changed_when: false @@ -187,26 +189,54 @@ - name: Pull the image while the old server is still running ansible.builtin.command: argv: "{{ valheim_compose_argv + ['pull', valheim_service] }}" - # Pull output is not a stable machine-readable change indicator. changed_when: false - - name: Stop and make a consistent offline config/world backup + - 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 the existing Valheim container + - name: Gracefully stop only existing Valheim container ansible.builtin.command: - argv: [docker, stop, --time, "120", "{{ valheim_container }}"] + argv: [docker, stop, --time, '120', "{{ valheim_container }}"] when: valheim_existing.get('State', {}).get('Running', false) changed_when: true - - name: Allocate a unique pre-update archive filename + - 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" + suffix: .tar.gz register: valheim_backup - - name: Back up config and worlds, excluding the separate automatic backup mount + - name: Back up the config and world directory without automatic backup mount ansible.builtin.command: argv: - tar @@ -219,20 +249,57 @@ - "{{ valheim_config_dir }}" - . changed_when: true - rescue: - - name: Abort rather than update without a completed backup + - name: Fail without deploying a new container on backup failure ansible.builtin.fail: msg: >- - Stop/backup failed; the replacement container was not deployed. - The original container may now be stopped. It is NOT automatically restarted, - because starting it can itself trigger a Steam game update without a valid backup. - Check docker ps and disk space on pve2_vm; an incomplete archive may remain. - Fix the backup failure and run this playbook again. + 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: Recreate Valheim and verify local readiness and the startup Steam check + - name: Install WebMap and recreate Valheim block: - - name: Recreate only Valheim and wait for its healthcheck + - 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', @@ -240,44 +307,61 @@ valheim_service] }} changed_when: true - - name: Read new-container startup logs without exposing them in Semaphore + - 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 Steam update completed rather than falling back to old binaries + - 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: >- - The container is healthy, but no successful Steam update marker was found. - Inspect docker logs valheim on the VM: Steam may have failed and the image - may be using its previous game installation, or upstream log wording changed. + 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: Report failure without destructive rollback or dumping potentially sensitive logs + - name: Fail safely without attempting an automatic downgrade ansible.builtin.fail: msg: >- - Valheim readiness or Steam-update verification failed. - Inspect 'docker ps -a --filter name=valheim' and 'docker logs --tail 150 valheim' - on pve2_vm. The pre-update config/world archive is {{ valheim_backup.path }}. - No data directories were deleted and no automatic downgrade/restore was attempted. + 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 offline archives only after a successful deployment + - name: Find expired pre-update archives after successful deployment only ansible.builtin.find: paths: "{{ valheim_preupdate_dir }}" - patterns: "config-*.tar.gz" + patterns: config-*.tar.gz age: "{{ valheim_preupdate_keep_days }}d" age_stamp: mtime file_type: file recurse: false register: valheim_old_backups - - name: Remove only expired Valheim pre-update archives + - name: Remove expired Valheim pre-update archives ansible.builtin.file: path: "{{ item.path }}" state: absent @@ -285,9 +369,12 @@ loop_control: label: "{{ item.path }}" - - name: Report completion + - name: Report successful deployment ansible.builtin.debug: msg: - - "Valheim passed local health and startup Steam-update checks." + - "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 }}" - - "Join using the VM address and UDP port 2456. Router/firewall reachability is not tested." + - "WebMap DLL SHA256: {{ webmap_dll_hash.stdout }}" + - "No external reachability test performed."