apache-airflow-providers-teradata 3.2.3rc1__py3-none-any.whl → 3.4.0__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.
@@ -0,0 +1,666 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import shutil
22
+ import stat
23
+ import subprocess
24
+ import uuid
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ if TYPE_CHECKING:
28
+ from paramiko import SSHClient
29
+
30
+
31
+ class TPTConfig:
32
+ """Configuration constants for TPT operations."""
33
+
34
+ DEFAULT_TIMEOUT = 5
35
+ FILE_PERMISSIONS_READ_ONLY = 0o400
36
+ TEMP_DIR_WINDOWS = "C:\\Windows\\Temp"
37
+ TEMP_DIR_UNIX = "/tmp"
38
+
39
+
40
+ def execute_remote_command(ssh_client: SSHClient, command: str) -> tuple[int, str, str]:
41
+ """
42
+ Execute a command on remote host and properly manage SSH channels.
43
+
44
+ :param ssh_client: SSH client connection
45
+ :param command: Command to execute
46
+ :return: Tuple of (exit_status, stdout, stderr)
47
+ """
48
+ stdin, stdout, stderr = ssh_client.exec_command(command)
49
+ try:
50
+ exit_status = stdout.channel.recv_exit_status()
51
+ stdout_data = stdout.read().decode().strip()
52
+ stderr_data = stderr.read().decode().strip()
53
+ return exit_status, stdout_data, stderr_data
54
+ finally:
55
+ stdin.close()
56
+ stdout.close()
57
+ stderr.close()
58
+
59
+
60
+ def write_file(path: str, content: str) -> None:
61
+ with open(path, "w", encoding="utf-8") as f:
62
+ f.write(content)
63
+
64
+
65
+ def secure_delete(file_path: str, logger: logging.Logger | None = None) -> None:
66
+ """
67
+ Securely delete a file using shred if available, otherwise use os.remove.
68
+
69
+ :param file_path: Path to the file to be deleted
70
+ :param logger: Optional logger instance
71
+ """
72
+ logger = logger or logging.getLogger(__name__)
73
+ if not os.path.exists(file_path):
74
+ return
75
+
76
+ try:
77
+ # Check if shred is available
78
+ if shutil.which("shred") is not None:
79
+ # Use shred to securely delete the file
80
+ subprocess.run(["shred", "--remove", file_path], check=True, timeout=TPTConfig.DEFAULT_TIMEOUT)
81
+ logger.info("Securely removed file using shred: %s", file_path)
82
+ else:
83
+ # Fall back to regular deletion
84
+ os.remove(file_path)
85
+ logger.info("Removed file: %s", file_path)
86
+
87
+ except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
88
+ logger.warning("Failed to remove file %s: %s", file_path, str(e))
89
+
90
+
91
+ def remote_secure_delete(
92
+ ssh_client: SSHClient, remote_files: list[str], logger: logging.Logger | None = None
93
+ ) -> None:
94
+ """
95
+ Securely delete remote files via SSH. Attempts shred first, falls back to rm if shred is unavailable.
96
+
97
+ :param ssh_client: SSH client connection
98
+ :param remote_files: List of remote file paths to delete
99
+ :param logger: Optional logger instance
100
+ """
101
+ logger = logger or logging.getLogger(__name__)
102
+ if not ssh_client or not remote_files:
103
+ return
104
+
105
+ try:
106
+ # Detect remote OS
107
+ remote_os = get_remote_os(ssh_client, logger)
108
+ windows_remote = remote_os == "windows"
109
+
110
+ # Check if shred is available on remote system (UNIX/Linux)
111
+ shred_available = False
112
+ if not windows_remote:
113
+ exit_status, output, _ = execute_remote_command(ssh_client, "command -v shred")
114
+ shred_available = exit_status == 0 and output.strip() != ""
115
+
116
+ for file_path in remote_files:
117
+ try:
118
+ if windows_remote:
119
+ # Windows remote host - use del command
120
+ replace_slash = file_path.replace("/", "\\")
121
+ execute_remote_command(
122
+ ssh_client, f'if exist "{replace_slash}" del /f /q "{replace_slash}"'
123
+ )
124
+ elif shred_available:
125
+ # UNIX/Linux with shred
126
+ execute_remote_command(ssh_client, f"shred --remove {file_path}")
127
+ else:
128
+ # UNIX/Linux without shred - overwrite then delete
129
+ execute_remote_command(
130
+ ssh_client,
131
+ f"if [ -f {file_path} ]; then "
132
+ f"dd if=/dev/zero of={file_path} bs=4096 count=$(($(stat -c '%s' {file_path})/4096+1)) 2>/dev/null; "
133
+ f"rm -f {file_path}; fi",
134
+ )
135
+ except Exception as e:
136
+ logger.warning("Failed to process remote file %s: %s", file_path, str(e))
137
+
138
+ logger.info("Processed remote files: %s", ", ".join(remote_files))
139
+ except Exception as e:
140
+ logger.warning("Failed to remove remote files: %s", str(e))
141
+
142
+
143
+ def terminate_subprocess(sp: subprocess.Popen | None, logger: logging.Logger | None = None) -> None:
144
+ """
145
+ Terminate a subprocess gracefully with proper error handling.
146
+
147
+ :param sp: Subprocess to terminate
148
+ :param logger: Optional logger instance
149
+ """
150
+ logger = logger or logging.getLogger(__name__)
151
+
152
+ if not sp or sp.poll() is not None:
153
+ # Process is None or already terminated
154
+ return
155
+
156
+ logger.info("Terminating subprocess (PID: %s)", sp.pid)
157
+
158
+ try:
159
+ sp.terminate() # Attempt to terminate gracefully
160
+ sp.wait(timeout=TPTConfig.DEFAULT_TIMEOUT)
161
+ logger.info("Subprocess terminated gracefully")
162
+ except subprocess.TimeoutExpired:
163
+ logger.warning(
164
+ "Subprocess did not terminate gracefully within %d seconds, killing it", TPTConfig.DEFAULT_TIMEOUT
165
+ )
166
+ try:
167
+ sp.kill()
168
+ sp.wait(timeout=2) # Brief wait after kill
169
+ logger.info("Subprocess killed successfully")
170
+ except Exception as e:
171
+ logger.error("Error killing subprocess: %s", str(e))
172
+ except Exception as e:
173
+ logger.error("Error terminating subprocess: %s", str(e))
174
+
175
+
176
+ def get_remote_os(ssh_client: SSHClient, logger: logging.Logger | None = None) -> str:
177
+ """
178
+ Detect the operating system of the remote host via SSH.
179
+
180
+ :param ssh_client: SSH client connection
181
+ :param logger: Optional logger instance
182
+ :return: Operating system type as string ('windows' or 'unix')
183
+ """
184
+ logger = logger or logging.getLogger(__name__)
185
+
186
+ if not ssh_client:
187
+ logger.warning("No SSH client provided for OS detection")
188
+ return "unix"
189
+
190
+ try:
191
+ # Check for Windows first
192
+ exit_status, stdout_data, stderr_data = execute_remote_command(ssh_client, "echo %OS%")
193
+
194
+ if "Windows" in stdout_data:
195
+ return "windows"
196
+
197
+ # All other systems are treated as Unix-like
198
+ return "unix"
199
+
200
+ except Exception as e:
201
+ logger.error("Error detecting remote OS: %s", str(e))
202
+ return "unix"
203
+
204
+
205
+ def set_local_file_permissions(local_file_path: str, logger: logging.Logger | None = None) -> None:
206
+ """
207
+ Set permissions for a local file to be read-only for the owner.
208
+
209
+ :param local_file_path: Path to the local file
210
+ :param logger: Optional logger instance
211
+ :raises FileNotFoundError: If the file does not exist
212
+ :raises OSError: If setting permissions fails
213
+ """
214
+ logger = logger or logging.getLogger(__name__)
215
+
216
+ if not local_file_path:
217
+ logger.warning("No file path provided for permission setting")
218
+ return
219
+
220
+ if not os.path.exists(local_file_path):
221
+ raise FileNotFoundError(f"File does not exist: {local_file_path}")
222
+
223
+ try:
224
+ # Set file permission to read-only for the owner (400)
225
+ os.chmod(local_file_path, TPTConfig.FILE_PERMISSIONS_READ_ONLY)
226
+ logger.info("Set read-only permissions for file %s", local_file_path)
227
+ except (OSError, PermissionError) as e:
228
+ raise OSError(f"Error setting permissions for local file {local_file_path}: {e}") from e
229
+
230
+
231
+ def _set_windows_file_permissions(
232
+ ssh_client: SSHClient, remote_file_path: str, logger: logging.Logger
233
+ ) -> None:
234
+ """Set restrictive permissions on Windows remote file."""
235
+ command = f'icacls "{remote_file_path}" /inheritance:r /grant:r "%USERNAME%":R'
236
+
237
+ exit_status, stdout_data, stderr_data = execute_remote_command(ssh_client, command)
238
+
239
+ if exit_status != 0:
240
+ raise RuntimeError(
241
+ f"Failed to set restrictive permissions on Windows remote file {remote_file_path}. "
242
+ f"Exit status: {exit_status}, Error: {stderr_data if stderr_data else 'N/A'}"
243
+ )
244
+ logger.info("Set restrictive permissions (owner read-only) for Windows remote file %s", remote_file_path)
245
+
246
+
247
+ def _set_unix_file_permissions(ssh_client: SSHClient, remote_file_path: str, logger: logging.Logger) -> None:
248
+ """Set read-only permissions on Unix/Linux remote file."""
249
+ command = f"chmod 400 {remote_file_path}"
250
+
251
+ exit_status, stdout_data, stderr_data = execute_remote_command(ssh_client, command)
252
+
253
+ if exit_status != 0:
254
+ raise RuntimeError(
255
+ f"Failed to set permissions (400) on remote file {remote_file_path}. "
256
+ f"Exit status: {exit_status}, Error: {stderr_data if stderr_data else 'N/A'}"
257
+ )
258
+ logger.info("Set read-only permissions for remote file %s", remote_file_path)
259
+
260
+
261
+ def set_remote_file_permissions(
262
+ ssh_client: SSHClient, remote_file_path: str, logger: logging.Logger | None = None
263
+ ) -> None:
264
+ """
265
+ Set permissions for a remote file to be read-only for the owner.
266
+
267
+ :param ssh_client: SSH client connection
268
+ :param remote_file_path: Path to the remote file
269
+ :param logger: Optional logger instance
270
+ :raises RuntimeError: If permission setting fails
271
+ """
272
+ logger = logger or logging.getLogger(__name__)
273
+
274
+ if not ssh_client or not remote_file_path:
275
+ logger.warning(
276
+ "Invalid parameters: ssh_client=%s, remote_file_path=%s", bool(ssh_client), remote_file_path
277
+ )
278
+ return
279
+
280
+ try:
281
+ # Detect remote OS once
282
+ remote_os = get_remote_os(ssh_client, logger)
283
+
284
+ if remote_os == "windows":
285
+ _set_windows_file_permissions(ssh_client, remote_file_path, logger)
286
+ else:
287
+ _set_unix_file_permissions(ssh_client, remote_file_path, logger)
288
+
289
+ except RuntimeError:
290
+ raise
291
+ except Exception as e:
292
+ raise RuntimeError(f"Error setting permissions for remote file {remote_file_path}: {e}") from e
293
+
294
+
295
+ def get_remote_temp_directory(ssh_client: SSHClient, logger: logging.Logger | None = None) -> str:
296
+ """
297
+ Get the remote temporary directory path based on the operating system.
298
+
299
+ :param ssh_client: SSH client connection
300
+ :param logger: Optional logger instance
301
+ :return: Path to the remote temporary directory
302
+ """
303
+ logger = logger or logging.getLogger(__name__)
304
+
305
+ try:
306
+ # Detect OS once
307
+ remote_os = get_remote_os(ssh_client, logger)
308
+
309
+ if remote_os == "windows":
310
+ exit_status, temp_dir, stderr_data = execute_remote_command(ssh_client, "echo %TEMP%")
311
+
312
+ if exit_status == 0 and temp_dir and temp_dir != "%TEMP%":
313
+ return temp_dir
314
+ logger.warning("Could not get TEMP directory, using default: %s", TPTConfig.TEMP_DIR_WINDOWS)
315
+ return TPTConfig.TEMP_DIR_WINDOWS
316
+
317
+ # Unix/Linux - use /tmp
318
+ return TPTConfig.TEMP_DIR_UNIX
319
+
320
+ except Exception as e:
321
+ logger.warning("Error getting remote temp directory: %s", str(e))
322
+ return TPTConfig.TEMP_DIR_UNIX
323
+
324
+
325
+ def is_valid_file(file_path: str) -> bool:
326
+ return os.path.isfile(file_path)
327
+
328
+
329
+ def verify_tpt_utility_installed(utility: str) -> None:
330
+ """Verify if a TPT utility (e.g., tbuild) is installed and available in the system's PATH."""
331
+ if shutil.which(utility) is None:
332
+ raise FileNotFoundError(
333
+ f"TPT utility '{utility}' is not installed or not available in the system's PATH"
334
+ )
335
+
336
+
337
+ def verify_tpt_utility_on_remote_host(
338
+ ssh_client: SSHClient, utility: str, logger: logging.Logger | None = None
339
+ ) -> None:
340
+ """
341
+ Verify if a TPT utility (tbuild) is installed on the remote host via SSH.
342
+
343
+ :param ssh_client: SSH client connection
344
+ :param utility: Name of the utility to verify
345
+ :param logger: Optional logger instance
346
+ :raises FileNotFoundError: If utility is not found on remote host
347
+ :raises RuntimeError: If verification fails unexpectedly
348
+ """
349
+ logger = logger or logging.getLogger(__name__)
350
+
351
+ try:
352
+ # Detect remote OS once
353
+ remote_os = get_remote_os(ssh_client, logger)
354
+
355
+ if remote_os == "windows":
356
+ command = f"where {utility}"
357
+ else:
358
+ command = f"which {utility}"
359
+
360
+ exit_status, output, error = execute_remote_command(ssh_client, command)
361
+
362
+ if exit_status != 0 or not output:
363
+ raise FileNotFoundError(
364
+ f"TPT utility '{utility}' is not installed or not available in PATH on the remote host. "
365
+ f"Command: {command}, Exit status: {exit_status}, "
366
+ f"stderr: {error if error else 'N/A'}"
367
+ )
368
+
369
+ logger.info("TPT utility '%s' found at: %s", utility, output.split("\n")[0])
370
+
371
+ except (FileNotFoundError, RuntimeError):
372
+ raise
373
+ except Exception as e:
374
+ raise RuntimeError(f"Failed to verify TPT utility '{utility}' on remote host: {e}") from e
375
+
376
+
377
+ def prepare_tpt_ddl_script(
378
+ sql: list[str],
379
+ error_list: list[int] | None,
380
+ source_conn: dict[str, Any],
381
+ job_name: str | None = None,
382
+ ) -> str:
383
+ """
384
+ Prepare a TPT script for executing DDL statements.
385
+
386
+ This method generates a TPT script that defines a DDL operator and applies the provided SQL statements.
387
+ It also supports specifying a list of error codes to handle during the operation.
388
+
389
+ :param sql: A list of DDL statements to execute.
390
+ :param error_list: A list of error codes to handle during the operation.
391
+ :param source_conn: Connection details for the source database.
392
+ :param job_name: The name of the TPT job. Defaults to unique name if None.
393
+ :return: A formatted TPT script as a string.
394
+ :raises ValueError: If the SQL statement list is empty.
395
+ """
396
+ if not sql or not isinstance(sql, list):
397
+ raise ValueError("SQL statement list must be a non-empty list")
398
+
399
+ # Clean and escape each SQL statement:
400
+ sql_statements = [
401
+ stmt.strip().rstrip(";").replace("'", "''")
402
+ for stmt in sql
403
+ if stmt and isinstance(stmt, str) and stmt.strip()
404
+ ]
405
+
406
+ if not sql_statements:
407
+ raise ValueError("No valid SQL statements found in the provided input")
408
+
409
+ # Format for TPT APPLY block, indenting after the first line
410
+ apply_sql = ",\n".join(
411
+ [f"('{stmt};')" if i == 0 else f" ('{stmt};')" for i, stmt in enumerate(sql_statements)]
412
+ )
413
+
414
+ if job_name is None:
415
+ job_name = f"airflow_tptddl_{uuid.uuid4().hex}"
416
+
417
+ # Format error list for inclusion in the TPT script
418
+ if not error_list:
419
+ error_list_stmt = "ErrorList = ['']"
420
+ else:
421
+ error_list_str = ", ".join([f"'{error}'" for error in error_list])
422
+ error_list_stmt = f"ErrorList = [{error_list_str}]"
423
+
424
+ host = source_conn["host"]
425
+ login = source_conn["login"]
426
+ password = source_conn["password"]
427
+
428
+ tpt_script = f"""
429
+ DEFINE JOB {job_name}
430
+ DESCRIPTION 'TPT DDL Operation'
431
+ (
432
+ APPLY
433
+ {apply_sql}
434
+ TO OPERATOR ( $DDL ()
435
+ ATTR
436
+ (
437
+ TdpId = '{host}',
438
+ UserName = '{login}',
439
+ UserPassword = '{password}',
440
+ {error_list_stmt}
441
+ )
442
+ );
443
+ );
444
+ """
445
+ return tpt_script
446
+
447
+
448
+ def prepare_tdload_job_var_file(
449
+ mode: str,
450
+ source_table: str | None,
451
+ select_stmt: str | None,
452
+ insert_stmt: str | None,
453
+ target_table: str | None,
454
+ source_file_name: str | None,
455
+ target_file_name: str | None,
456
+ source_format: str,
457
+ target_format: str,
458
+ source_text_delimiter: str,
459
+ target_text_delimiter: str,
460
+ source_conn: dict[str, Any],
461
+ target_conn: dict[str, Any] | None = None,
462
+ ) -> str:
463
+ """
464
+ Prepare a tdload job variable file based on the specified mode.
465
+
466
+ :param mode: The operation mode ('file_to_table', 'table_to_file', or 'table_to_table')
467
+ :param source_table: Name of the source table
468
+ :param select_stmt: SQL SELECT statement for data extraction
469
+ :param insert_stmt: SQL INSERT statement for data loading
470
+ :param target_table: Name of the target table
471
+ :param source_file_name: Path to the source file
472
+ :param target_file_name: Path to the target file
473
+ :param source_format: Format of source data
474
+ :param target_format: Format of target data
475
+ :param source_text_delimiter: Source text delimiter
476
+ :param target_text_delimiter: Target text delimiter
477
+ :return: The content of the job variable file
478
+ :raises ValueError: If invalid parameters are provided
479
+ """
480
+ # Create a dictionary to store job variables
481
+ job_vars = {}
482
+
483
+ # Add appropriate parameters based on the mode
484
+ if mode == "file_to_table":
485
+ job_vars.update(
486
+ {
487
+ "TargetTdpId": source_conn["host"],
488
+ "TargetUserName": source_conn["login"],
489
+ "TargetUserPassword": source_conn["password"],
490
+ "TargetTable": target_table,
491
+ "SourceFileName": source_file_name,
492
+ }
493
+ )
494
+ if insert_stmt:
495
+ job_vars["InsertStmt"] = insert_stmt
496
+
497
+ elif mode == "table_to_file":
498
+ job_vars.update(
499
+ {
500
+ "SourceTdpId": source_conn["host"],
501
+ "SourceUserName": source_conn["login"],
502
+ "SourceUserPassword": source_conn["password"],
503
+ "TargetFileName": target_file_name,
504
+ }
505
+ )
506
+
507
+ if source_table:
508
+ job_vars["SourceTable"] = source_table
509
+ elif select_stmt:
510
+ job_vars["SourceSelectStmt"] = select_stmt
511
+
512
+ elif mode == "table_to_table":
513
+ if target_conn is None:
514
+ raise ValueError("target_conn must be provided for 'table_to_table' mode")
515
+ job_vars.update(
516
+ {
517
+ "SourceTdpId": source_conn["host"],
518
+ "SourceUserName": source_conn["login"],
519
+ "SourceUserPassword": source_conn["password"],
520
+ "TargetTdpId": target_conn["host"],
521
+ "TargetUserName": target_conn["login"],
522
+ "TargetUserPassword": target_conn["password"],
523
+ "TargetTable": target_table,
524
+ }
525
+ )
526
+
527
+ if source_table:
528
+ job_vars["SourceTable"] = source_table
529
+ elif select_stmt:
530
+ job_vars["SourceSelectStmt"] = select_stmt
531
+ if insert_stmt:
532
+ job_vars["InsertStmt"] = insert_stmt
533
+
534
+ # Add common parameters if not empty
535
+ if source_format:
536
+ job_vars["SourceFormat"] = source_format
537
+ if target_format:
538
+ job_vars["TargetFormat"] = target_format
539
+ if source_text_delimiter:
540
+ job_vars["SourceTextDelimiter"] = source_text_delimiter
541
+ if target_text_delimiter:
542
+ job_vars["TargetTextDelimiter"] = target_text_delimiter
543
+
544
+ # Format job variables content
545
+ job_var_content = "".join([f"{key}='{value}',\n" for key, value in job_vars.items()])
546
+ job_var_content = job_var_content.rstrip(",\n")
547
+
548
+ return job_var_content
549
+
550
+
551
+ def is_valid_remote_job_var_file(
552
+ ssh_client: SSHClient, remote_job_var_file_path: str, logger: logging.Logger | None = None
553
+ ) -> bool:
554
+ """Check if the given remote job variable file path is a valid file."""
555
+ if remote_job_var_file_path:
556
+ sftp_client = ssh_client.open_sftp()
557
+ try:
558
+ # Get file metadata
559
+ file_stat = sftp_client.stat(remote_job_var_file_path)
560
+ if file_stat.st_mode:
561
+ is_regular_file = stat.S_ISREG(file_stat.st_mode)
562
+ return is_regular_file
563
+ return False
564
+ except FileNotFoundError:
565
+ if logger:
566
+ logger.error("File does not exist on remote at : %s", remote_job_var_file_path)
567
+ return False
568
+ finally:
569
+ sftp_client.close()
570
+ else:
571
+ return False
572
+
573
+
574
+ def read_file(file_path: str, encoding: str = "UTF-8") -> str:
575
+ """
576
+ Read the content of a file with the specified encoding.
577
+
578
+ :param file_path: Path to the file to be read.
579
+ :param encoding: Encoding to use for reading the file.
580
+ :return: Content of the file as a string.
581
+ """
582
+ if not os.path.isfile(file_path):
583
+ raise FileNotFoundError(f"The file {file_path} does not exist.")
584
+
585
+ with open(file_path, encoding=encoding) as f:
586
+ return f.read()
587
+
588
+
589
+ def decrypt_remote_file(
590
+ ssh_client: SSHClient,
591
+ remote_enc_file: str,
592
+ remote_dec_file: str,
593
+ password: str,
594
+ logger: logging.Logger | None = None,
595
+ ) -> int:
596
+ """
597
+ Decrypt a remote file using OpenSSL.
598
+
599
+ :param ssh_client: SSH client connection
600
+ :param remote_enc_file: Path to the encrypted file
601
+ :param remote_dec_file: Path for the decrypted file
602
+ :param password: Decryption password
603
+ :param logger: Optional logger instance
604
+ :return: Exit status of the decryption command
605
+ :raises RuntimeError: If decryption fails
606
+ """
607
+ logger = logger or logging.getLogger(__name__)
608
+
609
+ # Detect remote OS
610
+ remote_os = get_remote_os(ssh_client, logger)
611
+ windows_remote = remote_os == "windows"
612
+
613
+ if windows_remote:
614
+ # Windows - use different quoting and potentially different OpenSSL parameters
615
+ password_escaped = password.replace('"', '""') # Escape double quotes for Windows
616
+ decrypt_cmd = (
617
+ f'openssl enc -d -aes-256-cbc -salt -pbkdf2 -pass pass:"{password_escaped}" '
618
+ f'-in "{remote_enc_file}" -out "{remote_dec_file}"'
619
+ )
620
+ else:
621
+ # Unix/Linux - use single quote escaping
622
+ password_escaped = password.replace("'", "'\\''") # Escape single quotes
623
+ decrypt_cmd = (
624
+ f"openssl enc -d -aes-256-cbc -salt -pbkdf2 -pass pass:'{password_escaped}' "
625
+ f"-in {remote_enc_file} -out {remote_dec_file}"
626
+ )
627
+
628
+ exit_status, stdout_data, stderr_data = execute_remote_command(ssh_client, decrypt_cmd)
629
+
630
+ if exit_status != 0:
631
+ raise RuntimeError(
632
+ f"Decryption failed with exit status {exit_status}. Error: {stderr_data if stderr_data else 'N/A'}"
633
+ )
634
+
635
+ logger.info("Successfully decrypted remote file %s to %s", remote_enc_file, remote_dec_file)
636
+ return exit_status
637
+
638
+
639
+ def transfer_file_sftp(
640
+ ssh_client: SSHClient, local_path: str, remote_path: str, logger: logging.Logger | None = None
641
+ ) -> None:
642
+ """
643
+ Transfer a file from local to remote host using SFTP.
644
+
645
+ :param ssh_client: SSH client connection
646
+ :param local_path: Local file path
647
+ :param remote_path: Remote file path
648
+ :param logger: Optional logger instance
649
+ :raises FileNotFoundError: If local file does not exist
650
+ :raises RuntimeError: If file transfer fails
651
+ """
652
+ logger = logger or logging.getLogger(__name__)
653
+
654
+ if not os.path.exists(local_path):
655
+ raise FileNotFoundError(f"Local file does not exist: {local_path}")
656
+
657
+ sftp = None
658
+ try:
659
+ sftp = ssh_client.open_sftp()
660
+ sftp.put(local_path, remote_path)
661
+ logger.info("Successfully transferred file from %s to %s", local_path, remote_path)
662
+ except Exception as e:
663
+ raise RuntimeError(f"Failed to transfer file from {local_path} to {remote_path}: {e}") from e
664
+ finally:
665
+ if sftp:
666
+ sftp.close()