add import and export
This commit is contained in:
1 parent
d4bd93bbe1
commit
35f541458c
7 files changed
+369
-38
No files matched your search
+234
-3
@@ -12,10 +12,16 @@
|
||||
# data/ worlds, mods, player data (kept across reinstalls)
|
||||
set -euo pipefail
|
||||
|
||||
# All paths used are absolute. Start from / so nothing depends on the caller's
|
||||
# directory (which the 'terraria' user may not be allowed into, e.g. /root).
|
||||
# Work from / so nothing depends on the caller's directory (which the 'terraria'
|
||||
# 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
|
||||
cd /
|
||||
|
||||
abspath() {
|
||||
if [[ $1 == /* ]]; then printf '%s\n' "$1"; else printf '%s\n' "$CALLER_PWD/$1"; fi
|
||||
}
|
||||
|
||||
# Local overrides (see the comments in that file)
|
||||
# shellcheck disable=SC1091
|
||||
if [[ -r /etc/default/terractl ]]; then . /etc/default/terractl; fi
|
||||
@@ -47,12 +53,16 @@ Admin commands:
|
||||
mods <name> enable|disable <mod>... change which installed mods load
|
||||
mods <name> remove <mod>... uninstall mods
|
||||
delete <name> [-y] [--no-backup] stop, disable and remove an instance
|
||||
export <name> [file.tar.gz] [--with-server]
|
||||
archive an instance (configs, worlds, mods)
|
||||
import <name> <file.tar.gz> create instance <name> from an archive
|
||||
(worlds/configs archived to $BASE_DIR/.deleted/)
|
||||
|
||||
Used by terraria@.service:
|
||||
prepare <name> create dirs/configs, unpack server if needed
|
||||
run <name> exec the server
|
||||
stop <name> <mainpid> graceful shutdown via console "exit"
|
||||
stop <name> <mainpid> graceful shutdown via console "exit" (service only;
|
||||
to stop a server use: systemctl stop terraria@<name>)
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -215,9 +225,40 @@ install_server() {
|
||||
log "install complete"
|
||||
}
|
||||
|
||||
# One server (or prepare) per instance at a time. The lock is held on file
|
||||
# descriptor 9. 'run' passes it on to the server process, so it's held for the
|
||||
# server's whole lifetime and released by the kernel when the server exits,
|
||||
# however it exits; it can never go stale.
|
||||
take_lock() {
|
||||
local name=$1 dir state
|
||||
dir=$(inst_dir "$name")
|
||||
exec 9>"$dir/.lock"
|
||||
if ! flock -n 9; then
|
||||
state=$(systemctl show -p ActiveState --value "terraria@$name" 2>/dev/null || true)
|
||||
case $state in
|
||||
active|activating|deactivating|reloading)
|
||||
die "terraria@$name is $state; stop it first: systemctl stop terraria@$name" ;;
|
||||
*)
|
||||
die "instance '$name' is already running outside systemd (e.g. 'terractl run' in another terminal); stop that first" ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# True while a server (or prepare) holds the instance lock. Only tests the lock:
|
||||
# never creates or changes the file, so it's safe for root and for members of
|
||||
# the 'terraria' group. If the lock can't be read, assumes nothing is running.
|
||||
instance_running() {
|
||||
local lock rc=0
|
||||
lock="$(inst_dir "$1")/.lock"
|
||||
[[ -r $lock ]] || return 1
|
||||
flock -n -E 75 "$lock" true 2>/dev/null || rc=$?
|
||||
[[ $rc -eq 75 ]]
|
||||
}
|
||||
|
||||
cmd_prepare() {
|
||||
local name=$1 dir; dir=$(inst_dir "$name")
|
||||
mkdir -p "$dir/data/Worlds"
|
||||
take_lock "$name" # never re-unpack server/ under a running server
|
||||
[[ -f $dir/instance.conf ]] || write_instance_conf "$name" vanilla
|
||||
load_conf "$name"
|
||||
if [[ $TYPE == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi
|
||||
@@ -227,6 +268,8 @@ cmd_prepare() {
|
||||
|
||||
cmd_run() {
|
||||
local name=$1 dir; dir=$(inst_dir "$name")
|
||||
[[ -f $dir/instance.conf ]] || die "no instance '$name'"
|
||||
take_lock "$name" # held by the server process until it exits
|
||||
load_conf "$name"
|
||||
[[ -f $dir/server/.installed ]] || die "server not installed; run 'prepare' first"
|
||||
# Per-instance HOME so nothing is shared through ~/.local or ~/.dotnet
|
||||
@@ -250,6 +293,11 @@ cmd_run() {
|
||||
|
||||
cmd_stop() {
|
||||
local name=$1 pid=${2:-} fifo="$RUN_DIR/$1.stdin" i
|
||||
# systemd sets INVOCATION_ID for every process it runs for a service
|
||||
if [[ -z ${INVOCATION_ID:-} ]]; then
|
||||
die "'stop' is only for terraria@.service. To stop the server: systemctl stop terraria@$name"
|
||||
fi
|
||||
if [[ -n $pid && ! $pid =~ ^[0-9]+$ ]]; then die "invalid process ID '$pid'"; fi
|
||||
if [[ -z $pid ]] || ! kill -0 "$pid" 2>/dev/null; then exit 0; fi
|
||||
if [[ -p $fifo ]]; then
|
||||
echo exit > "$fifo"
|
||||
@@ -332,6 +380,9 @@ cmd_delete() {
|
||||
systemctl stop "terraria@$name.socket" 2>/dev/null || true
|
||||
systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true
|
||||
fi
|
||||
if instance_running "$name"; then
|
||||
die "'$name' is still running outside systemd (e.g. 'terractl run' in another terminal); stop it, then run delete again"
|
||||
fi
|
||||
|
||||
if [[ $backup -eq 1 ]]; then
|
||||
mkdir -p "$BASE_DIR/.deleted"
|
||||
@@ -347,6 +398,179 @@ cmd_delete() {
|
||||
echo "deleted instance '$name'"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------- export/import ----
|
||||
# Archive layout: terractl-export.txt (info about the export; optional)
|
||||
# <instance>/... (instance.conf, serverconfig.txt, data/, [server/])
|
||||
# Backups made by 'delete' have the same layout without the info file, so they
|
||||
# can be imported too.
|
||||
EXPORT_INFO=terractl-export.txt
|
||||
|
||||
# Ask a server running under systemd to save, then wait until its world files
|
||||
# have been written and stopped changing. Best effort: carries on after 60s.
|
||||
save_and_wait() {
|
||||
local name=$1 dir marker newest last="" stable=0 i
|
||||
dir=$(inst_dir "$name")
|
||||
marker=$(mktemp)
|
||||
printf 'save\n' > "$RUN_DIR/$name.stdin"
|
||||
log "'$name' is running: asked it to save, waiting for the world files..."
|
||||
for ((i = 0; i < 60; i++)); do
|
||||
sleep 1
|
||||
newest=$(find "$dir/data/Worlds" -maxdepth 1 -type f -newer "$marker" -printf '%T@\n' 2>/dev/null |
|
||||
sort -n | tail -n 1) || true
|
||||
[[ -n $newest ]] || continue
|
||||
if [[ $newest == "$last" ]]; then
|
||||
stable=$((stable + 1))
|
||||
if [[ $stable -ge 3 ]]; then rm -f "$marker"; log "save finished"; return 0; fi
|
||||
else
|
||||
stable=0; last=$newest
|
||||
fi
|
||||
done
|
||||
rm -f "$marker"
|
||||
log "warning: no completed save seen within 60s; exporting the last saved state"
|
||||
}
|
||||
|
||||
cmd_export() {
|
||||
local name=$1; shift
|
||||
local out="" with_server=0 arg dir installed_zip="" ver rc=0
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--with-server) with_server=1 ;;
|
||||
-*) die "unknown option '$arg'" ;;
|
||||
*) [[ -z $out ]] || die "more than one output file given"; out=$arg ;;
|
||||
esac
|
||||
done
|
||||
dir=$(inst_dir "$name")
|
||||
[[ -f $dir/instance.conf ]] || die "no instance '$name'"
|
||||
load_conf "$name"
|
||||
|
||||
[[ -n $out ]] || out="$name-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
out=$(abspath "$out")
|
||||
[[ $out == *.tar.gz || $out == *.tgz ]] || out="$out.tar.gz"
|
||||
[[ ! -e $out ]] || die "$out already exists"
|
||||
[[ -d $(dirname "$out") ]] || die "folder $(dirname "$out") does not exist"
|
||||
|
||||
if instance_running "$name"; then
|
||||
if [[ -p $RUN_DIR/$name.stdin ]]; then
|
||||
save_and_wait "$name"
|
||||
else
|
||||
die "'$name' is running outside systemd, so terractl can't ask it to save; stop it first (type 'exit' in its console)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -r $dir/server/.installed ]]; then read -r _ installed_zip < "$dir/server/.installed" || true; fi
|
||||
ver=$TERRACTL_VERSION; if [[ $ver == *@* ]]; then ver=development; fi
|
||||
EXTRACT_TMP=$(mktemp -d) # removed by cleanup() on exit
|
||||
{
|
||||
echo "# Created by 'terractl export'. Import with: terractl import <name> <this file>"
|
||||
echo "format=1"
|
||||
echo "terractl=$ver"
|
||||
echo "instance=$name"
|
||||
echo "type=$TYPE"
|
||||
echo "server_zip=$installed_zip"
|
||||
echo "includes_server=$(if [[ $with_server -eq 1 ]]; then echo yes; else echo no; fi)"
|
||||
echo "exported=$(date -Iseconds)"
|
||||
echo "host=$(hostname 2>/dev/null || echo unknown)"
|
||||
} > "$EXTRACT_TMP/$EXPORT_INFO"
|
||||
|
||||
local -a excludes=(--exclude="$name/.lock" --exclude="$name/.extract.*"
|
||||
--exclude="$name/data/Mods/.enabled.json.tmp")
|
||||
if [[ $with_server -eq 0 ]]; then excludes+=(--exclude="$name/server"); fi
|
||||
|
||||
# serverconfig.txt can hold the server password, so the archive is private (0600)
|
||||
(
|
||||
umask 077
|
||||
tar -czf "$out" --owner=0 --group=0 --numeric-owner "${excludes[@]}" \
|
||||
-C "$EXTRACT_TMP" "$EXPORT_INFO" -C "$BASE_DIR" "$name"
|
||||
) || rc=$?
|
||||
if [[ $rc -ge 2 ]]; then rm -f "$out"; die "export failed (tar exit status $rc)"; fi
|
||||
if [[ $rc -eq 1 ]]; then
|
||||
log "warning: some files changed while being archived (an autosave?); consider exporting again"
|
||||
fi
|
||||
echo "exported '$name' to $out ($(du -h "$out" | cut -f1), readable only by $(id -un))"
|
||||
if [[ $with_server -eq 0 ]]; then
|
||||
echo "server/ not included; it's unpacked again from ${installed_zip:-the server zip} on first start after import"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_import() {
|
||||
local name=$1; shift
|
||||
[[ $# -eq 1 ]] || die "usage: terractl import <new instance name> <file.tar.gz>"
|
||||
local file dir listing types m top="" meta_zip="" meta_from="" port newport p f clash=0 zip=""
|
||||
file=$(abspath "$1")
|
||||
[[ -f $file && -r $file ]] || die "cannot read $file"
|
||||
dir=$(inst_dir "$name")
|
||||
[[ ! -e $dir ]] || die "instance '$name' already exists; choose another name, or delete it first"
|
||||
|
||||
# Check the archive before extracting anything
|
||||
listing=$(tar -tzf "$file" 2>/dev/null) || die "$file is not a readable .tar.gz archive"
|
||||
types=$(tar -tvzf "$file" 2>/dev/null | cut -c1 | sort -u | tr -d '\n')
|
||||
[[ $types =~ ^[-d]+$ ]] || die "archive contains links or special files; refusing to import it"
|
||||
local -A tops=()
|
||||
while IFS= read -r m; do
|
||||
case $m in
|
||||
/*|..|../*|*/../*|*/..) die "archive contains an unsafe path '$m'; refusing to import it" ;;
|
||||
esac
|
||||
m=${m#./}
|
||||
if [[ -z $m || $m == . || $m == "$EXPORT_INFO" ]]; then continue; fi
|
||||
tops[${m%%/*}]=1
|
||||
done <<< "$listing"
|
||||
if [[ ${#tops[@]} -ne 1 ]]; then
|
||||
local found="nothing"
|
||||
if [[ ${#tops[@]} -gt 0 ]]; then found="${!tops[*]}"; fi
|
||||
die "expected one instance folder in the archive (as 'terractl export' creates); found: $found"
|
||||
fi
|
||||
top=${!tops[*]}
|
||||
|
||||
mkdir -p "$BASE_DIR"
|
||||
EXTRACT_TMP=$(mktemp -d "$BASE_DIR/.import.XXXXXX") # removed by cleanup() on exit
|
||||
( umask 027; tar -xzf "$file" -C "$EXTRACT_TMP" --no-same-owner --no-same-permissions ) ||
|
||||
die "extracting $file failed"
|
||||
[[ -f $EXTRACT_TMP/$top/instance.conf ]] || die "no instance.conf in '$top/'; this isn't a terractl instance archive"
|
||||
if [[ -f $EXTRACT_TMP/$EXPORT_INFO ]]; then
|
||||
meta_zip=$(sed -n 's/^server_zip=//p' "$EXTRACT_TMP/$EXPORT_INFO" | tail -n 1)
|
||||
meta_from=$(sed -n 's/^instance=//p' "$EXTRACT_TMP/$EXPORT_INFO" | tail -n 1)
|
||||
fi
|
||||
mv "$EXTRACT_TMP/$top" "$dir"
|
||||
rm -rf "$EXTRACT_TMP"; EXTRACT_TMP=""
|
||||
|
||||
# serverconfig.txt holds absolute paths from where it came from; keep only
|
||||
# the part after /data/ (this also converts old /opt/terraria instances)
|
||||
if [[ -f $dir/serverconfig.txt ]]; then
|
||||
sed -i -E "s#^(world|worldpath|banlist)=.*/data/#\1=$dir/data/#" "$dir/serverconfig.txt"
|
||||
port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$dir/serverconfig.txt" | tail -n 1)
|
||||
if [[ -n $port ]]; then
|
||||
for f in "$BASE_DIR"/*/serverconfig.txt; do
|
||||
[[ -f $f && $f != "$dir/serverconfig.txt" ]] || continue
|
||||
p=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$f" | tail -n 1)
|
||||
if [[ $p == "$port" ]]; then clash=1; fi
|
||||
done
|
||||
if [[ $clash -eq 1 ]]; then
|
||||
newport=$(next_free_port)
|
||||
sed -i "s/^port=.*/port=$newport/" "$dir/serverconfig.txt"
|
||||
log "port $port is already used by another instance; '$name' now uses port $newport"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Keep the server version the world was exported with, unless one is pinned already
|
||||
load_conf "$name"
|
||||
if [[ -z $ZIP && $meta_zip =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; then
|
||||
sed -i "s/^ZIP=.*/ZIP=$meta_zip/" "$dir/instance.conf"
|
||||
ZIP=$meta_zip
|
||||
log "pinned ZIP=$meta_zip in instance.conf (the version it was exported with)"
|
||||
fi
|
||||
if [[ ! -f $dir/server/.installed ]]; then
|
||||
zip=$(pick_zip 2>/dev/null) || true
|
||||
if [[ -z $zip || ! -r $zip ]]; then
|
||||
log "warning: before starting, put ${ZIP:-a $TYPE server zip} in $DIST_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then chown -R "$SVC_USER:$SVC_USER" "$dir"; fi
|
||||
echo "imported${meta_from:+ '$meta_from' as} '$name' into $dir"
|
||||
echo "Review $dir/serverconfig.txt, then: systemctl enable --now terraria@$name"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- mods ----
|
||||
# enabled.json is a plain JSON array of mod names, e.g. ["CalamityMod","MagicStorage"].
|
||||
# Mod names are identifiers, so simple text handling is enough (no jq needed).
|
||||
@@ -404,6 +628,7 @@ mods_add() {
|
||||
local src f n found
|
||||
local -a files=() cfgs=()
|
||||
for src in "$@"; do
|
||||
src=$(abspath "$src")
|
||||
if [[ -f $src ]]; then
|
||||
[[ $src == *.tmod ]] || die "$src is not a .tmod file"
|
||||
files+=("$src")
|
||||
@@ -540,6 +765,7 @@ cmd_list() {
|
||||
load_conf "$n"
|
||||
port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$d/serverconfig.txt" 2>/dev/null | tail -n1)
|
||||
state=$(systemctl is-active "terraria@$n" 2>/dev/null || true)
|
||||
if [[ $state != active ]] && instance_running "$n"; then state="running (outside systemd)"; fi
|
||||
printf '%-20s %-11s %-6s %s\n' "$n" "$TYPE" "${port:--}" "$state"
|
||||
) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)"
|
||||
done
|
||||
@@ -554,6 +780,9 @@ cmd_reinstall() {
|
||||
*) die "unknown option '$1'" ;;
|
||||
esac
|
||||
[[ -f $dir/instance.conf ]] || die "no instance '$name'"
|
||||
if [[ $clean -eq 1 ]] && instance_running "$name"; then
|
||||
die "'$name' is running; --clean would delete .NET under it. Stop it first: systemctl stop terraria@$name"
|
||||
fi
|
||||
rm -f "$dir/server/.installed"
|
||||
if [[ $clean -eq 1 ]]; then
|
||||
rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs"
|
||||
@@ -598,5 +827,7 @@ case $command in
|
||||
new) cmd_new "$@" ;;
|
||||
reinstall) cmd_reinstall "$@" ;;
|
||||
delete) cmd_delete "$@" ;;
|
||||
export) cmd_export "$@" ;;
|
||||
import) cmd_import "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
Reference in new issue
Block a user