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.
Files changed (41) hide show
  1. db_sync_tool/__main__.py +7 -252
  2. db_sync_tool/cli.py +733 -0
  3. db_sync_tool/database/process.py +94 -111
  4. db_sync_tool/database/utility.py +339 -121
  5. db_sync_tool/info.py +1 -1
  6. db_sync_tool/recipes/drupal.py +87 -12
  7. db_sync_tool/recipes/laravel.py +7 -6
  8. db_sync_tool/recipes/parsing.py +102 -0
  9. db_sync_tool/recipes/symfony.py +17 -28
  10. db_sync_tool/recipes/typo3.py +33 -54
  11. db_sync_tool/recipes/wordpress.py +13 -12
  12. db_sync_tool/remote/client.py +206 -71
  13. db_sync_tool/remote/file_transfer.py +303 -0
  14. db_sync_tool/remote/rsync.py +18 -15
  15. db_sync_tool/remote/system.py +2 -3
  16. db_sync_tool/remote/transfer.py +51 -47
  17. db_sync_tool/remote/utility.py +29 -30
  18. db_sync_tool/sync.py +52 -28
  19. db_sync_tool/utility/config.py +367 -0
  20. db_sync_tool/utility/config_resolver.py +573 -0
  21. db_sync_tool/utility/console.py +779 -0
  22. db_sync_tool/utility/exceptions.py +32 -0
  23. db_sync_tool/utility/helper.py +155 -148
  24. db_sync_tool/utility/info.py +53 -20
  25. db_sync_tool/utility/log.py +55 -31
  26. db_sync_tool/utility/logging_config.py +410 -0
  27. db_sync_tool/utility/mode.py +85 -150
  28. db_sync_tool/utility/output.py +122 -51
  29. db_sync_tool/utility/parser.py +33 -53
  30. db_sync_tool/utility/pure.py +93 -0
  31. db_sync_tool/utility/security.py +79 -0
  32. db_sync_tool/utility/system.py +277 -194
  33. db_sync_tool/utility/validation.py +2 -9
  34. db_sync_tool_kmi-3.0.2.dist-info/METADATA +99 -0
  35. db_sync_tool_kmi-3.0.2.dist-info/RECORD +44 -0
  36. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/WHEEL +1 -1
  37. db_sync_tool_kmi-2.11.6.dist-info/METADATA +0 -276
  38. db_sync_tool_kmi-2.11.6.dist-info/RECORD +0 -34
  39. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/entry_points.txt +0 -0
  40. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info/licenses}/LICENSE +0 -0
  41. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/top_level.txt +0 -0
@@ -1,39 +1,181 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: future_fstrings -*-
3
2
 
4
3
  """
5
- Client script
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 sys
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
- ssh_client_origin = None
13
- ssh_client_target = None
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
- :return:
165
+ Loading the origin ssh client (legacy function).
166
+
167
+ Prefer using SSHClientManager for new code.
23
168
  """
24
- global ssh_client_origin
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
- :return:
174
+ Loading the target ssh client (legacy function).
175
+
176
+ Prefer using SSHClientManager for new code.
33
177
  """
34
- global ssh_client_target
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
- _ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
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 = system.config[ssh]['port'] if 'port' in system.config[ssh] else 22
198
+ _ssh_port = client_cfg.port
50
199
  _ssh_key = None
51
200
  _ssh_password = None
52
201
 
53
202
  # Check authentication
54
- if 'ssh_key' in system.config[ssh]:
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 = system.config[ssh]['ssh_key']
58
- elif 'password' in system.config[ssh]:
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 = system.config[ssh]['password']
62
- elif 'ssh_agent' in system.config:
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
- sys.exit(
67
- output.message(
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=system.config[ssh]['host'],
77
- username=system.config[ssh]['user'],
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
- sys.exit(
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
- helper.run_script(mode.Client.TARGET, 'after')
118
- if not ssh_client_target is None:
119
- ssh_client_target.close()
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 'jump_host' in system.config[client]:
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.set_missing_host_key_policy(paramiko.AutoAddPolicy())
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 = system.config[client]['jump_host']['host']
141
- _jump_host_user = system.config[client]['jump_host']['user'] if 'user' in system.config[client]['jump_host'] else system.config[client]['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 'ssh_key' in system.config[client]['jump_host']:
144
- _jump_host_ssh_key = system.config[client]['jump_host']['ssh_key']
145
- elif 'ssh_key' in system.config[client]:
146
- _jump_host_ssh_key = system.config[client]['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 'port' in system.config[client]['jump_host']:
151
- _jump_host_port = system.config[client]['jump_host']['port']
152
- elif 'port' in system.config[client]:
153
- _jump_host_port = system.config[client]['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=system.config[client]['jump_host']['password'] if 'password' in system.config[client]['jump_host'] else None,
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 additional_ssh_clients
169
- additional_ssh_clients.append(_jump_host_client)
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=(system.config[client]['host'], 22),
176
- src_addr=(system.config[client]['jump_host']['private'] if 'private' in system.config[client]['jump_host'] else system.config[client]['jump_host']['host'], 22)
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 = system.config[client]['jump_host']['name'] if 'name' in system.config[client]['jump_host'] else _jump_host_host
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