@minecraft-docker/mcctl 1.6.13 → 1.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/commands/backup.d.ts +1 -0
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/backup.js +28 -2
- package/dist/commands/backup.js.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +7 -4
- package/scripts/backup.sh +569 -0
- package/scripts/create-server.sh +580 -0
- package/scripts/delete-server.sh +266 -0
- package/scripts/init.sh +390 -0
- package/scripts/lib/common.sh +248 -0
- package/scripts/lock.sh +448 -0
- package/scripts/logs.sh +283 -0
- package/scripts/mcctl.sh +543 -0
- package/scripts/migrate-nip-io.sh +258 -0
- package/scripts/player.sh +329 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# common.sh - Shared functions for mcctl scripts
|
|
4
|
+
# =============================================================================
|
|
5
|
+
# Source this file in other scripts:
|
|
6
|
+
# source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
|
|
7
|
+
#
|
|
8
|
+
# When running via npm package (mcctl CLI), these environment variables are set:
|
|
9
|
+
# MCCTL_ROOT - User data directory (~/.minecraft-servers)
|
|
10
|
+
# MCCTL_SCRIPTS - Package scripts directory
|
|
11
|
+
# MCCTL_TEMPLATES - Package templates directory
|
|
12
|
+
# =============================================================================
|
|
13
|
+
|
|
14
|
+
# Get script/platform directories
|
|
15
|
+
# Support both direct execution and npm package execution
|
|
16
|
+
if [[ -n "${MCCTL_ROOT:-}" ]]; then
|
|
17
|
+
# Running via npm package (mcctl CLI)
|
|
18
|
+
PLATFORM_DIR="$MCCTL_ROOT"
|
|
19
|
+
SCRIPT_DIR="${MCCTL_SCRIPTS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
20
|
+
TEMPLATES_DIR="${MCCTL_TEMPLATES:-$PLATFORM_DIR/servers/_template}"
|
|
21
|
+
else
|
|
22
|
+
# Running directly (development mode)
|
|
23
|
+
SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
24
|
+
PLATFORM_DIR="${PLATFORM_DIR:-$(dirname "$SCRIPT_DIR")}"
|
|
25
|
+
TEMPLATES_DIR="$PLATFORM_DIR/servers/_template"
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# Export for sub-scripts
|
|
29
|
+
export PLATFORM_DIR SCRIPT_DIR TEMPLATES_DIR
|
|
30
|
+
|
|
31
|
+
# Colors (disabled if not terminal or JSON output)
|
|
32
|
+
setup_colors() {
|
|
33
|
+
if [[ -t 1 ]] && [[ "${JSON_OUTPUT:-}" != "true" ]]; then
|
|
34
|
+
RED='\033[0;31m'
|
|
35
|
+
GREEN='\033[0;32m'
|
|
36
|
+
YELLOW='\033[1;33m'
|
|
37
|
+
BLUE='\033[0;34m'
|
|
38
|
+
CYAN='\033[0;36m'
|
|
39
|
+
BOLD='\033[1m'
|
|
40
|
+
NC='\033[0m'
|
|
41
|
+
else
|
|
42
|
+
RED=''
|
|
43
|
+
GREEN=''
|
|
44
|
+
YELLOW=''
|
|
45
|
+
BLUE=''
|
|
46
|
+
CYAN=''
|
|
47
|
+
BOLD=''
|
|
48
|
+
NC=''
|
|
49
|
+
fi
|
|
50
|
+
}
|
|
51
|
+
setup_colors
|
|
52
|
+
|
|
53
|
+
# =============================================================================
|
|
54
|
+
# Output Functions
|
|
55
|
+
# =============================================================================
|
|
56
|
+
|
|
57
|
+
error() {
|
|
58
|
+
echo -e "${RED}[ERROR]${NC} $1" >&2
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
warn() {
|
|
62
|
+
echo -e "${YELLOW}[WARN]${NC} $1" >&2
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
info() {
|
|
66
|
+
echo -e "${GREEN}[INFO]${NC} $1"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
debug() {
|
|
70
|
+
if [[ "${DEBUG:-}" == "true" ]]; then
|
|
71
|
+
echo -e "${BLUE}[DEBUG]${NC} $1" >&2
|
|
72
|
+
fi
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# =============================================================================
|
|
76
|
+
# Sudo Functions
|
|
77
|
+
# =============================================================================
|
|
78
|
+
# Supports MCCTL_SUDO_PASSWORD environment variable for automation
|
|
79
|
+
# When set, uses sudo -S to pipe password via stdin
|
|
80
|
+
|
|
81
|
+
# Run command with sudo, supporting MCCTL_SUDO_PASSWORD for automation
|
|
82
|
+
# Usage: run_with_sudo <command> [args...]
|
|
83
|
+
# Example: run_with_sudo tee -a /etc/avahi/hosts
|
|
84
|
+
# Example: run_with_sudo systemctl restart avahi-daemon
|
|
85
|
+
run_with_sudo() {
|
|
86
|
+
if [[ -z "$*" ]]; then
|
|
87
|
+
error "run_with_sudo: No command specified"
|
|
88
|
+
return 1
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
if [[ -n "${MCCTL_SUDO_PASSWORD:-}" ]]; then
|
|
92
|
+
# Automation mode: use sudo -S to read password from stdin
|
|
93
|
+
debug "Using MCCTL_SUDO_PASSWORD for sudo"
|
|
94
|
+
echo "$MCCTL_SUDO_PASSWORD" | sudo -S "$@" 2>/dev/null
|
|
95
|
+
else
|
|
96
|
+
# Interactive mode: let sudo prompt for password
|
|
97
|
+
sudo "$@"
|
|
98
|
+
fi
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Run command with sudo and capture output (for commands like tee)
|
|
102
|
+
# Usage: echo "content" | run_with_sudo_stdin <command> [args...]
|
|
103
|
+
# Example: echo "$ip $hostname" | run_with_sudo_stdin tee -a /etc/avahi/hosts
|
|
104
|
+
run_with_sudo_stdin() {
|
|
105
|
+
if [[ -z "$*" ]]; then
|
|
106
|
+
error "run_with_sudo_stdin: No command specified"
|
|
107
|
+
return 1
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
if [[ -n "${MCCTL_SUDO_PASSWORD:-}" ]]; then
|
|
111
|
+
# Automation mode: need to handle both password and stdin content
|
|
112
|
+
# Use expect-like approach with heredoc
|
|
113
|
+
local stdin_content
|
|
114
|
+
stdin_content=$(cat)
|
|
115
|
+
debug "Using MCCTL_SUDO_PASSWORD for sudo with stdin"
|
|
116
|
+
{
|
|
117
|
+
echo "$MCCTL_SUDO_PASSWORD"
|
|
118
|
+
echo "$stdin_content"
|
|
119
|
+
} | sudo -S "$@" 2>/dev/null
|
|
120
|
+
else
|
|
121
|
+
# Interactive mode: let sudo prompt for password, pass stdin through
|
|
122
|
+
sudo "$@"
|
|
123
|
+
fi
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Check if sudo password is configured for automation
|
|
127
|
+
has_sudo_password() {
|
|
128
|
+
[[ -n "${MCCTL_SUDO_PASSWORD:-}" ]]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Validate sudo password (test if it works)
|
|
132
|
+
validate_sudo_password() {
|
|
133
|
+
if [[ -z "${MCCTL_SUDO_PASSWORD:-}" ]]; then
|
|
134
|
+
return 1
|
|
135
|
+
fi
|
|
136
|
+
echo "$MCCTL_SUDO_PASSWORD" | sudo -S -v 2>/dev/null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# =============================================================================
|
|
140
|
+
# Docker Functions
|
|
141
|
+
# =============================================================================
|
|
142
|
+
|
|
143
|
+
# Check if Docker is available
|
|
144
|
+
check_docker() {
|
|
145
|
+
if ! command -v docker &> /dev/null; then
|
|
146
|
+
error "Docker is not installed or not in PATH"
|
|
147
|
+
return 1
|
|
148
|
+
fi
|
|
149
|
+
if ! docker info &> /dev/null; then
|
|
150
|
+
error "Docker daemon is not running or permission denied"
|
|
151
|
+
return 1
|
|
152
|
+
fi
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Get container status (running, exited, paused, etc.)
|
|
157
|
+
get_container_status() {
|
|
158
|
+
local container="$1"
|
|
159
|
+
local status
|
|
160
|
+
status=$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null) || status="not_found"
|
|
161
|
+
echo -n "$status"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Check if container exists
|
|
165
|
+
container_exists() {
|
|
166
|
+
local container="$1"
|
|
167
|
+
docker inspect "$container" &>/dev/null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Get container health status
|
|
171
|
+
get_container_health() {
|
|
172
|
+
local container="$1"
|
|
173
|
+
local health
|
|
174
|
+
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container" 2>/dev/null) || health="unknown"
|
|
175
|
+
echo -n "$health"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Get minecraft server containers (those starting with mc-)
|
|
179
|
+
get_mc_containers() {
|
|
180
|
+
docker ps -a --filter "name=mc-" --format '{{.Names}}' | grep -v "mc-router" | sort
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Get running minecraft server containers
|
|
184
|
+
get_running_mc_containers() {
|
|
185
|
+
docker ps --filter "name=mc-" --filter "status=running" --format '{{.Names}}' | grep -v "mc-router" | sort
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Get container's assigned world (from environment or volume mount)
|
|
189
|
+
get_container_world() {
|
|
190
|
+
local container="$1"
|
|
191
|
+
# Check WORLD environment variable first
|
|
192
|
+
local world=$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container" 2>/dev/null | grep "^WORLD=" | cut -d= -f2)
|
|
193
|
+
if [[ -n "$world" ]]; then
|
|
194
|
+
echo "$world"
|
|
195
|
+
return
|
|
196
|
+
fi
|
|
197
|
+
# Otherwise try to get from volume mount
|
|
198
|
+
echo "-"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Get container's hostname label (mc-router.host)
|
|
202
|
+
get_container_hostname() {
|
|
203
|
+
local container="$1"
|
|
204
|
+
local hostname
|
|
205
|
+
hostname=$(docker inspect --format '{{index .Config.Labels "mc-router.host"}}' "$container" 2>/dev/null) || hostname="-"
|
|
206
|
+
[[ -z "$hostname" ]] && hostname="-"
|
|
207
|
+
echo -n "$hostname"
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# =============================================================================
|
|
211
|
+
# JSON Output Functions
|
|
212
|
+
# =============================================================================
|
|
213
|
+
|
|
214
|
+
# Escape string for JSON
|
|
215
|
+
json_escape() {
|
|
216
|
+
local str="$1"
|
|
217
|
+
str="${str//\\/\\\\}"
|
|
218
|
+
str="${str//\"/\\\"}"
|
|
219
|
+
str="${str//$'\n'/\\n}"
|
|
220
|
+
str="${str//$'\r'/\\r}"
|
|
221
|
+
str="${str//$'\t'/\\t}"
|
|
222
|
+
echo "$str"
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# =============================================================================
|
|
226
|
+
# Path Functions
|
|
227
|
+
# =============================================================================
|
|
228
|
+
|
|
229
|
+
get_servers_dir() {
|
|
230
|
+
echo "$PLATFORM_DIR/servers"
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
get_worlds_dir() {
|
|
234
|
+
echo "$PLATFORM_DIR/worlds"
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
get_locks_dir() {
|
|
238
|
+
echo "$PLATFORM_DIR/worlds/.locks"
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# List all server directories
|
|
242
|
+
get_server_names() {
|
|
243
|
+
local servers_dir
|
|
244
|
+
servers_dir=$(get_servers_dir)
|
|
245
|
+
if [[ -d "$servers_dir" ]]; then
|
|
246
|
+
find "$servers_dir" -maxdepth 1 -mindepth 1 -type d ! -name '_template' -exec basename {} \; 2>/dev/null | sort
|
|
247
|
+
fi
|
|
248
|
+
}
|
package/scripts/lock.sh
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# lock.sh - World locking system for Minecraft servers
|
|
4
|
+
# =============================================================================
|
|
5
|
+
# Provides flock-based exclusive locking to prevent simultaneous world access.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ./scripts/lock.sh lock <world> <server> # Acquire lock
|
|
9
|
+
# ./scripts/lock.sh unlock <world> <server> # Release lock
|
|
10
|
+
# ./scripts/lock.sh check <world> # Check lock status
|
|
11
|
+
# ./scripts/lock.sh list # List all locks
|
|
12
|
+
# ./scripts/lock.sh list --json # JSON output
|
|
13
|
+
#
|
|
14
|
+
# Lock file format:
|
|
15
|
+
# worlds/.locks/<world-name>.lock
|
|
16
|
+
# Content: <server-name>:<timestamp>:<pid>
|
|
17
|
+
#
|
|
18
|
+
# Exit codes:
|
|
19
|
+
# 0 - Success
|
|
20
|
+
# 1 - Error (lock failed, invalid args, etc.)
|
|
21
|
+
# 2 - Warning (lock already held, stale lock detected)
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
set -e
|
|
25
|
+
|
|
26
|
+
# Get script/platform directories
|
|
27
|
+
# Support both direct execution and npm package execution (mcctl CLI)
|
|
28
|
+
if [[ -n "${MCCTL_ROOT:-}" ]]; then
|
|
29
|
+
# Running via npm package
|
|
30
|
+
PLATFORM_DIR="$MCCTL_ROOT"
|
|
31
|
+
SCRIPT_DIR="${MCCTL_SCRIPTS:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
|
|
32
|
+
else
|
|
33
|
+
# Running directly (development mode)
|
|
34
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
35
|
+
PLATFORM_DIR="$(dirname "$SCRIPT_DIR")"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
WORLDS_DIR="$PLATFORM_DIR/worlds"
|
|
39
|
+
LOCKS_DIR="$WORLDS_DIR/.locks"
|
|
40
|
+
|
|
41
|
+
# Configuration
|
|
42
|
+
STALE_THRESHOLD=86400 # 24 hours in seconds
|
|
43
|
+
|
|
44
|
+
# Colors (disabled if not terminal)
|
|
45
|
+
if [[ -t 1 ]]; then
|
|
46
|
+
RED='\033[0;31m'
|
|
47
|
+
GREEN='\033[0;32m'
|
|
48
|
+
YELLOW='\033[1;33m'
|
|
49
|
+
BLUE='\033[0;34m'
|
|
50
|
+
NC='\033[0m'
|
|
51
|
+
else
|
|
52
|
+
RED=''
|
|
53
|
+
GREEN=''
|
|
54
|
+
YELLOW=''
|
|
55
|
+
BLUE=''
|
|
56
|
+
NC=''
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# =============================================================================
|
|
60
|
+
# Helper Functions
|
|
61
|
+
# =============================================================================
|
|
62
|
+
|
|
63
|
+
usage() {
|
|
64
|
+
cat <<EOF
|
|
65
|
+
Usage: $(basename "$0") <command> [options]
|
|
66
|
+
|
|
67
|
+
Commands:
|
|
68
|
+
lock <world> <server> Acquire exclusive lock on world for server
|
|
69
|
+
unlock <world> <server> Release lock (only owner can release)
|
|
70
|
+
check <world> Check if world is locked
|
|
71
|
+
list [--json] List all worlds and their lock status
|
|
72
|
+
|
|
73
|
+
Options:
|
|
74
|
+
--json Output in JSON format (for list command)
|
|
75
|
+
--force Force unlock (admin override, use with caution)
|
|
76
|
+
|
|
77
|
+
Exit codes:
|
|
78
|
+
0 - Success
|
|
79
|
+
1 - Error
|
|
80
|
+
2 - Warning (lock held by another, stale lock)
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
$(basename "$0") lock survival mc-ironwood
|
|
84
|
+
$(basename "$0") unlock survival mc-ironwood
|
|
85
|
+
$(basename "$0") check survival
|
|
86
|
+
$(basename "$0") list --json
|
|
87
|
+
EOF
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
error() {
|
|
91
|
+
echo -e "${RED}[ERROR]${NC} $1" >&2
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
warn() {
|
|
95
|
+
echo -e "${YELLOW}[WARN]${NC} $1" >&2
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
info() {
|
|
99
|
+
echo -e "${GREEN}[INFO]${NC} $1"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
debug() {
|
|
103
|
+
if [[ "${DEBUG:-}" == "true" ]]; then
|
|
104
|
+
echo -e "${BLUE}[DEBUG]${NC} $1" >&2
|
|
105
|
+
fi
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Ensure locks directory exists
|
|
109
|
+
ensure_locks_dir() {
|
|
110
|
+
if [[ ! -d "$LOCKS_DIR" ]]; then
|
|
111
|
+
mkdir -p "$LOCKS_DIR"
|
|
112
|
+
debug "Created locks directory: $LOCKS_DIR"
|
|
113
|
+
fi
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Get lock file path for a world
|
|
117
|
+
get_lock_file() {
|
|
118
|
+
local world="$1"
|
|
119
|
+
echo "$LOCKS_DIR/${world}.lock"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Parse lock file content
|
|
123
|
+
# Returns: server:timestamp:pid
|
|
124
|
+
parse_lock_file() {
|
|
125
|
+
local lock_file="$1"
|
|
126
|
+
if [[ -f "$lock_file" ]]; then
|
|
127
|
+
cat "$lock_file"
|
|
128
|
+
fi
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Check if lock is stale (older than threshold)
|
|
132
|
+
is_stale_lock() {
|
|
133
|
+
local lock_file="$1"
|
|
134
|
+
local lock_content
|
|
135
|
+
local lock_timestamp
|
|
136
|
+
local current_time
|
|
137
|
+
local age
|
|
138
|
+
|
|
139
|
+
if [[ ! -f "$lock_file" ]]; then
|
|
140
|
+
return 1 # No lock file, not stale
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
lock_content=$(parse_lock_file "$lock_file")
|
|
144
|
+
lock_timestamp=$(echo "$lock_content" | cut -d: -f2)
|
|
145
|
+
|
|
146
|
+
if [[ -z "$lock_timestamp" ]]; then
|
|
147
|
+
return 0 # Invalid format, consider stale
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
current_time=$(date +%s)
|
|
151
|
+
age=$((current_time - lock_timestamp))
|
|
152
|
+
|
|
153
|
+
if [[ $age -gt $STALE_THRESHOLD ]]; then
|
|
154
|
+
return 0 # Stale
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
return 1 # Not stale
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# Get all world directories
|
|
161
|
+
get_worlds() {
|
|
162
|
+
if [[ -d "$WORLDS_DIR" ]]; then
|
|
163
|
+
find "$WORLDS_DIR" -maxdepth 1 -mindepth 1 -type d ! -name '.locks' -exec basename {} \; 2>/dev/null | sort
|
|
164
|
+
fi
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# =============================================================================
|
|
168
|
+
# Lock Functions
|
|
169
|
+
# =============================================================================
|
|
170
|
+
|
|
171
|
+
# Acquire lock on a world
|
|
172
|
+
# Usage: lock_world <world> <server>
|
|
173
|
+
lock_world() {
|
|
174
|
+
local world="$1"
|
|
175
|
+
local server="$2"
|
|
176
|
+
local lock_file
|
|
177
|
+
local lock_content
|
|
178
|
+
local current_holder
|
|
179
|
+
local timestamp
|
|
180
|
+
local pid
|
|
181
|
+
|
|
182
|
+
if [[ -z "$world" || -z "$server" ]]; then
|
|
183
|
+
error "Usage: lock <world> <server>"
|
|
184
|
+
return 1
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
ensure_locks_dir
|
|
188
|
+
lock_file=$(get_lock_file "$world")
|
|
189
|
+
timestamp=$(date +%s)
|
|
190
|
+
pid=$$
|
|
191
|
+
|
|
192
|
+
# Use flock for atomic locking
|
|
193
|
+
(
|
|
194
|
+
flock -n 200 || {
|
|
195
|
+
# Lock is held, check who owns it
|
|
196
|
+
if [[ -f "$lock_file" ]]; then
|
|
197
|
+
lock_content=$(parse_lock_file "$lock_file")
|
|
198
|
+
current_holder=$(echo "$lock_content" | cut -d: -f1)
|
|
199
|
+
|
|
200
|
+
if [[ "$current_holder" == "$server" ]]; then
|
|
201
|
+
warn "World '$world' is already locked by this server ($server)"
|
|
202
|
+
exit 2
|
|
203
|
+
else
|
|
204
|
+
error "World '$world' is locked by '$current_holder'"
|
|
205
|
+
exit 1
|
|
206
|
+
fi
|
|
207
|
+
fi
|
|
208
|
+
error "Failed to acquire lock on '$world'"
|
|
209
|
+
exit 1
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# Check for existing lock file
|
|
213
|
+
if [[ -f "$lock_file" ]]; then
|
|
214
|
+
lock_content=$(parse_lock_file "$lock_file")
|
|
215
|
+
current_holder=$(echo "$lock_content" | cut -d: -f1)
|
|
216
|
+
|
|
217
|
+
if [[ "$current_holder" == "$server" ]]; then
|
|
218
|
+
# Same server, update timestamp
|
|
219
|
+
echo "${server}:${timestamp}:${pid}" > "$lock_file"
|
|
220
|
+
info "Lock renewed: $world -> $server"
|
|
221
|
+
exit 0
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
# Check for stale lock
|
|
225
|
+
if is_stale_lock "$lock_file"; then
|
|
226
|
+
warn "Removing stale lock on '$world' (was held by '$current_holder')"
|
|
227
|
+
rm -f "$lock_file"
|
|
228
|
+
else
|
|
229
|
+
error "World '$world' is locked by '$current_holder'"
|
|
230
|
+
exit 1
|
|
231
|
+
fi
|
|
232
|
+
fi
|
|
233
|
+
|
|
234
|
+
# Create lock file
|
|
235
|
+
echo "${server}:${timestamp}:${pid}" > "$lock_file"
|
|
236
|
+
info "Lock acquired: $world -> $server"
|
|
237
|
+
exit 0
|
|
238
|
+
) 200>"$lock_file.flock"
|
|
239
|
+
|
|
240
|
+
local result=$?
|
|
241
|
+
rm -f "$lock_file.flock"
|
|
242
|
+
return $result
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# Release lock on a world
|
|
246
|
+
# Usage: unlock_world <world> <server> [--force]
|
|
247
|
+
unlock_world() {
|
|
248
|
+
local world="$1"
|
|
249
|
+
local server="$2"
|
|
250
|
+
local force="${3:-}"
|
|
251
|
+
local lock_file
|
|
252
|
+
local lock_content
|
|
253
|
+
local current_holder
|
|
254
|
+
|
|
255
|
+
if [[ -z "$world" || -z "$server" ]]; then
|
|
256
|
+
error "Usage: unlock <world> <server>"
|
|
257
|
+
return 1
|
|
258
|
+
fi
|
|
259
|
+
|
|
260
|
+
lock_file=$(get_lock_file "$world")
|
|
261
|
+
|
|
262
|
+
if [[ ! -f "$lock_file" ]]; then
|
|
263
|
+
warn "World '$world' is not locked"
|
|
264
|
+
return 2
|
|
265
|
+
fi
|
|
266
|
+
|
|
267
|
+
lock_content=$(parse_lock_file "$lock_file")
|
|
268
|
+
current_holder=$(echo "$lock_content" | cut -d: -f1)
|
|
269
|
+
|
|
270
|
+
# Check ownership
|
|
271
|
+
if [[ "$current_holder" != "$server" ]]; then
|
|
272
|
+
if [[ "$force" == "--force" ]]; then
|
|
273
|
+
warn "Force unlocking '$world' (was held by '$current_holder')"
|
|
274
|
+
else
|
|
275
|
+
error "World '$world' is locked by '$current_holder', not '$server'"
|
|
276
|
+
error "Use --force to override (admin only)"
|
|
277
|
+
return 1
|
|
278
|
+
fi
|
|
279
|
+
fi
|
|
280
|
+
|
|
281
|
+
# Remove lock file
|
|
282
|
+
rm -f "$lock_file"
|
|
283
|
+
info "Lock released: $world (was held by $current_holder)"
|
|
284
|
+
return 0
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# Check lock status of a world
|
|
288
|
+
# Usage: check_lock <world>
|
|
289
|
+
check_lock() {
|
|
290
|
+
local world="$1"
|
|
291
|
+
local lock_file
|
|
292
|
+
local lock_content
|
|
293
|
+
local holder
|
|
294
|
+
local timestamp
|
|
295
|
+
local lock_time
|
|
296
|
+
local age
|
|
297
|
+
|
|
298
|
+
if [[ -z "$world" ]]; then
|
|
299
|
+
error "Usage: check <world>"
|
|
300
|
+
return 1
|
|
301
|
+
fi
|
|
302
|
+
|
|
303
|
+
lock_file=$(get_lock_file "$world")
|
|
304
|
+
|
|
305
|
+
if [[ ! -f "$lock_file" ]]; then
|
|
306
|
+
echo "unlocked"
|
|
307
|
+
return 0
|
|
308
|
+
fi
|
|
309
|
+
|
|
310
|
+
lock_content=$(parse_lock_file "$lock_file")
|
|
311
|
+
holder=$(echo "$lock_content" | cut -d: -f1)
|
|
312
|
+
timestamp=$(echo "$lock_content" | cut -d: -f2)
|
|
313
|
+
|
|
314
|
+
if [[ -n "$timestamp" ]]; then
|
|
315
|
+
lock_time=$(date -d "@$timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -r "$timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "unknown")
|
|
316
|
+
age=$(( $(date +%s) - timestamp ))
|
|
317
|
+
|
|
318
|
+
if is_stale_lock "$lock_file"; then
|
|
319
|
+
echo "stale:$holder:$lock_time (${age}s ago)"
|
|
320
|
+
return 2
|
|
321
|
+
else
|
|
322
|
+
echo "locked:$holder:$lock_time (${age}s ago)"
|
|
323
|
+
return 0
|
|
324
|
+
fi
|
|
325
|
+
else
|
|
326
|
+
echo "locked:$holder:unknown"
|
|
327
|
+
return 0
|
|
328
|
+
fi
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# List all worlds and their lock status
|
|
332
|
+
# Usage: list_locks [--json]
|
|
333
|
+
list_locks() {
|
|
334
|
+
local json_output="${1:-}"
|
|
335
|
+
local worlds
|
|
336
|
+
local world
|
|
337
|
+
local lock_file
|
|
338
|
+
local lock_content
|
|
339
|
+
local holder
|
|
340
|
+
local timestamp
|
|
341
|
+
local lock_time
|
|
342
|
+
local status
|
|
343
|
+
local first=true
|
|
344
|
+
|
|
345
|
+
ensure_locks_dir
|
|
346
|
+
|
|
347
|
+
if [[ "$json_output" == "--json" ]]; then
|
|
348
|
+
echo "{"
|
|
349
|
+
echo ' "worlds": ['
|
|
350
|
+
else
|
|
351
|
+
printf "%-20s %-12s %-20s %s\n" "WORLD" "STATUS" "HOLDER" "LOCKED_AT"
|
|
352
|
+
printf "%-20s %-12s %-20s %s\n" "-----" "------" "------" "---------"
|
|
353
|
+
fi
|
|
354
|
+
|
|
355
|
+
worlds=$(get_worlds)
|
|
356
|
+
|
|
357
|
+
for world in $worlds; do
|
|
358
|
+
lock_file=$(get_lock_file "$world")
|
|
359
|
+
holder=""
|
|
360
|
+
timestamp=""
|
|
361
|
+
lock_time=""
|
|
362
|
+
|
|
363
|
+
if [[ -f "$lock_file" ]]; then
|
|
364
|
+
lock_content=$(parse_lock_file "$lock_file")
|
|
365
|
+
holder=$(echo "$lock_content" | cut -d: -f1)
|
|
366
|
+
timestamp=$(echo "$lock_content" | cut -d: -f2)
|
|
367
|
+
|
|
368
|
+
if [[ -n "$timestamp" ]]; then
|
|
369
|
+
lock_time=$(date -d "@$timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -r "$timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "unknown")
|
|
370
|
+
fi
|
|
371
|
+
|
|
372
|
+
if is_stale_lock "$lock_file"; then
|
|
373
|
+
status="stale"
|
|
374
|
+
else
|
|
375
|
+
status="locked"
|
|
376
|
+
fi
|
|
377
|
+
else
|
|
378
|
+
status="available"
|
|
379
|
+
holder="-"
|
|
380
|
+
lock_time="-"
|
|
381
|
+
fi
|
|
382
|
+
|
|
383
|
+
if [[ "$json_output" == "--json" ]]; then
|
|
384
|
+
if [[ "$first" != "true" ]]; then
|
|
385
|
+
echo ","
|
|
386
|
+
fi
|
|
387
|
+
first=false
|
|
388
|
+
printf ' {"name": "%s", "status": "%s"' "$world" "$status"
|
|
389
|
+
if [[ "$status" != "available" ]]; then
|
|
390
|
+
printf ', "holder": "%s", "locked_at": "%s", "timestamp": %s' "$holder" "$lock_time" "${timestamp:-null}"
|
|
391
|
+
fi
|
|
392
|
+
printf "}"
|
|
393
|
+
else
|
|
394
|
+
printf "%-20s %-12s %-20s %s\n" "$world" "$status" "$holder" "$lock_time"
|
|
395
|
+
fi
|
|
396
|
+
done
|
|
397
|
+
|
|
398
|
+
if [[ "$json_output" == "--json" ]]; then
|
|
399
|
+
echo ""
|
|
400
|
+
echo " ],"
|
|
401
|
+
echo " \"locks_dir\": \"$LOCKS_DIR\","
|
|
402
|
+
echo " \"stale_threshold_seconds\": $STALE_THRESHOLD"
|
|
403
|
+
echo "}"
|
|
404
|
+
fi
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
# =============================================================================
|
|
408
|
+
# Main
|
|
409
|
+
# =============================================================================
|
|
410
|
+
|
|
411
|
+
main() {
|
|
412
|
+
local command="${1:-}"
|
|
413
|
+
shift || true
|
|
414
|
+
|
|
415
|
+
case "$command" in
|
|
416
|
+
lock)
|
|
417
|
+
lock_world "$@"
|
|
418
|
+
;;
|
|
419
|
+
unlock)
|
|
420
|
+
unlock_world "$@"
|
|
421
|
+
;;
|
|
422
|
+
check)
|
|
423
|
+
check_lock "$@"
|
|
424
|
+
;;
|
|
425
|
+
list)
|
|
426
|
+
list_locks "$@"
|
|
427
|
+
;;
|
|
428
|
+
-h|--help|help)
|
|
429
|
+
usage
|
|
430
|
+
exit 0
|
|
431
|
+
;;
|
|
432
|
+
"")
|
|
433
|
+
error "No command specified"
|
|
434
|
+
usage
|
|
435
|
+
exit 1
|
|
436
|
+
;;
|
|
437
|
+
*)
|
|
438
|
+
error "Unknown command: $command"
|
|
439
|
+
usage
|
|
440
|
+
exit 1
|
|
441
|
+
;;
|
|
442
|
+
esac
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
# Run main if script is executed directly
|
|
446
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
447
|
+
main "$@"
|
|
448
|
+
fi
|