diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d0254..dcb4719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,14 @@ # Changelog -## Unreleased +## 0.0.12 - unreleased +- New `upgrade` command to change an instance's server version, up or down: + - `terractl upgrade ` lists the zips in `dist/` for its type, marking the installed, pinned and newest ones. The `terraria` group can use it without `sudo`. + - `sudo terractl upgrade |latest` checks the zip first (right type, readable by `terraria`, contains the server, passes `unzip -t`), stops the server (saving the world), backs up its worlds and configs to `instances/.backups/`, pins `ZIP=`, unpacks the zip right away and starts the server again if it was running. If unpacking fails, the instance is left as it was. `--no-backup` skips the backup. + - Going to an older version needs `--downgrade`, because worlds saved by a newer version can't be opened by an older one. + - Bash completion offers the instance's zips. Works with `--host`. +- `reinstall` now always unpacks the **same** version again. Before, an instance with a blank `ZIP=` picked the newest zip in `dist/`, so `reinstall` could silently upgrade it. It now pins the zip the instance runs first, and refuses if that zip is gone from `dist/`. It also keeps the record of the installed version (`server/.installed`) until the new unpack is done. +- `terractl` run by a relative path (e.g. `sudo ./bin/terractl prepare ` from a source tree) no longer fails to switch to the `terraria` user. - New `fix-perms` command: `sudo terractl fix-perms ` gives every file of an instance back to the `terraria` user and reapplies the usual modes (group can read, others nothing; server binaries executable), e.g. after copying worlds in as root. Symlinks are not followed. Works with `--host` too. - `list` warns about zips in `dist/` that the `terraria` user can't read (e.g. copied in with `scp -p` from a private file), and `info` lists them as `unreadable_zip=`. A server that fails to install because of one now says how to fix it (`sudo chmod 644 `) instead of just "cannot read", and a missing zip is reported as not found. diff --git a/README.md b/README.md index fac9325..ae12737 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ sudo terractl attach calamity - **Many servers, one host.** Each instance is `terraria@.service`, with its own directory, port, world and mods. - **Vanilla and tModLoader side by side**, chosen per instance. -- **No manual installs.** Drop the official server zips into `/var/lib/terraria/dist`; they're unpacked on demand, and each instance can be pinned to a specific version. +- **No manual installs.** Drop the official server zips into `/var/lib/terraria/dist`; they're unpacked on demand, and each instance can be pinned to a specific version. `terractl upgrade` checks a new zip, backs up the worlds and switches version in one step. - **Safe shutdowns.** Stopping a server saves the world first. - **A live console.** `terractl attach` shows the server's output and lets you type commands into it; `terractl watch` just shows the output. - **Mod management.** `terractl mods` installs, enables and removes mods, with correct file ownership. diff --git a/VERSION b/VERSION index 2cfabea..8cbf02c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.11 +0.0.12 diff --git a/bin/terractl b/bin/terractl index 102fb7c..b5c4109 100755 --- a/bin/terractl +++ b/bin/terractl @@ -19,6 +19,7 @@ set -euo pipefail # user may not be allowed into, e.g. /root). Paths given on the command line are # resolved against where terractl was called from, via abspath(). CALLER_PWD=$PWD +SELF=$(readlink -f "$0") # before the cd, in case $0 is a relative path cd / abspath() { @@ -77,7 +78,10 @@ Admin commands: attach interactive console (Ctrl-D to detach) watch [-n ] [-q] follow the server's output, read-only (Ctrl-C to quit) cmd send a console command (e.g. save, say hi) - reinstall [--clean] re-unpack server on next start + upgrade show the server zips it can use, and which it runs + upgrade |latest [--downgrade] [--no-backup] + change its server version now (backs up worlds first) + reinstall [--clean] re-unpack the same server version on next start (--clean also re-downloads tModLoader's .NET) fix-perms give all of an instance's files back to the '$SVC_USER' user, with the usual modes (run as root) @@ -140,6 +144,7 @@ write_instance_conf() { cat > "$dir/instance.conf" < TYPE=$type # vanilla | tmodloader @@ -221,8 +226,9 @@ install_server() { dir=$(inst_dir "$name") marker="$dir/server/.installed" - # Already installed with the right type (and pinned zip, if any)? Nothing to do. - if [[ -f $marker ]]; then + # Already installed with the right type (and pinned zip, if any), and not + # asked to unpack it again ('reinstall')? Nothing to do. + if [[ -f $marker && ! -e $dir/server/.reinstall ]]; then read -r have_type have_zip < "$marker" || true if [[ $have_type == "$TYPE" ]] && [[ -z $ZIP || $have_zip == "$(basename "$ZIP")" ]]; then return 0 @@ -1101,8 +1107,49 @@ cmd_list() { zip_warning } +# ------------------------------------------------------------ versions ---- +# The zip an instance runs is recorded in server/.installed when it's unpacked. +# A pin (ZIP= in instance.conf) that differs from it makes the next start (the +# 'prepare' step) unpack the pinned zip instead. + +zip_pattern() { + case $TYPE in + vanilla) echo 'terraria-server-*.zip' ;; + tmodloader) echo 'tModLoader*.zip' ;; + esac +} + +# The zips in dist/ for this instance's TYPE, oldest first +type_zips() { + find "$DIST_DIR" -maxdepth 1 -type f -name "$(zip_pattern)" -printf '%f\n' | sort -V +} + +# The zip server/ was unpacked from (file name only), or nothing +installed_zip() { + local marker type="" zip="" + marker="$(inst_dir "$1")/server/.installed" + if [[ -r $marker ]]; then read -r type zip < "$marker" || true; fi + echo "$zip" +} + +# instance.conf is sourced by bash, so only plain file names go into it +safe_zip_name() { [[ $1 =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; } + +set_zip_pin() { + local conf; conf="$(inst_dir "$1")/instance.conf" + if grep -q '^ZIP=' "$conf"; then + sed -i "s|^ZIP=.*|ZIP=$2|" "$conf" + else + printf 'ZIP=%s\n' "$2" >> "$conf" + fi + fix_owner "$conf" +} + +# Unpacks the same zip again on the next start. An unpinned instance is pinned +# to the zip it runs first, as otherwise it would pick the newest zip in dist/ +# and so change version; changing version is what 'upgrade' is for. cmd_reinstall() { - local name=$1 clean=0 dir; dir=$(inst_dir "$name") + local name=$1 clean=0 dir installed; dir=$(inst_dir "$name") shift case ${1:-} in --clean) clean=1 ;; @@ -1113,7 +1160,19 @@ cmd_reinstall() { if [[ $clean -eq 1 ]] && instance_running "$name"; then die "'$name' is running; --clean would delete .NET under it. Stop it first: $(hint group stop "$name")" fi - rm -f "$dir/server/.installed" + load_conf "$name" + installed=$(installed_zip "$name") + if [[ -z $ZIP && -n $installed ]]; then + [[ -f $DIST_DIR/$installed ]] || + die "'$name' runs $installed, which is no longer in $DIST_DIR. Put it back to unpack it again, or change version: $(hint root upgrade "$name")" + safe_zip_name "$installed" || die "can't pin '$installed' in instance.conf; rename it in $DIST_DIR and use: $(hint root upgrade "$name" "")" + set_zip_pin "$name" "$installed" + log "pinned ZIP=$installed in instance.conf, so the same version is unpacked again" + elif [[ -n $ZIP && -n $installed && $(basename "$ZIP") != "$installed" ]]; then + log "note: instance.conf pins ZIP=$ZIP, but $installed is installed; the next start unpacks $(basename "$ZIP")" + fi + # Keeps .installed, so what it runs is still known (e.g. to 'upgrade') + if [[ -d $dir/server ]]; then touch "$dir/server/.reinstall"; fix_owner "$dir/server/.reinstall"; fi if [[ $clean -eq 1 ]]; then rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs" echo "removed the downloaded .NET runtime and logs; .NET will be downloaded again" @@ -1121,6 +1180,155 @@ cmd_reinstall() { echo "server/ will be re-unpacked on next start: $(hint group restart "$name")" } +upgrade_list() { + local z joined newest="" + local -a zips=() note=() + mapfile -t zips < <(type_zips) + if [[ ${#zips[@]} -gt 0 ]]; then newest=${zips[-1]}; fi + echo "'$name' ($TYPE) runs: ${installed:-nothing yet (unpacked on its first start)}" + if [[ ${#zips[@]} -eq 0 ]]; then + echo "No '$(zip_pattern)' zips in $DIST_DIR" + return 0 + fi + printf '%-40s %s\n' ZIP NOTE + for z in "${zips[@]}"; do + note=() + if [[ $z == "$installed" ]]; then note+=(installed); fi + if [[ -n $ZIP && $z == "${ZIP##*/}" ]]; then note+=(pinned); fi + if [[ $z == "$newest" ]]; then note+=(newest); fi + if ! svc_can_read "$DIST_DIR/$z"; then note+=("unreadable by $SVC_USER"); fi + joined=$(printf '%s, ' "${note[@]}") + printf '%-40s %s\n' "$z" "${joined%, }" | sed 's/ *$//' + done + if [[ -n $installed && ! -f $DIST_DIR/$installed ]]; then + printf '%-40s %s\n' "$installed" "installed, no longer in $DIST_DIR" + fi + echo "Change version with: $(hint root upgrade "$name" "|latest")" +} + +# Changes the server version (zip) of an instance, up or down, and applies it +# right away: checks the zip, stops the server, backs up its worlds and +# configs, pins ZIP=, unpacks the zip and starts the server again. Anything +# that fails before the new server/ is in place leaves the instance as it was. +cmd_upgrade() { + local name=$1; shift + local target="" downgrade=0 backup=1 arg dir installed zip f listing lower verb state + local was_running=0 installed_name old_pin backup_file="" unit="terraria@$name" + for arg in "$@"; do + case $arg in + --downgrade) downgrade=1 ;; + --no-backup) backup=0 ;; + -*) die "unknown option '$arg'" ;; + *) [[ -z $target ]] || die "more than one zip given"; target=$arg ;; + esac + done + dir=$(inst_dir "$name") + [[ -f $dir/instance.conf ]] || die "no instance '$name'" + load_conf "$name" + installed=$(installed_zip "$name") + if [[ -z $target ]]; then upgrade_list; return 0; fi + [[ $EUID -eq 0 ]] || die "changing version needs root: sudo terractl upgrade $name $*" + + # Check the zip before changing anything + if [[ $target == latest ]]; then + zip=$(type_zips | tail -n 1) + [[ -n $zip ]] || die "no '$(zip_pattern)' zips in $DIST_DIR" + elif [[ $target == */* ]]; then + [[ $(dirname "$(abspath "$target")") -ef $DIST_DIR ]] || + die "$target isn't in $DIST_DIR; copy it there first" + zip=$(basename "$target") + else + zip=$target + fi + # shellcheck disable=SC2053 # the pattern is a glob on purpose + [[ $zip == $(zip_pattern) ]] || die "$zip isn't a $TYPE zip (expected $(zip_pattern))" + safe_zip_name "$zip" || die "rename $zip to use only letters, digits and ._+- (it's written into instance.conf)" + f="$DIST_DIR/$zip" + [[ -f $f ]] || die "$f not found. Available: $(hint group upgrade "$name")" + svc_can_read "$f" || die "the '$SVC_USER' user can't read $f; fix it with: sudo chmod 644 $f" + + if [[ $zip == "$installed" ]]; then + if [[ ${ZIP##*/} != "$zip" ]]; then set_zip_pin "$name" "$zip"; log "pinned ZIP=$zip in instance.conf"; fi + echo "'$name' already runs $zip" + return 0 + fi + if [[ -n $installed ]]; then + lower=$(printf '%s\n' "$installed" "$zip" | sort -V | head -n 1) + if [[ $lower == "$zip" ]]; then + verb=downgraded + if [[ $downgrade -eq 0 ]]; then + die "$zip is older than $installed, which '$name' runs now. Worlds saved by a newer version" \ + "can't be opened by an older one, and mods may not load. To go ahead anyway: $(hint root upgrade "$name" "$zip" --downgrade)" + fi + else + verb=upgraded + fi + else + verb=switched + fi + + command -v unzip >/dev/null || die "unzip is not installed" + listing=$(unzip -Z1 "$f" 2>/dev/null) || die "$f isn't a readable zip (damaged, or not fully downloaded?)" + case $TYPE in + vanilla) grep -q '/Linux/TerrariaServer\.bin\.x86_64$' <<< "$listing" || + die "$f has no Linux/TerrariaServer.bin.x86_64; is it the dedicated server zip?" ;; + tmodloader) grep -qE '(^|/)start-tModLoaderServer\.sh$' <<< "$listing" || + die "$f has no start-tModLoaderServer.sh; is it a tModLoader release zip?" ;; + esac + log "checking $zip..." + unzip -tqq "$f" >/dev/null 2>&1 || die "$f is damaged (unzip -t found errors); download it again" + + state=$(systemctl is-active "$unit" 2>/dev/null || true) + if [[ $state == inactive || $state == failed ]] && instance_running "$name"; then + die "'$name' is running outside systemd (e.g. 'terractl run' in another terminal); stop it first" + fi + if [[ $state == active || $state == activating || $state == reloading ]]; then + was_running=1 + echo "stopping $unit (saves the world first)..." + systemctl stop "$unit" + fi + + if [[ $backup -eq 1 ]]; then + mkdir -p "$BASE_DIR/.backups" + chmod 700 "$BASE_DIR/.backups" + installed_name=${installed%.zip} + backup_file="$BASE_DIR/.backups/$name-$(date +%Y%m%d-%H%M%S)-${installed_name:-new}" + while [[ -e $backup_file.tar.gz ]]; do backup_file+=_; done + backup_file+=.tar.gz + # In a subshell, so a failure comes back here instead of exiting + if ! ( trap cleanup EXIT; cmd_export "$name" "$backup_file" ) >/dev/null; then + if [[ $was_running -eq 1 ]]; then systemctl start "$unit"; fi + die "backing up '$name' failed, so its version wasn't changed" + fi + echo "backup of worlds and configs: $backup_file" + fi + + old_pin=$ZIP + set_zip_pin "$name" "$zip" + # Unpack now, as the service would, so any problem shows up here. Until the + # new server/ is complete, the old one is left in place. + if ! runuser -u "$SVC_USER" -- env HOME="$dir" "$SELF" prepare "$name"; then + set_zip_pin "$name" "$old_pin" + if [[ $was_running -eq 1 ]]; then systemctl start "$unit"; fi + die "unpacking $zip failed (see above); '$name' still runs ${installed:-its old version}" + fi + + echo "$verb '$name' from ${installed:-nothing} to $zip" + if [[ $was_running -eq 1 ]]; then + systemctl start "$unit" + echo "$unit started again" + else + echo "Start it with: $(hint group start "$name")" + fi + echo "Players need the version in $zip to join." + if [[ $TYPE == tmodloader ]]; then + echo "Check that its mods still load: $(hint group watch "$name")" + fi + if [[ -n $backup_file ]]; then + echo "If its worlds need to go back to $installed, see 'Undoing an upgrade' in the guide." + fi +} + # Gives every file of an instance back to the service user, with the modes the # service creates files with (UMask=0027): the terraria group can read but not # change them, others get nothing. For files copied in as root, or with odd @@ -1184,7 +1392,7 @@ cmd_info() { # anything it does on this host, and passes on where it was called from. run_remote_helper() { local helper=$REMOTE_HELPER ver=$TERRACTL_VERSION self - self=$(readlink -f "$0") + self=$SELF # A development copy uses the helper from its source tree if [[ $helper == *@* ]]; then helper="$(dirname "$self")/../lib/terractl-remote"; fi if [[ $ver == *@* ]]; then ver=development; fi @@ -1218,7 +1426,7 @@ if [[ $command == prepare || $command == run ]]; then if [[ $EUID -eq 0 ]]; then command -v runuser >/dev/null || die "runuser not found; run as '$SVC_USER' instead" exec runuser -u "$SVC_USER" -- env HOME="$(inst_dir "$1")" \ - "$(readlink -f "$0")" "$command" "$@" + "$SELF" "$command" "$@" elif [[ $(id -un) != "$SVC_USER" ]]; then # Being in the group isn't enough: files would end up owned by the caller die "'$command' needs root (it switches to the '$SVC_USER' user by itself): sudo terractl $command $1" @@ -1242,6 +1450,7 @@ case $command in mods) cmd_mods "$@" ;; new) cmd_new "$@" ;; reinstall) cmd_reinstall "$@" ;; + upgrade) cmd_upgrade "$@" ;; fix-perms) cmd_fix_perms "$@" ;; delete) cmd_delete "$@" ;; rename) cmd_rename "$@" ;; diff --git a/completions/bash/terractl b/completions/bash/terractl index 2080ded..6b31827 100644 --- a/completions/bash/terractl +++ b/completions/bash/terractl @@ -4,16 +4,36 @@ # Installed to /share/bash-completion/completions/terractl, where the # bash-completion package loads it automatically. Also works with 'sudo terractl'. -# Instances directory: default, or TERRARIA_BASE from /etc/default/terractl. +# A setting from /etc/default/terractl, or its default: _terractl_setting . # The file is parsed rather than sourced, so nothing runs in your shell. -_terractl_base() { - local base=/var/lib/terraria/instances value +_terractl_setting() { + local result=$2 value if [[ -r /etc/default/terractl ]]; then - value=$(sed -n 's/^[[:space:]]*TERRARIA_BASE=//p' /etc/default/terractl | tail -n 1) + value=$(sed -n "s/^[[:space:]]*$1=//p" /etc/default/terractl | tail -n 1) value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'} - if [[ -n $value ]]; then base=$value; fi + if [[ -n $value ]]; then result=$value; fi fi - printf '%s\n' "$base" + printf '%s\n' "$result" +} + +# Instances directory +_terractl_base() { _terractl_setting TERRARIA_BASE /var/lib/terraria/instances; } + +# Server zips an instance can use, by its TYPE in instance.conf (all zips if +# that can't be read). On a remote, all of its zips, from its daemon. +_terractl_zips() { + if [[ -n $2 ]]; then + timeout 5 terractl --host "$2" info 2>/dev/null /dev/null | tail -n 1) + case $type in + vanilla) pattern='terraria-server-*.zip' ;; + tmodloader) pattern='tModLoader*.zip' ;; + esac + while IFS= read -r f; do printf '%s\n' "${f##*/}"; done < <(compgen -G "$dist/$pattern") } # Instance names. Reading the directory needs root or the 'terraria' group; if @@ -105,7 +125,7 @@ _terractl() { cword=$COMP_CWORD fi - local commands="new list info start stop restart enable disable attach watch cmd mods reinstall fix-perms delete rename export import" + local commands="new list info start stop restart enable disable attach watch cmd mods upgrade reinstall fix-perms delete rename export import" local console="save say playing kick ban motd password time dawn noon dusk midnight settle seed version maxplayers port clear help" # terractl --host ...: the same as on this host, except @@ -173,6 +193,12 @@ _terractl() { if (( cword == 3 )); then _terractl_reply "$console"; fi ;; reinstall) if (( cword == 3 )); then _terractl_reply "--clean"; fi ;; + upgrade) + if [[ $cur == -* ]]; then + _terractl_reply "--downgrade --no-backup" + else + _terractl_reply "latest $(_terractl_zips "$instance" "$host")" + fi ;; enable|disable) if (( cword == 3 )); then _terractl_reply "--now"; fi ;; watch) diff --git a/docs/guide.md b/docs/guide.md index b4bf2af..b7b273e 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -87,6 +87,7 @@ On stop, `terractl service-stop` sends `exit` through the pipe so the world is s │ ├── Mods/ tModLoader mods + enabled.json │ ├── ModConfigs/ tModLoader mod settings │ └── banlist.txt + ├── .backups/ Backups made by 'terractl upgrade' └── .deleted/ Backups made by 'terractl delete' /run/terraria/.stdin Console pipe (exists only while running) @@ -468,7 +469,7 @@ EXTRA_ARGS=() # extra server arguments, as a bash array | Setting | Effect | |---|---| | `TYPE` | Which server software runs. Changing it re-unpacks `server/` from the other kind of zip on the next start. `data/` and `serverconfig.txt` are kept. | -| `ZIP` | Pins a specific zip from `/var/lib/terraria/dist` (a file name, or an absolute path). Changing it re-unpacks `server/` on the next start. If left blank, the newest matching zip is used at first install and **never upgraded automatically**. | +| `ZIP` | Pins a specific zip from `/var/lib/terraria/dist` (a file name, or an absolute path). Changing it re-unpacks `server/` on the next start; `terractl upgrade` sets it for you (see [Upgrading](#upgrading-or-downgrading-an-instance)). If left blank, the newest matching zip is used at first install and **never upgraded automatically**. | | `EXTRA_ARGS` | Extra command-line arguments, e.g. `EXTRA_ARGS=(-disableannouncementbox)`. | This file also marks the directory as an instance: `list` ignores directories without one, and `delete` refuses to remove them. @@ -563,17 +564,59 @@ Stable tModLoader and the latest vanilla Terraria can be based on different Terr ### Upgrading (or downgrading) an instance -1. Put the new zip in `/var/lib/terraria/dist`, with the version in its name. -2. Set `ZIP=` to that file name in the instance's `instance.conf`. -3. Restart the instance: +Put the new zip in `/var/lib/terraria/dist`, with the version in its name (see [Server zips](#server-zips)). To see which zips an instance can use and which one it runs: -```bash -sudo systemctl restart terraria@calamity +``` +$ terractl upgrade calamity +'calamity' (tmodloader) runs: tModLoader-v2026.06.1.0.zip +ZIP NOTE +tModLoader-v2026.06.1.0.zip installed, pinned +tModLoader-v2026.07.3.0.zip newest +Change version with: sudo terractl upgrade calamity |latest ``` -The helper sees that the zip has changed and re-unpacks `server/`. Nothing in `data/` is touched. To move back, set `ZIP=` to the old file. +Then switch to it, by name or with `latest` (the newest zip of the instance's type): -**Pin `ZIP=` on modded instances.** Pinned instances never change version by accident, and you can see at a glance which version each one is on. New tModLoader versions can break older mods, so only upgrade a modded server once its mods support the new version. +```bash +sudo terractl upgrade calamity tModLoader-v2026.07.3.0.zip +sudo terractl upgrade calamity latest +``` + +`upgrade` applies the change right away, in this order: + +1. **Checks the zip** before changing anything: it must be in `dist/`, match the instance's type, be readable by the `terraria` user, contain the server, and pass `unzip -t`. A damaged or half-downloaded zip is refused here, not on the next start. +2. **Stops the server** if it's running. Stopping saves the world. +3. **Backs up** the worlds and configs (not `server/`) to `/var/lib/terraria/instances/.backups/--.tar.gz`, readable only by root. Use `--no-backup` to skip this. +4. **Pins `ZIP=`** in `instance.conf` to the new zip, so the version can't change by accident afterwards. +5. **Unpacks the new zip** into `server/`. For tModLoader, the .NET runtime and logs are kept, as always. If this fails, the old pin is put back and the old `server/` is still in place, so the instance is exactly as it was. +6. **Starts the server again** if it was running. + +Nothing in `data/` is changed by `upgrade` itself. But once the new version saves a world, older versions can't open it. + +**Downgrades need `--downgrade`.** `upgrade` compares the version in the zip names, and refuses to go to an older one without it: + +```bash +sudo terractl upgrade calamity tModLoader-v2026.06.1.0.zip --downgrade +``` + +Downgrading only changes the server software. If worlds have already been saved by the newer version, restore them from the backup as well (see [Undoing an upgrade](#undoing-an-upgrade)). + +**Pin `ZIP=` on modded instances.** `upgrade` always pins, and pinned instances never change version by accident. New tModLoader versions can break older mods, so only upgrade a modded server once its mods support the new version. After upgrading, check that the mods load with `terractl watch calamity`. + +You can still change version by hand: set `ZIP=` in `instance.conf` and restart. That skips the checks and the backup, and a bad zip only shows up when the server fails to start. + +### Undoing an upgrade + +To go back to the old version *and* the worlds as they were before the upgrade, replace the instance with the backup `upgrade` made. The backup is in the same format as `export`, so `import` restores it, pinned to the version it was taken with: + +```bash +sudo ls /var/lib/terraria/instances/.backups/ +sudo terractl delete calamity -y # keeps its own backup in .deleted/, just in case +sudo terractl import calamity /var/lib/terraria/instances/.backups/calamity-20261002-213553-tModLoader-v2026.06.1.0.tar.gz +sudo terractl enable calamity --now +``` + +Anything played since the upgrade is lost this way. The backups are never deleted automatically; remove old ones with `sudo rm`. ### Re-unpacking the same version @@ -581,14 +624,16 @@ If `server/` gets damaged or you want a clean copy: ```bash sudo terractl reinstall calamity -sudo systemctl restart terraria@calamity +sudo terractl restart calamity ``` +`reinstall` unpacks the **same** zip again on the next start; it never changes version. If `ZIP=` is blank, it first pins the zip the instance runs, because otherwise the next start would pick the newest zip in `dist/`. If that zip is no longer in `dist/`, it says so and changes nothing: put the zip back, or use `upgrade`. + For tModLoader, the downloaded .NET runtime and `tModLoader-Logs/` are kept. If you suspect .NET itself is damaged, use `--clean` to delete them too, so .NET is downloaded again on the next start: ```bash sudo terractl reinstall calamity --clean -sudo systemctl restart terraria@calamity +sudo terractl restart calamity ``` --- @@ -789,7 +834,7 @@ Everything worth keeping is in `data/`, `instance.conf` and `serverconfig.txt`, 30 4 * * * /usr/bin/terractl export calamity /var/backups/terraria/calamity-$(date +\%F).tar.gz && find /var/backups/terraria -name 'calamity-*.tar.gz' -mtime +14 -delete ``` -Create the folder first with `sudo mkdir -p /var/backups/terraria`. In a crontab, `%` has a special meaning and must be written as `\%`, as above. Terraria also keeps a `.bak` copy of each world next to the world file, which is useful if the latest save is damaged. +Create the folder first with `sudo mkdir -p /var/backups/terraria`. Separately, `upgrade` backs up an instance's worlds and configs before every version change (see [Undoing an upgrade](#undoing-an-upgrade)). In a crontab, `%` has a special meaning and must be written as `\%`, as above. Terraria also keeps a `.bak` copy of each world next to the world file, which is useful if the latest save is damaged. --- @@ -807,6 +852,8 @@ The servers always run as the `terraria` user. Administration is simplest with ` | `attach` | root, or members of the `terraria` group | Writes to the console pipe and reads the servers' journal. | | `cmd` | root, or members of the `terraria` group | Same as `attach`. | | `watch` | root, or members of the `terraria` group | Reads the servers' journal only. | +| `upgrade ` (list) | root, or members of the `terraria` group | | +| `upgrade ` | root only | Checked by the script. Stops and starts the server, and unpacks the zip as `terraria`. | | `reinstall` | root (or `terraria`) | | | `fix-perms` | root only | Checked by the script. | | `mods ` (list) | root, or members of the `terraria` group | | @@ -875,6 +922,7 @@ While a server runs, whether systemd started it or you did, it holds a lock on t - **A second `run` is refused**, and so is starting the service while a copy started by hand is running. In that case the service keeps retrying every 10 seconds and starts by itself once the other copy exits. - **`prepare` is refused**, so `server/` can't be replaced under the running server. - **`reinstall --clean` is refused**, so `.NET` can't be deleted under it. Plain `reinstall` is allowed, because it only takes effect at the next start. +- **`upgrade` stops the service first**, and refuses if a copy started by hand is running. - **`delete` stops the service**, but refuses to go on if a copy started by hand is still running. - **`list` shows a copy started by hand** as `running (outside systemd)`. @@ -950,7 +998,8 @@ Its access was revoked with `daemon revoke`, or the host's `/var/lib/terractl` w - instance names; - `vanilla`/`tmodloader` for `new`; - common server console commands for `cmd` (`save`, `say`, `playing`, `kick`...); -- the options of `enable`, `disable`, `watch`, `reinstall`, `delete`, `export`, `copy` and `move`; +- the options of `enable`, `disable`, `watch`, `upgrade`, `reinstall`, `delete`, `export`, `copy` and `move`; +- server zips for `upgrade`, only those of the instance's type (on a remote, all of its zips); - remote names after `--host`, and `:` for `copy` and `move`. Instance names on a remote are asked from its daemon, so they can take a moment; - `.tar.gz` files for `export` and `import`; - `mods` actions. For `mods add` it completes `.tmod` files and folders; for `mods enable` and `mods remove`, the installed mods; for `mods disable`, the enabled ones. @@ -973,7 +1022,10 @@ terractl disable [--now] Don't start at boot (--now: an terractl attach Interactive console (Ctrl-D detaches) terractl watch [-n ] [-q] Follow the output, read-only (Ctrl-C quits) terractl cmd Send one console command -terractl reinstall [--clean] Re-unpack server/ on next start +terractl upgrade Show the zips it can use, and which it runs +terractl upgrade |latest [--downgrade] [--no-backup] + Change server version now (backs up first) +terractl reinstall [--clean] Re-unpack the same version on next start (--clean also re-downloads .NET) terractl fix-perms Reset the owner and modes of all its files terractl mods [list] Show installed mods diff --git a/lib/terractl-remote b/lib/terractl-remote index 40bb8c0..956c66d 100755 --- a/lib/terractl-remote +++ b/lib/terractl-remote @@ -325,7 +325,7 @@ def host_addresses(listen_host, port): # --------------------------------------------------------------- daemon ---- EXEC_COMMANDS = {"list", "info", "new", "start", "stop", "restart", "enable", "disable", "cmd", "watch", - "mods", "reinstall", "fix-perms", "delete", "rename", "export", "import", + "mods", "reinstall", "upgrade", "fix-perms", "delete", "rename", "export", "import", "-V", "--version", "version", "-h", "--help", "help"}