Repository navigation
Expand file tree
/
Copy pathcontainer-backup.py
More file actions
268 lines (242 loc) · 11.7 KB
/
Copy pathcontainer-backup.py
File metadata and controls
268 lines (242 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
#!/usr/bin/python3
"""Non-root, container-native backup commands. No Docker socket required."""
import argparse
import contextlib
import fcntl
import json
import os
from pathlib import Path
import shutil
import sys
import tarfile
import tempfile
import time
import uuid
def drop_runtime_privileges():
"""Replace a root CLI invocation with this command running as tml."""
if os.geteuid() != 0:
return
os.execv('/usr/bin/setpriv', [
'setpriv', '--reuid=tml', '--regid=tml', '--init-groups',
'--no-new-privs', '--', sys.executable, os.path.realpath(__file__),
*sys.argv[1:],
])
if __name__ == '__main__':
drop_runtime_privileges()
sys.path.insert(0, '/terraria-server')
import backup
import admin_backup_details
DATA = Path('/data')
DEST = Path('/backups')
CONTROL = DATA / '.tmod-control'
RUNTIME = Path(os.environ.get('TMOD_RUNTIME_DIR', '/tmp/tmodloader'))
@contextlib.contextmanager
def exclusive(path):
with path.open('a') as stream:
try:
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError('Data is in use; stop the server before restoring.')
yield
def preflight(check_reserve=True):
if not DEST.is_mount() or os.path.samefile(DEST, DATA):
raise ValueError('/backups must be a separate writable mount, not container storage.')
for root, dirs, files in os.walk(DATA, followlinks=False):
if os.path.samefile(root, DEST):
raise ValueError('/backups cannot refer to a directory inside /data.')
with tempfile.TemporaryFile(dir=DEST):
pass
reserve = int(os.environ.get('TMOD_BACKUP_MIN_FREE_MB', '1024')) * 1024 * 1024
if reserve < 0:
raise ValueError('TMOD_BACKUP_MIN_FREE_MB must be non-negative.')
if check_reserve and shutil.disk_usage(DEST).free < reserve:
raise ValueError('Backup storage is below TMOD_BACKUP_MIN_FREE_MB; free space before retrying.')
def identity():
return admin_backup_details.current_build()
def cold_backup():
preflight()
if (RUNTIME / 'server.pid').exists():
raise RuntimeError('Refusing to archive while the server is running.')
# Only the supervisor calls this, after reaping a cleanly exited server.
source = CONTROL / 'dataset-id'
if not source.exists():
source.write_text(uuid.uuid4().hex)
info = {'Image': identity(), 'Config': {'Image': 'container-build-fingerprint'},
'Source': source.read_text().strip()}
info['Runtime'] = admin_backup_details.current_runtime()
try:
info['Running'] = json.loads((RUNTIME / 'admin-effective.json').read_text())
except (OSError, ValueError):
pass
if os.environ.get('TMOD_USECONFIGFILE', 'No').lower() in ('yes', 'true', '1'):
info['Running'] = {'TMOD_WORLDNAME': None}
result = backup.create_backup(DATA, DEST, info)
return result
def request_backup():
try:
preflight()
except Exception:
import admin_metrics
admin_metrics.record('failed', 'Backup mount or free-space preflight failed; server was not stopped')
raise
if not (RUNTIME / 'supervisor.pid').exists():
raise RuntimeError('No running supervisor; use docker compose exec for backup.')
with exclusive(RUNTIME / 'backup-client.lock'):
request_id = uuid.uuid4().hex
response = RUNTIME / 'backup-result'
response.unlink(missing_ok=True)
request = RUNTIME / 'backup-request'
temporary = RUNTIME / 'backup-request.tmp'
temporary.write_text(request_id)
temporary.replace(request)
print('Backup requested; waiting for shutdown, archive verification and restart.', flush=True)
while True:
if response.exists():
result = response.read_text().splitlines()
if result and result[0] == request_id:
print('\n'.join(result[2:]))
if result[1] != '0':
raise RuntimeError('Backup failed; inspect docker compose logs.')
return
pid = int((RUNTIME / 'supervisor.pid').read_text())
try:
os.kill(pid, 0)
except ProcessLookupError:
raise RuntimeError('Supervisor exited; inspect logs before retrying.')
time.sleep(1)
def preview(bundle):
import admin_recovery
bundle = admin_recovery.archive_path(bundle.name)
preflight(check_reserve=False)
metadata = backup.validate(bundle)
if metadata['image_id'] != identity():
raise ValueError('Backup build fingerprint differs; use the original image to restore.')
with tarfile.open(bundle / 'data.tar.gz', 'r:gz') as tar:
members = tar.getmembers()
required = sum(m.size for m in members if m.isfile())
free = shutil.disk_usage(DATA).free
if required > free:
raise ValueError('Insufficient space to stage restored data alongside original data.')
return {'archive': bundle.name, 'sha256': metadata['sha256'], 'created': metadata.get('created'),
'worlds': [Path(m.name).name for m in members if m.name.startswith('data/tModLoader/Worlds/') and m.name.endswith('.wld')],
'snapshot': admin_backup_details.inspect_archive(bundle)['snapshot'],
'required_bytes': required, 'free_bytes': free,
'replaces': 'Worlds, mods, mod configuration, logs and saved dashboard settings. Current admin credentials and Compose settings are preserved.'}
def supervisor_lock():
# The supervisor retains its lock throughout the operation. Never unlock it
# to let an external restore race a server restart.
if os.getppid() != int((RUNTIME / 'supervisor.pid').read_text()):
raise RuntimeError('Supervised restore must be called directly by the supervisor.')
if not os.path.samestat(os.fstat(9), (CONTROL / 'server.lock').stat()):
raise RuntimeError('Missing inherited server lock.')
fcntl.flock(9, fcntl.LOCK_EX | fcntl.LOCK_NB)
if (RUNTIME / 'server.pid').exists():
raise RuntimeError('The game must be stopped before recovery.')
return contextlib.nullcontext()
def restore(bundle, supervised=False, expected=None):
if supervised:
import admin_recovery
bundle = admin_recovery.archive_path(bundle.name)
preflight(check_reserve=False)
CONTROL.mkdir(mode=0o700, exist_ok=True)
with supervisor_lock() if supervised else exclusive(CONTROL / 'server.lock'):
pending = CONTROL / 'restore-pending'
if pending.exists():
raise RuntimeError('An interrupted restore needs manual recovery; see /data/.tmod-control/restore-pending.')
metadata = backup.validate(bundle)
if expected is not None and metadata['sha256'] != expected:
raise ValueError('Archive changed since preview. Preview it again before restoring.')
if metadata['image_id'] != identity():
raise ValueError('Backup build fingerprint differs; use the original image to restore.')
backup.scan_tree(DATA)
with tarfile.open(bundle / 'data.tar.gz', 'r:gz') as tar:
required = sum(m.size for m in tar if m.isfile())
if required > shutil.disk_usage(DATA).free:
raise ValueError('Insufficient space to stage restored data alongside original data.')
with tempfile.TemporaryDirectory(prefix='restore-stage-', dir=CONTROL) as temp:
stage = Path(temp)
with tarfile.open(bundle / 'data.tar.gz', 'r:gz') as tar:
tar.extractall(stage, filter='fully_trusted')
if supervised:
# Keep the dashboard session and future logins on the same
# credential, even when the backup predates a token change.
import admin_auth
token = admin_auth.token_path()
if token.is_relative_to(DATA) and token.relative_to(DATA).parts[0] != '.tmod-control':
target = stage / 'data' / token.relative_to(DATA)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(token, target)
for root, dirs, files in os.walk(stage, topdown=False):
for name in files:
with (Path(root) / name).open('rb') as stream:
os.fsync(stream.fileno())
backup.sync_directory(Path(root))
old = CONTROL / ('before-restore-' + uuid.uuid4().hex)
old.mkdir(mode=0o700)
pending.write_text(f'Original data: {old}\nArchive: {bundle}\n')
with pending.open('r+b') as stream:
os.fsync(stream.fileno())
backup.sync_directory(CONTROL)
# Mount roots cannot be renamed. Keep originals on the same filesystem
# and block server startup if interrupted during the per-entry move.
for item in DATA.iterdir():
if item.name != '.tmod-control':
item.rename(old / item.name)
backup.sync_directory(old)
for item in (stage / 'data').iterdir():
item.rename(DATA / item.name)
backup.sync_directory(DATA)
pending.unlink()
backup.sync_directory(CONTROL)
print(f'Restore complete. Original data retained at {old}. Start the server and verify health.')
def main():
os.umask(0o077)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=['backup', 'verify', 'restore', '_cold', '_retain', '_preflight', '_preview', '_restore', '_inspect', '_prepare'])
parser.add_argument('--archive', type=Path)
parser.add_argument('--sha256')
parser.add_argument('--confirm', action='store_true')
args = parser.parse_args()
if args.command in ('verify', 'restore', '_retain', '_preview', '_restore', '_inspect', '_prepare') and args.archive is None:
parser.error('--archive is required')
if args.command == 'backup':
request_backup()
elif args.command == '_preflight':
preflight()
elif args.command == '_cold':
result = cold_backup()
(RUNTIME / 'backup-archive').write_text(str(result))
elif args.command == '_retain':
backup.retain(DEST, int(os.environ.get('TMOD_BACKUP_KEEP', '7')), args.archive)
elif args.command == 'verify':
print(json.dumps(backup.validate(args.archive), indent=2))
elif args.command in ('_inspect', '_prepare'):
import admin_recovery
bundle = admin_recovery.archive_path(args.archive.name)
if args.command == '_inspect':
result = admin_backup_details.inspect_archive(bundle)
else:
if not args.confirm or not args.sha256:
raise ValueError('Explicit confirmation and inspected checksum are required.')
preflight()
result = admin_backup_details.prepare(bundle, DEST, args.sha256, int(os.environ.get('TMOD_BACKUP_MIN_FREE_MB', '1024')) * 1024 * 1024)
print(json.dumps(result))
elif args.command == '_preview':
result = preview(args.archive)
if args.sha256 and result['sha256'] != args.sha256:
raise ValueError('Archive changed since preview. Preview it again.')
print(json.dumps(result))
elif args.command == '_restore':
if not args.confirm or not args.sha256:
parser.error('Supervised restore requires confirmation and a preview checksum.')
restore(args.archive, supervised=True, expected=args.sha256)
elif args.command == 'restore':
if not args.confirm:
parser.error('Restore requires --confirm and a stopped server.')
restore(args.archive.resolve())
if __name__ == '__main__':
try:
main()
except Exception as error:
print(f'ERROR: {error}', file=sys.stderr)
sys.exit(1)