diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..2c90403 Binary files /dev/null and b/.DS_Store differ diff --git a/docker-compose/docker-compose-valheim.yml b/docker-compose/docker-compose-valheim.yml new file mode 100644 index 0000000..b9e9ea7 --- /dev/null +++ b/docker-compose/docker-compose-valheim.yml @@ -0,0 +1,105 @@ +# 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. +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" + + 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: "Howard Valheim" + WORLD_NAME: "Howardheim" + 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" + BACKUPS_MAX_AGE: "7" + 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. + SUPERVISOR_HTTP: "false" + STATUS_HTTP: "false" + + volumes: + - type: bind + source: /data/compose/valheim/config + target: /config + bind: + create_host_path: false + - type: bind + source: /data/compose/valheim/data + target: /opt/valheim + bind: + create_host_path: false + - type: bind + source: /backups/valheim/automatic + target: /config/backups + bind: + create_host_path: false + - type: bind + source: /home/howard/.ansible-compose/env/valheim_server_pass + target: /run/secrets/valheim_server_pass + 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 + - /bin/bash + - -c + - >- + . /usr/local/etc/valheim/defaults && + . /usr/local/etc/valheim/common && + server_is_running && server_is_listening + interval: 30s + 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 new file mode 100644 index 0000000..3198c45 --- /dev/null +++ b/update_valheim.yml @@ -0,0 +1,295 @@ +--- +# 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 + hosts: pve2_vm + gather_facts: false + become: true + + 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_project: valheim + valheim_service: valheim + valheim_wait_timeout: 1800 + valheim_preupdate_dir: "/backups/valheim/pre-update" + valheim_preupdate_keep_days: 14 + + # 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] }}" + 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 }} + 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 before making changes + ansible.builtin.assert: + that: + - not ansible_check_mode + fail_msg: "This maintenance playbook does not support --check. Use --syntax-check instead." + + - name: Check Docker Compose version + ansible.builtin.command: + argv: [docker, compose, version, --short] + register: valheim_compose_version + changed_when: false + + - name: Require Compose with wait support + 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 + 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 + 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. + + - name: Validate the password without logging 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("Cannot read the password file as UTF-8.") + 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.") + - "{{ 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 + 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: Save only the existing container metadata in Ansible 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 + 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: "Container name is occupied. The existing container must belong to stack/service valheim." + when: valheim_existing | length > 0 + + - name: Refuse a silent switch to different persistent data or password 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. + loop: "{{ valheim_spec.volumes }}" + loop_control: + label: "{{ item.target }}" + when: valheim_existing | length > 0 + + tasks: + - name: Ensure the dedicated remote compose directory exists + ansible.builtin.file: + path: "{{ compose_remote_dir }}" + state: directory + owner: "{{ ansible_user }}" + mode: "0755" + + - name: Ensure the writable host bind directories exist + 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 the offline pre-update backup directory exists + ansible.builtin.file: + path: "{{ valheim_preupdate_dir }}" + state: directory + owner: root + group: root + mode: "0700" + + - name: Upload only the 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 the compose configuration before interrupting 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] }}" + # Pull output is not a stable machine-readable change indicator. + changed_when: false + + - name: Stop and make a consistent offline config/world backup + block: + - name: Gracefully stop only the 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 pre-update archive filename + 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 config and worlds, excluding the separate 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: Abort rather than update without a completed backup + 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. + + - name: Recreate Valheim and verify local readiness and the startup Steam check + block: + - name: Recreate only Valheim and wait for its 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 new-container startup logs without exposing them in Semaphore + 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 + 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. + + rescue: + - name: Report failure without destructive rollback or dumping potentially sensitive logs + 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. + + - name: Find expired offline archives only after a successful deployment + 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 only 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 completion + ansible.builtin.debug: + msg: + - "Valheim passed local health and startup Steam-update checks." + - "Offline config/world backup: {{ valheim_backup.path }}" + - "Join using the VM address and UDP port 2456. Router/firewall reachability is not tested."