apache-airflow-providers-teradata 3.2.3__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.
- airflow/providers/teradata/__init__.py +3 -3
- airflow/providers/teradata/get_provider_info.py +9 -0
- airflow/providers/teradata/hooks/bteq.py +1 -1
- airflow/providers/teradata/hooks/tpt.py +499 -0
- airflow/providers/teradata/hooks/ttu.py +1 -2
- airflow/providers/teradata/operators/teradata_compute_cluster.py +1 -1
- airflow/providers/teradata/operators/tpt.py +640 -0
- airflow/providers/teradata/triggers/teradata_compute_cluster.py +1 -1
- airflow/providers/teradata/utils/bteq_util.py +1 -1
- airflow/providers/teradata/utils/tpt_util.py +666 -0
- {apache_airflow_providers_teradata-3.2.3.dist-info → apache_airflow_providers_teradata-3.4.0.dist-info}/METADATA +10 -10
- {apache_airflow_providers_teradata-3.2.3.dist-info → apache_airflow_providers_teradata-3.4.0.dist-info}/RECORD +16 -13
- {apache_airflow_providers_teradata-3.2.3.dist-info → apache_airflow_providers_teradata-3.4.0.dist-info}/WHEEL +0 -0
- {apache_airflow_providers_teradata-3.2.3.dist-info → apache_airflow_providers_teradata-3.4.0.dist-info}/entry_points.txt +0 -0
- {apache_airflow_providers_teradata-3.2.3.dist-info → apache_airflow_providers_teradata-3.4.0.dist-info}/licenses/LICENSE +0 -0
- {apache_airflow_providers_teradata-3.2.3.dist-info → apache_airflow_providers_teradata-3.4.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -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()
|