db-sync-tool-kmi 2.11.6__py3-none-any.whl → 3.0.2__py3-none-any.whl
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.
- db_sync_tool/__main__.py +7 -252
- db_sync_tool/cli.py +733 -0
- db_sync_tool/database/process.py +94 -111
- db_sync_tool/database/utility.py +339 -121
- db_sync_tool/info.py +1 -1
- db_sync_tool/recipes/drupal.py +87 -12
- db_sync_tool/recipes/laravel.py +7 -6
- db_sync_tool/recipes/parsing.py +102 -0
- db_sync_tool/recipes/symfony.py +17 -28
- db_sync_tool/recipes/typo3.py +33 -54
- db_sync_tool/recipes/wordpress.py +13 -12
- db_sync_tool/remote/client.py +206 -71
- db_sync_tool/remote/file_transfer.py +303 -0
- db_sync_tool/remote/rsync.py +18 -15
- db_sync_tool/remote/system.py +2 -3
- db_sync_tool/remote/transfer.py +51 -47
- db_sync_tool/remote/utility.py +29 -30
- db_sync_tool/sync.py +52 -28
- db_sync_tool/utility/config.py +367 -0
- db_sync_tool/utility/config_resolver.py +573 -0
- db_sync_tool/utility/console.py +779 -0
- db_sync_tool/utility/exceptions.py +32 -0
- db_sync_tool/utility/helper.py +155 -148
- db_sync_tool/utility/info.py +53 -20
- db_sync_tool/utility/log.py +55 -31
- db_sync_tool/utility/logging_config.py +410 -0
- db_sync_tool/utility/mode.py +85 -150
- db_sync_tool/utility/output.py +122 -51
- db_sync_tool/utility/parser.py +33 -53
- db_sync_tool/utility/pure.py +93 -0
- db_sync_tool/utility/security.py +79 -0
- db_sync_tool/utility/system.py +277 -194
- db_sync_tool/utility/validation.py +2 -9
- db_sync_tool_kmi-3.0.2.dist-info/METADATA +99 -0
- db_sync_tool_kmi-3.0.2.dist-info/RECORD +44 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/WHEEL +1 -1
- db_sync_tool_kmi-2.11.6.dist-info/METADATA +0 -276
- db_sync_tool_kmi-2.11.6.dist-info/RECORD +0 -34
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/entry_points.txt +0 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info/licenses}/LICENSE +0 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/top_level.txt +0 -0
db_sync_tool/remote/client.py
CHANGED
|
@@ -1,39 +1,181 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: future_fstrings -*-
|
|
3
2
|
|
|
4
3
|
"""
|
|
5
|
-
|
|
4
|
+
SSH client management for remote connections.
|
|
5
|
+
|
|
6
|
+
Provides both legacy global access and modern context manager patterns:
|
|
7
|
+
|
|
8
|
+
Legacy (backwards compatible):
|
|
9
|
+
from db_sync_tool.remote import client
|
|
10
|
+
client.load_ssh_client_origin()
|
|
11
|
+
# use client.ssh_client_origin
|
|
12
|
+
client.close_ssh_clients()
|
|
13
|
+
|
|
14
|
+
Modern (recommended):
|
|
15
|
+
from db_sync_tool.remote.client import SSHClientManager
|
|
16
|
+
with SSHClientManager() as mgr:
|
|
17
|
+
mgr.load_origin()
|
|
18
|
+
# use mgr.origin
|
|
6
19
|
"""
|
|
7
20
|
|
|
8
|
-
import
|
|
21
|
+
import warnings
|
|
9
22
|
import paramiko
|
|
10
23
|
from db_sync_tool.utility import mode, system, helper, output
|
|
24
|
+
from db_sync_tool.utility.exceptions import DbSyncError
|
|
11
25
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
additional_ssh_clients = []
|
|
26
|
+
# Suppress paramiko warnings about unknown host keys
|
|
27
|
+
warnings.filterwarnings("ignore", message="Unknown.*host key", module="paramiko")
|
|
15
28
|
|
|
16
29
|
default_timeout = 600
|
|
17
30
|
|
|
18
31
|
|
|
32
|
+
class SSHClientManager:
|
|
33
|
+
"""
|
|
34
|
+
Manages SSH client connections with automatic cleanup.
|
|
35
|
+
|
|
36
|
+
Can be used as a context manager for guaranteed resource cleanup,
|
|
37
|
+
or instantiated directly for more control.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_instance: 'SSHClientManager | None' = None
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self._origin: paramiko.SSHClient | None = None
|
|
44
|
+
self._target: paramiko.SSHClient | None = None
|
|
45
|
+
self._additional: list[paramiko.SSHClient] = []
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def get_instance(cls) -> 'SSHClientManager':
|
|
49
|
+
"""Get or create the singleton instance."""
|
|
50
|
+
if cls._instance is None:
|
|
51
|
+
cls._instance = cls()
|
|
52
|
+
return cls._instance
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def origin(self) -> paramiko.SSHClient | None:
|
|
56
|
+
"""Get origin SSH client."""
|
|
57
|
+
return self._origin
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def target(self) -> paramiko.SSHClient | None:
|
|
61
|
+
"""Get target SSH client."""
|
|
62
|
+
return self._target
|
|
63
|
+
|
|
64
|
+
def _update_legacy_globals(self) -> None:
|
|
65
|
+
"""Sync internal state to legacy global variables."""
|
|
66
|
+
global ssh_client_origin, ssh_client_target, additional_ssh_clients
|
|
67
|
+
ssh_client_origin = self._origin
|
|
68
|
+
ssh_client_target = self._target
|
|
69
|
+
additional_ssh_clients = list(self._additional)
|
|
70
|
+
|
|
71
|
+
def load_origin(self) -> paramiko.SSHClient:
|
|
72
|
+
"""Load and return the origin SSH client."""
|
|
73
|
+
self._origin = load_ssh_client(mode.Client.ORIGIN)
|
|
74
|
+
self._update_legacy_globals()
|
|
75
|
+
helper.run_script(mode.Client.ORIGIN, 'before')
|
|
76
|
+
return self._origin
|
|
77
|
+
|
|
78
|
+
def load_target(self) -> paramiko.SSHClient:
|
|
79
|
+
"""Load and return the target SSH client."""
|
|
80
|
+
self._target = load_ssh_client(mode.Client.TARGET)
|
|
81
|
+
self._update_legacy_globals()
|
|
82
|
+
helper.run_script(mode.Client.TARGET, 'before')
|
|
83
|
+
return self._target
|
|
84
|
+
|
|
85
|
+
def add_additional(self, client: paramiko.SSHClient) -> None:
|
|
86
|
+
"""Register an additional SSH client for cleanup."""
|
|
87
|
+
self._additional.append(client)
|
|
88
|
+
self._update_legacy_globals()
|
|
89
|
+
|
|
90
|
+
def close_all(self) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Close all managed SSH connections.
|
|
93
|
+
|
|
94
|
+
Uses exception handling to ensure all cleanup steps complete,
|
|
95
|
+
even if individual steps fail. This is important since close_all()
|
|
96
|
+
is called from __exit__.
|
|
97
|
+
"""
|
|
98
|
+
errors = []
|
|
99
|
+
|
|
100
|
+
# Origin cleanup
|
|
101
|
+
try:
|
|
102
|
+
helper.run_script(mode.Client.ORIGIN, 'after')
|
|
103
|
+
except Exception as e:
|
|
104
|
+
errors.append(f"origin after-script: {e}")
|
|
105
|
+
try:
|
|
106
|
+
if self._origin is not None:
|
|
107
|
+
self._origin.close()
|
|
108
|
+
except Exception as e:
|
|
109
|
+
errors.append(f"origin close: {e}")
|
|
110
|
+
finally:
|
|
111
|
+
self._origin = None
|
|
112
|
+
|
|
113
|
+
# Target cleanup
|
|
114
|
+
try:
|
|
115
|
+
helper.run_script(mode.Client.TARGET, 'after')
|
|
116
|
+
except Exception as e:
|
|
117
|
+
errors.append(f"target after-script: {e}")
|
|
118
|
+
try:
|
|
119
|
+
if self._target is not None:
|
|
120
|
+
self._target.close()
|
|
121
|
+
except Exception as e:
|
|
122
|
+
errors.append(f"target close: {e}")
|
|
123
|
+
finally:
|
|
124
|
+
self._target = None
|
|
125
|
+
|
|
126
|
+
# Additional clients cleanup
|
|
127
|
+
for i, client in enumerate(self._additional):
|
|
128
|
+
try:
|
|
129
|
+
client.close()
|
|
130
|
+
except Exception as e:
|
|
131
|
+
errors.append(f"additional client {i}: {e}")
|
|
132
|
+
self._additional.clear()
|
|
133
|
+
|
|
134
|
+
# Global after-script
|
|
135
|
+
try:
|
|
136
|
+
helper.run_script(script='after')
|
|
137
|
+
except Exception as e:
|
|
138
|
+
errors.append(f"global after-script: {e}")
|
|
139
|
+
|
|
140
|
+
# Always sync globals
|
|
141
|
+
self._update_legacy_globals()
|
|
142
|
+
|
|
143
|
+
# Log errors if any occurred (but don't raise - cleanup should be silent)
|
|
144
|
+
if errors:
|
|
145
|
+
for err in errors:
|
|
146
|
+
output.message(output.Subject.WARNING, f"Cleanup error: {err}", verbose_only=True)
|
|
147
|
+
|
|
148
|
+
def __enter__(self) -> 'SSHClientManager':
|
|
149
|
+
"""Context manager entry."""
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
153
|
+
"""Context manager exit - ensures cleanup even on errors."""
|
|
154
|
+
self.close_all()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Legacy global variables for backwards compatibility
|
|
158
|
+
ssh_client_origin: paramiko.SSHClient | None = None
|
|
159
|
+
ssh_client_target: paramiko.SSHClient | None = None
|
|
160
|
+
additional_ssh_clients: list[paramiko.SSHClient] = []
|
|
161
|
+
|
|
162
|
+
|
|
19
163
|
def load_ssh_client_origin():
|
|
20
164
|
"""
|
|
21
|
-
Loading the origin ssh client
|
|
22
|
-
|
|
165
|
+
Loading the origin ssh client (legacy function).
|
|
166
|
+
|
|
167
|
+
Prefer using SSHClientManager for new code.
|
|
23
168
|
"""
|
|
24
|
-
|
|
25
|
-
ssh_client_origin = load_ssh_client(mode.Client.ORIGIN)
|
|
26
|
-
helper.run_script(mode.Client.ORIGIN, 'before')
|
|
169
|
+
SSHClientManager.get_instance().load_origin()
|
|
27
170
|
|
|
28
171
|
|
|
29
172
|
def load_ssh_client_target():
|
|
30
173
|
"""
|
|
31
|
-
Loading the target ssh client
|
|
32
|
-
|
|
174
|
+
Loading the target ssh client (legacy function).
|
|
175
|
+
|
|
176
|
+
Prefer using SSHClientManager for new code.
|
|
33
177
|
"""
|
|
34
|
-
|
|
35
|
-
ssh_client_target = load_ssh_client(mode.Client.TARGET)
|
|
36
|
-
helper.run_script(mode.Client.TARGET, 'before')
|
|
178
|
+
SSHClientManager.get_instance().load_target()
|
|
37
179
|
|
|
38
180
|
|
|
39
181
|
def load_ssh_client(ssh):
|
|
@@ -42,39 +184,42 @@ def load_ssh_client(ssh):
|
|
|
42
184
|
:param ssh: String
|
|
43
185
|
:return:
|
|
44
186
|
"""
|
|
187
|
+
cfg = system.get_typed_config()
|
|
188
|
+
client_cfg = cfg.get_client(ssh)
|
|
189
|
+
|
|
45
190
|
_host_name = helper.get_ssh_host_name(ssh, True)
|
|
46
191
|
_ssh_client = paramiko.SSHClient()
|
|
47
|
-
|
|
192
|
+
# Load known hosts from system for security (prevents MITM attacks)
|
|
193
|
+
_ssh_client.load_system_host_keys()
|
|
194
|
+
# Log warning for unknown hosts but still accept connection (convenience over strict security)
|
|
195
|
+
# Note: This does NOT prevent MITM - use RejectPolicy() for strict host verification
|
|
196
|
+
_ssh_client.set_missing_host_key_policy(paramiko.WarningPolicy())
|
|
48
197
|
|
|
49
|
-
_ssh_port =
|
|
198
|
+
_ssh_port = client_cfg.port
|
|
50
199
|
_ssh_key = None
|
|
51
200
|
_ssh_password = None
|
|
52
201
|
|
|
53
202
|
# Check authentication
|
|
54
|
-
if
|
|
203
|
+
if client_cfg.ssh_key:
|
|
55
204
|
_authentication_method = f'{output.CliFormat.BLACK} - ' \
|
|
56
205
|
f'(authentication: key){output.CliFormat.ENDC}'
|
|
57
|
-
_ssh_key =
|
|
58
|
-
elif
|
|
206
|
+
_ssh_key = client_cfg.ssh_key
|
|
207
|
+
elif client_cfg.password:
|
|
59
208
|
_authentication_method = f'{output.CliFormat.BLACK} - ' \
|
|
60
|
-
f'authentication: password){output.CliFormat.ENDC}'
|
|
61
|
-
_ssh_password =
|
|
62
|
-
elif
|
|
209
|
+
f'(authentication: password){output.CliFormat.ENDC}'
|
|
210
|
+
_ssh_password = client_cfg.password
|
|
211
|
+
elif cfg.ssh_agent:
|
|
63
212
|
_authentication_method = f'{output.CliFormat.BLACK} - ' \
|
|
64
213
|
f'(authentication: key){output.CliFormat.ENDC}'
|
|
65
214
|
else:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
output.Subject.ERROR,
|
|
69
|
-
'Missing SSH authentication. Neither ssh key nor ssh password given.',
|
|
70
|
-
False
|
|
71
|
-
)
|
|
215
|
+
raise DbSyncError(
|
|
216
|
+
'Missing SSH authentication. Neither ssh key nor ssh password given.'
|
|
72
217
|
)
|
|
73
218
|
|
|
74
219
|
# Try to connect to remote client via paramiko
|
|
75
220
|
try:
|
|
76
|
-
_ssh_client.connect(hostname=
|
|
77
|
-
username=
|
|
221
|
+
_ssh_client.connect(hostname=client_cfg.host,
|
|
222
|
+
username=client_cfg.user,
|
|
78
223
|
key_filename=_ssh_key,
|
|
79
224
|
password=_ssh_password,
|
|
80
225
|
port=_ssh_port,
|
|
@@ -88,13 +233,7 @@ def load_ssh_client(ssh):
|
|
|
88
233
|
_ssh_client.get_transport().set_keepalive(60)
|
|
89
234
|
|
|
90
235
|
except paramiko.ssh_exception.AuthenticationException:
|
|
91
|
-
|
|
92
|
-
output.message(
|
|
93
|
-
output.Subject.ERROR,
|
|
94
|
-
f'SSH authentication for {_host_name} failed',
|
|
95
|
-
False
|
|
96
|
-
)
|
|
97
|
-
)
|
|
236
|
+
raise DbSyncError(f'SSH authentication for {_host_name} failed') from None
|
|
98
237
|
|
|
99
238
|
output.message(
|
|
100
239
|
output.host_to_subject(ssh),
|
|
@@ -107,21 +246,11 @@ def load_ssh_client(ssh):
|
|
|
107
246
|
|
|
108
247
|
def close_ssh_clients():
|
|
109
248
|
"""
|
|
110
|
-
Closing ssh client sessions
|
|
111
|
-
:return:
|
|
112
|
-
"""
|
|
113
|
-
helper.run_script(mode.Client.ORIGIN, 'after')
|
|
114
|
-
if not ssh_client_origin is None:
|
|
115
|
-
ssh_client_origin.close()
|
|
249
|
+
Closing ssh client sessions (legacy function).
|
|
116
250
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
for additional_ssh_client in additional_ssh_clients:
|
|
122
|
-
additional_ssh_client.close()
|
|
123
|
-
|
|
124
|
-
helper.run_script(script='after')
|
|
251
|
+
Prefer using SSHClientManager as context manager for new code.
|
|
252
|
+
"""
|
|
253
|
+
SSHClientManager.get_instance().close_all()
|
|
125
254
|
|
|
126
255
|
|
|
127
256
|
def get_jump_host_channel(client):
|
|
@@ -131,26 +260,32 @@ def get_jump_host_channel(client):
|
|
|
131
260
|
:param client:
|
|
132
261
|
:return:
|
|
133
262
|
"""
|
|
263
|
+
cfg = system.get_typed_config()
|
|
264
|
+
client_cfg = cfg.get_client(client)
|
|
265
|
+
|
|
134
266
|
_jump_host_channel = None
|
|
135
|
-
if
|
|
267
|
+
if client_cfg.jump_host is not None:
|
|
268
|
+
jump_host = client_cfg.jump_host
|
|
269
|
+
|
|
136
270
|
# prepare jump host config
|
|
137
271
|
_jump_host_client = paramiko.SSHClient()
|
|
138
|
-
_jump_host_client.
|
|
272
|
+
_jump_host_client.load_system_host_keys()
|
|
273
|
+
_jump_host_client.set_missing_host_key_policy(paramiko.WarningPolicy())
|
|
139
274
|
|
|
140
|
-
_jump_host_host =
|
|
141
|
-
_jump_host_user =
|
|
275
|
+
_jump_host_host = jump_host.host
|
|
276
|
+
_jump_host_user = jump_host.user if jump_host.user else client_cfg.user
|
|
142
277
|
|
|
143
|
-
if
|
|
144
|
-
_jump_host_ssh_key =
|
|
145
|
-
elif
|
|
146
|
-
_jump_host_ssh_key =
|
|
278
|
+
if jump_host.ssh_key:
|
|
279
|
+
_jump_host_ssh_key = jump_host.ssh_key
|
|
280
|
+
elif client_cfg.ssh_key:
|
|
281
|
+
_jump_host_ssh_key = client_cfg.ssh_key
|
|
147
282
|
else:
|
|
148
283
|
_jump_host_ssh_key = None
|
|
149
284
|
|
|
150
|
-
if
|
|
151
|
-
_jump_host_port =
|
|
152
|
-
elif
|
|
153
|
-
_jump_host_port =
|
|
285
|
+
if jump_host.port:
|
|
286
|
+
_jump_host_port = jump_host.port
|
|
287
|
+
elif client_cfg.port:
|
|
288
|
+
_jump_host_port = client_cfg.port
|
|
154
289
|
else:
|
|
155
290
|
_jump_host_port = 22
|
|
156
291
|
|
|
@@ -159,26 +294,26 @@ def get_jump_host_channel(client):
|
|
|
159
294
|
hostname=_jump_host_host,
|
|
160
295
|
username=_jump_host_user,
|
|
161
296
|
key_filename=_jump_host_ssh_key,
|
|
162
|
-
password=
|
|
297
|
+
password=jump_host.password,
|
|
163
298
|
port=_jump_host_port,
|
|
164
299
|
compress=True,
|
|
165
300
|
timeout=default_timeout
|
|
166
301
|
)
|
|
167
302
|
|
|
168
|
-
global
|
|
169
|
-
|
|
303
|
+
# Register for cleanup via manager (also updates legacy global)
|
|
304
|
+
SSHClientManager.get_instance().add_additional(_jump_host_client)
|
|
170
305
|
|
|
171
306
|
# open the necessary channel
|
|
172
307
|
_jump_host_transport = _jump_host_client.get_transport()
|
|
173
308
|
_jump_host_channel = _jump_host_transport.open_channel(
|
|
174
309
|
'direct-tcpip',
|
|
175
|
-
dest_addr=(
|
|
176
|
-
src_addr=(
|
|
310
|
+
dest_addr=(client_cfg.host, 22),
|
|
311
|
+
src_addr=(jump_host.private if jump_host.private else jump_host.host, 22)
|
|
177
312
|
)
|
|
178
313
|
|
|
179
314
|
# print information
|
|
180
315
|
_destination_client = helper.get_ssh_host_name(client, minimal=True)
|
|
181
|
-
_jump_host_name =
|
|
316
|
+
_jump_host_name = jump_host.name if jump_host.name else _jump_host_host
|
|
182
317
|
output.message(
|
|
183
318
|
output.host_to_subject(client),
|
|
184
319
|
f'Initialize remote SSH jump host {output.CliFormat.BLACK}local ➔ {output.CliFormat.BOLD}{_jump_host_name}{output.CliFormat.ENDC}{output.CliFormat.BLACK} ➔ {_destination_client}{output.CliFormat.ENDC}',
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
File transfer module using rsync.
|
|
5
|
+
|
|
6
|
+
Provides file synchronization functionality integrated with db-sync-tool's
|
|
7
|
+
existing infrastructure for SSH authentication and rsync handling.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from db_sync_tool.utility import mode, system, output, helper
|
|
14
|
+
from db_sync_tool.utility.config import FileTransferConfig
|
|
15
|
+
from db_sync_tool.remote import rsync
|
|
16
|
+
|
|
17
|
+
# Temporary directory for PROXY mode file transfers
|
|
18
|
+
_temp_file_dir: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def transfer_files() -> None:
|
|
22
|
+
"""
|
|
23
|
+
Transfer configured files between clients.
|
|
24
|
+
|
|
25
|
+
Iterates through the files configuration and synchronizes each
|
|
26
|
+
origin/target pair using rsync.
|
|
27
|
+
"""
|
|
28
|
+
cfg = system.get_typed_config()
|
|
29
|
+
|
|
30
|
+
# Check if file transfer is enabled
|
|
31
|
+
if not cfg.with_files and not cfg.files_only:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# Skip if no files configured
|
|
35
|
+
if not cfg.files:
|
|
36
|
+
output.message(
|
|
37
|
+
output.Subject.WARNING,
|
|
38
|
+
'File transfer enabled but no files configured',
|
|
39
|
+
True
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
# Skip file transfer for dump/import modes
|
|
44
|
+
if mode.is_dump() or mode.is_import():
|
|
45
|
+
output.message(
|
|
46
|
+
output.Subject.WARNING,
|
|
47
|
+
'File transfer not available in dump/import mode',
|
|
48
|
+
True
|
|
49
|
+
)
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
output.message(
|
|
53
|
+
output.Subject.INFO,
|
|
54
|
+
f'Starting file transfer ({len(cfg.files)} configured)',
|
|
55
|
+
True
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
for i, file_config in enumerate(cfg.files, 1):
|
|
59
|
+
output.message(
|
|
60
|
+
output.Subject.INFO,
|
|
61
|
+
f'[{i}/{len(cfg.files)}] {file_config.origin} -> {file_config.target}',
|
|
62
|
+
True
|
|
63
|
+
)
|
|
64
|
+
_transfer_single(file_config)
|
|
65
|
+
|
|
66
|
+
output.message(
|
|
67
|
+
output.Subject.INFO,
|
|
68
|
+
'File transfer completed',
|
|
69
|
+
True
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _transfer_single(file_config: FileTransferConfig) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Transfer a single file configuration entry.
|
|
76
|
+
|
|
77
|
+
:param file_config: FileTransferConfig instance
|
|
78
|
+
"""
|
|
79
|
+
cfg = system.get_typed_config()
|
|
80
|
+
|
|
81
|
+
origin_path = _resolve_path(file_config.origin, mode.Client.ORIGIN)
|
|
82
|
+
target_path = _resolve_path(file_config.target, mode.Client.TARGET)
|
|
83
|
+
|
|
84
|
+
if cfg.dry_run:
|
|
85
|
+
output.message(
|
|
86
|
+
output.Subject.INFO,
|
|
87
|
+
f'[DRY RUN] Would sync {origin_path} -> {target_path}',
|
|
88
|
+
True
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
sync_mode_val = mode.get_sync_mode()
|
|
93
|
+
|
|
94
|
+
if sync_mode_val == mode.SyncMode.PROXY:
|
|
95
|
+
_transfer_proxy(origin_path, target_path, file_config)
|
|
96
|
+
elif sync_mode_val == mode.SyncMode.SYNC_REMOTE:
|
|
97
|
+
_transfer_remote_to_remote(origin_path, target_path, file_config)
|
|
98
|
+
else:
|
|
99
|
+
_transfer_standard(origin_path, target_path, file_config)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_path(path: str, client: str) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Resolve file path, making relative paths absolute.
|
|
105
|
+
|
|
106
|
+
:param path: Path (relative or absolute)
|
|
107
|
+
:param client: Client identifier (origin/target)
|
|
108
|
+
:return: Resolved absolute path
|
|
109
|
+
"""
|
|
110
|
+
if path.startswith('/'):
|
|
111
|
+
return path
|
|
112
|
+
|
|
113
|
+
base_path = system.get_typed_config().get_client(client).path
|
|
114
|
+
if not base_path:
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
# Preserve trailing slash for rsync (important for directory sync behavior)
|
|
118
|
+
resolved = str(Path(base_path).parent / path)
|
|
119
|
+
if path.endswith('/') and not resolved.endswith('/'):
|
|
120
|
+
resolved += '/'
|
|
121
|
+
return resolved
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_excludes(excludes: list[str]) -> str:
|
|
125
|
+
"""
|
|
126
|
+
Build rsync exclude arguments.
|
|
127
|
+
|
|
128
|
+
:param excludes: List of patterns to exclude
|
|
129
|
+
:return: String with --exclude flags
|
|
130
|
+
"""
|
|
131
|
+
if not excludes:
|
|
132
|
+
return ''
|
|
133
|
+
return ' '.join(f'--exclude={helper.quote_shell_arg(e)}' for e in excludes)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _get_file_options(file_config: FileTransferConfig) -> str:
|
|
137
|
+
"""
|
|
138
|
+
Get rsync options for file transfer.
|
|
139
|
+
|
|
140
|
+
:param file_config: FileTransferConfig instance
|
|
141
|
+
:return: Combined options string
|
|
142
|
+
"""
|
|
143
|
+
cfg = system.get_typed_config()
|
|
144
|
+
base_options = rsync.get_options()
|
|
145
|
+
|
|
146
|
+
# Add global file options
|
|
147
|
+
if cfg.files_options:
|
|
148
|
+
base_options += f' {cfg.files_options}'
|
|
149
|
+
|
|
150
|
+
# Add per-transfer options
|
|
151
|
+
if file_config.options:
|
|
152
|
+
base_options += f' {file_config.options}'
|
|
153
|
+
|
|
154
|
+
return base_options
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _transfer_standard(origin_path: str, target_path: str,
|
|
158
|
+
file_config: FileTransferConfig) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Standard file transfer (RECEIVER, SENDER, SYNC_LOCAL modes).
|
|
161
|
+
|
|
162
|
+
:param origin_path: Source path
|
|
163
|
+
:param target_path: Destination path
|
|
164
|
+
:param file_config: FileTransferConfig instance
|
|
165
|
+
"""
|
|
166
|
+
# Determine which client is remote
|
|
167
|
+
if mode.is_origin_remote():
|
|
168
|
+
remote_client = mode.Client.ORIGIN
|
|
169
|
+
elif mode.is_target_remote():
|
|
170
|
+
remote_client = mode.Client.TARGET
|
|
171
|
+
else:
|
|
172
|
+
remote_client = None
|
|
173
|
+
|
|
174
|
+
_run_rsync(
|
|
175
|
+
remote_client=remote_client,
|
|
176
|
+
origin_path=origin_path,
|
|
177
|
+
target_path=target_path,
|
|
178
|
+
file_config=file_config
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _transfer_proxy(origin_path: str, target_path: str,
|
|
183
|
+
file_config: FileTransferConfig) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Proxy mode file transfer (remote -> local -> remote).
|
|
186
|
+
|
|
187
|
+
:param origin_path: Source path
|
|
188
|
+
:param target_path: Destination path
|
|
189
|
+
:param file_config: FileTransferConfig instance
|
|
190
|
+
"""
|
|
191
|
+
global _temp_file_dir
|
|
192
|
+
|
|
193
|
+
# Create local temp directory
|
|
194
|
+
_temp_file_dir = system.default_local_sync_path + 'files/'
|
|
195
|
+
helper.check_and_create_dump_dir(mode.Client.LOCAL, _temp_file_dir)
|
|
196
|
+
|
|
197
|
+
output.message(
|
|
198
|
+
output.Subject.INFO,
|
|
199
|
+
'Proxy mode: origin -> local',
|
|
200
|
+
True
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Step 1: Origin -> Local
|
|
204
|
+
_run_rsync(
|
|
205
|
+
remote_client=mode.Client.ORIGIN,
|
|
206
|
+
origin_path=origin_path,
|
|
207
|
+
target_path=_temp_file_dir,
|
|
208
|
+
file_config=file_config
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
output.message(
|
|
212
|
+
output.Subject.INFO,
|
|
213
|
+
'Proxy mode: local -> target',
|
|
214
|
+
True
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Step 2: Local -> Target
|
|
218
|
+
_run_rsync(
|
|
219
|
+
remote_client=mode.Client.TARGET,
|
|
220
|
+
origin_path=_temp_file_dir,
|
|
221
|
+
target_path=target_path,
|
|
222
|
+
file_config=file_config
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _transfer_remote_to_remote(origin_path: str, target_path: str,
|
|
227
|
+
file_config: FileTransferConfig) -> None:
|
|
228
|
+
"""
|
|
229
|
+
Remote-to-remote file transfer (SYNC_REMOTE mode).
|
|
230
|
+
|
|
231
|
+
Executes rsync on origin to sync directly to target.
|
|
232
|
+
|
|
233
|
+
:param origin_path: Source path
|
|
234
|
+
:param target_path: Destination path
|
|
235
|
+
:param file_config: FileTransferConfig instance
|
|
236
|
+
"""
|
|
237
|
+
cfg = system.get_typed_config()
|
|
238
|
+
|
|
239
|
+
# Build rsync command to run on origin
|
|
240
|
+
excludes = _get_excludes(file_config.exclude)
|
|
241
|
+
options = _get_file_options(file_config)
|
|
242
|
+
password_env = rsync.get_password_environment(mode.Client.TARGET)
|
|
243
|
+
auth = rsync.get_authorization(mode.Client.TARGET)
|
|
244
|
+
target_host = f'{cfg.target.user}@{cfg.target.host}:'
|
|
245
|
+
|
|
246
|
+
command = (
|
|
247
|
+
f'{password_env}rsync {options} {auth} '
|
|
248
|
+
f'{excludes} {origin_path} {target_host}{target_path}'
|
|
249
|
+
).strip()
|
|
250
|
+
|
|
251
|
+
# Clean up multiple spaces
|
|
252
|
+
command = ' '.join(command.split())
|
|
253
|
+
|
|
254
|
+
mode.run_command(command, mode.Client.ORIGIN, True)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _run_rsync(remote_client: str | None, origin_path: str, target_path: str,
|
|
258
|
+
file_config: FileTransferConfig) -> None:
|
|
259
|
+
"""
|
|
260
|
+
Execute rsync command for file transfer.
|
|
261
|
+
|
|
262
|
+
:param remote_client: Client identifier for SSH authentication (or None for local)
|
|
263
|
+
:param origin_path: Source path
|
|
264
|
+
:param target_path: Destination path
|
|
265
|
+
:param file_config: FileTransferConfig instance
|
|
266
|
+
"""
|
|
267
|
+
excludes = _get_excludes(file_config.exclude)
|
|
268
|
+
options = _get_file_options(file_config)
|
|
269
|
+
|
|
270
|
+
# Build origin and target host prefixes
|
|
271
|
+
if remote_client == mode.Client.ORIGIN:
|
|
272
|
+
origin_host = rsync.get_host(mode.Client.ORIGIN)
|
|
273
|
+
target_host = ''
|
|
274
|
+
elif remote_client == mode.Client.TARGET:
|
|
275
|
+
origin_host = ''
|
|
276
|
+
target_host = rsync.get_host(mode.Client.TARGET)
|
|
277
|
+
else:
|
|
278
|
+
origin_host = ''
|
|
279
|
+
target_host = ''
|
|
280
|
+
|
|
281
|
+
# Build the rsync command
|
|
282
|
+
password_env = rsync.get_password_environment(remote_client) if remote_client else ''
|
|
283
|
+
auth = rsync.get_authorization(remote_client) if remote_client else ''
|
|
284
|
+
|
|
285
|
+
command = (
|
|
286
|
+
f'{password_env}rsync {options} {auth} {excludes} '
|
|
287
|
+
f'{origin_host}{origin_path} {target_host}{target_path}'
|
|
288
|
+
).strip()
|
|
289
|
+
|
|
290
|
+
# Clean up multiple spaces
|
|
291
|
+
command = ' '.join(command.split())
|
|
292
|
+
|
|
293
|
+
output_str = mode.run_command(command, mode.Client.LOCAL, True)
|
|
294
|
+
if output_str:
|
|
295
|
+
rsync.read_stats(output_str)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cleanup() -> None:
|
|
299
|
+
"""Clean up temporary file transfer directory."""
|
|
300
|
+
global _temp_file_dir
|
|
301
|
+
if _temp_file_dir and Path(_temp_file_dir).exists():
|
|
302
|
+
shutil.rmtree(_temp_file_dir, ignore_errors=True)
|
|
303
|
+
_temp_file_dir = None
|