📦 deps(thirdparty): update snapshots
This commit is contained in:
+66
@@ -0,0 +1,66 @@
|
||||
"""Path guards for local Monte Carlo template manifests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _allow_external_paths() -> bool:
|
||||
return os.getenv("MCD_ALLOW_EXTERNAL_PATHS", "").lower() in {"1", "true", "yes"}
|
||||
|
||||
|
||||
def _is_relative_to(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_local_path(raw_path: str, *, expect_file: bool = False, create_parent: bool = False) -> Path:
|
||||
value = str(raw_path).strip()
|
||||
if not value or "\0" in value:
|
||||
raise ValueError("Path must be a non-empty filesystem path")
|
||||
base = Path.cwd().resolve()
|
||||
candidate = Path(value).expanduser()
|
||||
resolved = (candidate if candidate.is_absolute() else base / candidate).resolve()
|
||||
if not _allow_external_paths() and not _is_relative_to(resolved, base):
|
||||
raise ValueError(f"Path must stay under the current working directory: {raw_path!r}")
|
||||
if expect_file and not resolved.is_file():
|
||||
raise FileNotFoundError(f"Input file not found: {resolved}")
|
||||
if create_parent:
|
||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
return resolved
|
||||
|
||||
|
||||
def safe_input_json_path(raw_path: str) -> Path:
|
||||
path = _resolve_local_path(raw_path, expect_file=True)
|
||||
if path.suffix.lower() != ".json":
|
||||
raise ValueError(f"Input manifest must be a .json file: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def safe_output_json_path(raw_path: str) -> Path:
|
||||
path = _resolve_local_path(raw_path, create_parent=True)
|
||||
if path.suffix.lower() != ".json":
|
||||
raise ValueError(f"Output manifest must be a .json file: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def safe_existing_directory(raw_path: str) -> Path:
|
||||
path = _resolve_local_path(raw_path)
|
||||
if not path.is_dir():
|
||||
raise NotADirectoryError(f"Directory not found: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def read_json_file(raw_path: str):
|
||||
with safe_input_json_path(raw_path).open() as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def write_json_file(raw_path: str, payload, *, indent: int = 2, default=None) -> None:
|
||||
with safe_output_json_path(raw_path).open("w") as fh:
|
||||
json.dump(payload, fh, indent=indent, default=default)
|
||||
+18
-6
@@ -24,8 +24,9 @@ import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from collect_lineage import LOOKBACK_HOURS, collect
|
||||
from collect_lineage import LOOKBACK_HOURS, _bounded_int, collect, validate_redshift_host
|
||||
from push_lineage import DEFAULT_BATCH_SIZE, push
|
||||
from _safe_paths import safe_output_json_path
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -33,7 +34,6 @@ log = logging.getLogger(__name__)
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Collect and push Redshift lineage to Monte Carlo")
|
||||
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
|
||||
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
|
||||
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
|
||||
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
|
||||
@@ -46,25 +46,37 @@ def main() -> None:
|
||||
parser.add_argument("--manifest", default="manifest_lineage.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
required = ["host", "db", "user", "password", "resource_uuid", "key_id", "key_token"]
|
||||
required = ["db", "user", "password", "resource_uuid", "key_id", "key_token"]
|
||||
missing = [k for k in required if getattr(args, k) is None]
|
||||
if missing:
|
||||
parser.error(f"Missing required arguments/env vars: {missing}")
|
||||
|
||||
manifest_path = str(safe_output_json_path(args.manifest))
|
||||
|
||||
redshift_host = os.getenv("REDSHIFT_HOST")
|
||||
if not redshift_host:
|
||||
parser.error("Missing required env var: REDSHIFT_HOST")
|
||||
redshift_host = validate_redshift_host(
|
||||
redshift_host,
|
||||
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
|
||||
)
|
||||
args.port = _bounded_int(args.port, "port", minimum=1, maximum=65535)
|
||||
args.lookback_hours = _bounded_int(args.lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
|
||||
|
||||
log.info("Step 1: Collecting lineage …")
|
||||
collect(
|
||||
host=args.host,
|
||||
host=redshift_host,
|
||||
db=args.db,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
manifest_path=args.manifest,
|
||||
manifest_path=manifest_path,
|
||||
port=args.port,
|
||||
lookback_hours=args.lookback_hours,
|
||||
)
|
||||
|
||||
log.info("Step 2: Pushing lineage to Monte Carlo …")
|
||||
push(
|
||||
manifest_path=args.manifest,
|
||||
manifest_path=manifest_path,
|
||||
resource_uuid=args.resource_uuid,
|
||||
key_id=args.key_id,
|
||||
key_token=args.key_token,
|
||||
|
||||
+17
-6
@@ -28,8 +28,9 @@ import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from collect_metadata import collect
|
||||
from collect_metadata import _bounded_int, collect, validate_redshift_host
|
||||
from push_metadata import DEFAULT_BATCH_SIZE, push
|
||||
from _safe_paths import safe_output_json_path
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -37,7 +38,6 @@ log = logging.getLogger(__name__)
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Collect and push Redshift metadata to Monte Carlo")
|
||||
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
|
||||
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
|
||||
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
|
||||
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
|
||||
@@ -49,24 +49,35 @@ def main() -> None:
|
||||
parser.add_argument("--manifest", default="manifest_metadata.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
required = ["host", "db", "user", "password", "resource_uuid", "key_id", "key_token"]
|
||||
required = ["db", "user", "password", "resource_uuid", "key_id", "key_token"]
|
||||
missing = [k for k in required if getattr(args, k) is None]
|
||||
if missing:
|
||||
parser.error(f"Missing required arguments/env vars: {missing}")
|
||||
|
||||
manifest_path = str(safe_output_json_path(args.manifest))
|
||||
|
||||
redshift_host = os.getenv("REDSHIFT_HOST")
|
||||
if not redshift_host:
|
||||
parser.error("Missing required env var: REDSHIFT_HOST")
|
||||
redshift_host = validate_redshift_host(
|
||||
redshift_host,
|
||||
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
|
||||
)
|
||||
args.port = _bounded_int(args.port, "port", minimum=1, maximum=65535)
|
||||
|
||||
log.info("Step 1: Collecting metadata …")
|
||||
collect(
|
||||
host=args.host,
|
||||
host=redshift_host,
|
||||
db=args.db,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
manifest_path=args.manifest,
|
||||
manifest_path=manifest_path,
|
||||
port=args.port,
|
||||
)
|
||||
|
||||
log.info("Step 2: Pushing metadata to Monte Carlo …")
|
||||
push(
|
||||
manifest_path=args.manifest,
|
||||
manifest_path=manifest_path,
|
||||
resource_uuid=args.resource_uuid,
|
||||
key_id=args.key_id,
|
||||
key_token=args.key_token,
|
||||
|
||||
+29
-6
@@ -28,8 +28,17 @@ import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from collect_query_logs import BATCH_SIZE, LOOKBACK_HOURS, LOOKBACK_LAG_HOURS, MAX_QUERIES, collect
|
||||
from collect_query_logs import (
|
||||
BATCH_SIZE,
|
||||
LOOKBACK_HOURS,
|
||||
LOOKBACK_LAG_HOURS,
|
||||
MAX_QUERIES,
|
||||
_bounded_int,
|
||||
collect,
|
||||
validate_redshift_host,
|
||||
)
|
||||
from push_query_logs import DEFAULT_BATCH_SIZE, push
|
||||
from _safe_paths import safe_output_json_path
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -37,7 +46,6 @@ log = logging.getLogger(__name__)
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Collect and push Redshift query logs to Monte Carlo")
|
||||
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
|
||||
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
|
||||
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
|
||||
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
|
||||
@@ -53,18 +61,33 @@ def main() -> None:
|
||||
parser.add_argument("--manifest", default="manifest_query_logs.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
required = ["host", "db", "user", "password", "resource_uuid", "key_id", "key_token"]
|
||||
required = ["db", "user", "password", "resource_uuid", "key_id", "key_token"]
|
||||
missing = [k for k in required if getattr(args, k) is None]
|
||||
if missing:
|
||||
parser.error(f"Missing required arguments/env vars: {missing}")
|
||||
|
||||
manifest_path = str(safe_output_json_path(args.manifest))
|
||||
|
||||
redshift_host = os.getenv("REDSHIFT_HOST")
|
||||
if not redshift_host:
|
||||
parser.error("Missing required env var: REDSHIFT_HOST")
|
||||
redshift_host = validate_redshift_host(
|
||||
redshift_host,
|
||||
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
|
||||
)
|
||||
args.port = _bounded_int(args.port, "port", minimum=1, maximum=65535)
|
||||
args.lookback_hours = _bounded_int(args.lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
|
||||
args.lookback_lag_hours = _bounded_int(args.lookback_lag_hours, "lookback_lag_hours", minimum=0, maximum=24 * 7)
|
||||
args.batch_size = _bounded_int(args.batch_size, "batch_size", minimum=1, maximum=10000)
|
||||
args.max_queries = _bounded_int(args.max_queries, "max_queries", minimum=1, maximum=100000)
|
||||
|
||||
log.info("Step 1: Collecting query logs …")
|
||||
collect(
|
||||
host=args.host,
|
||||
host=redshift_host,
|
||||
db=args.db,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
manifest_path=args.manifest,
|
||||
manifest_path=manifest_path,
|
||||
port=args.port,
|
||||
lookback_hours=args.lookback_hours,
|
||||
lookback_lag_hours=args.lookback_lag_hours,
|
||||
@@ -74,7 +97,7 @@ def main() -> None:
|
||||
|
||||
log.info("Step 2: Pushing query logs to Monte Carlo …")
|
||||
push(
|
||||
manifest_path=args.manifest,
|
||||
manifest_path=manifest_path,
|
||||
resource_uuid=args.resource_uuid,
|
||||
key_id=args.key_id,
|
||||
key_token=args.key_token,
|
||||
|
||||
+70
-7
@@ -18,6 +18,7 @@ Prerequisites:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -26,6 +27,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import psycopg2
|
||||
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -33,6 +35,55 @@ log = logging.getLogger(__name__)
|
||||
RESOURCE_TYPE = "redshift"
|
||||
LOOKBACK_HOURS: int = int(os.getenv("LOOKBACK_HOURS", "24")) # ← SUBSTITUTE
|
||||
|
||||
_ALLOWED_REDSHIFT_HOST_RE = re.compile(
|
||||
r"^[a-z0-9][a-z0-9.-]*\.(?:redshift|redshift-serverless)\.[a-z0-9-]+\.amazonaws\.com(?:\.cn)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _explicitly_allowed_redshift_hosts() -> set[str]:
|
||||
raw_hosts = os.getenv("REDSHIFT_ALLOWED_HOSTS", "")
|
||||
return {host.strip().lower().rstrip(".") for host in raw_hosts.split(",") if host.strip()}
|
||||
|
||||
|
||||
def validate_redshift_host(host: str, *, allow_private: bool = False) -> str:
|
||||
value = str(host).strip()
|
||||
if not value or any(part in value for part in ("/", "\\", "@", ":")):
|
||||
raise ValueError(f"Invalid Redshift host: {host!r}")
|
||||
hostname = value.lower().rstrip(".")
|
||||
allowed_hosts = _explicitly_allowed_redshift_hosts()
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
if hostname in allowed_hosts:
|
||||
return hostname
|
||||
match = _ALLOWED_REDSHIFT_HOST_RE.fullmatch(hostname)
|
||||
if match:
|
||||
return match.group(0)
|
||||
raise ValueError(
|
||||
"Redshift host must be an AWS Redshift endpoint or be listed in REDSHIFT_ALLOWED_HOSTS"
|
||||
)
|
||||
if hostname not in allowed_hosts:
|
||||
raise ValueError("Redshift IP hosts must be listed in REDSHIFT_ALLOWED_HOSTS")
|
||||
blocked = (
|
||||
address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_multicast
|
||||
or address.is_unspecified
|
||||
or address.is_reserved
|
||||
or (address.is_private and not allow_private)
|
||||
)
|
||||
if blocked:
|
||||
raise ValueError(f"Redshift host address is not allowed: {host!r}")
|
||||
return str(address)
|
||||
|
||||
|
||||
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
|
||||
value = int(value)
|
||||
if value < minimum or value > maximum:
|
||||
raise ValueError(f"{field} must be between {minimum} and {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _check_available_memory(min_gb: float = 2.0) -> None:
|
||||
"""Warn if available memory is below the threshold."""
|
||||
@@ -96,9 +147,10 @@ def _dictfetch(cursor: Any, sql: str, params: tuple | None = None) -> list[dict[
|
||||
|
||||
def fetch_query_texts(cursor: Any, lookback_hours: int) -> list[str]:
|
||||
"""Assemble full query texts from sys_query_history + sys_querytext."""
|
||||
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
|
||||
rows = _dictfetch(
|
||||
cursor,
|
||||
f"""
|
||||
"""
|
||||
SELECT
|
||||
sq.query_id,
|
||||
LISTAGG(
|
||||
@@ -107,11 +159,12 @@ def fetch_query_texts(cursor: Any, lookback_hours: int) -> list[str]:
|
||||
) WITHIN GROUP (ORDER BY st.sequence) AS full_text
|
||||
FROM sys_query_history sq
|
||||
JOIN sys_querytext st ON sq.query_id = st.query_id
|
||||
WHERE sq.start_time >= DATEADD(hour, -{lookback_hours}, GETDATE())
|
||||
WHERE sq.start_time >= DATEADD(hour, -%s, GETDATE())
|
||||
AND sq.status = 'success'
|
||||
GROUP BY sq.query_id
|
||||
LIMIT 50000
|
||||
""", # ← SUBSTITUTE: adjust lookback_hours, LIMIT, or add user/database filters
|
||||
(lookback_hours,),
|
||||
)
|
||||
return [r["full_text"] for r in rows if r.get("full_text")]
|
||||
|
||||
@@ -171,6 +224,10 @@ def collect(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Connect to Redshift, collect lineage, write a JSON manifest, and return events."""
|
||||
_check_available_memory()
|
||||
allow_private_host = os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"}
|
||||
host = validate_redshift_host(host, allow_private=allow_private_host)
|
||||
port = _bounded_int(port, "port", minimum=1, maximum=65535)
|
||||
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
|
||||
collected_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
conn = psycopg2.connect(
|
||||
@@ -197,8 +254,7 @@ def collect(
|
||||
"lineage_event_count": len(all_events),
|
||||
"events": all_events,
|
||||
}
|
||||
with open(manifest_path, "w") as fh:
|
||||
json.dump(manifest, fh, indent=2)
|
||||
write_json_file(manifest_path, manifest)
|
||||
log.info("Manifest written to %s (%d events)", manifest_path, len(all_events))
|
||||
|
||||
return all_events
|
||||
@@ -206,7 +262,6 @@ def collect(
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Collect Redshift lineage to a manifest file")
|
||||
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
|
||||
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
|
||||
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
|
||||
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
|
||||
@@ -215,13 +270,21 @@ def main() -> None:
|
||||
parser.add_argument("--manifest", default="manifest_lineage.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
required = ["host", "db", "user", "password"]
|
||||
required = ["db", "user", "password"]
|
||||
missing = [k for k in required if getattr(args, k) is None]
|
||||
if missing:
|
||||
parser.error(f"Missing required arguments/env vars: {missing}")
|
||||
|
||||
redshift_host = os.getenv("REDSHIFT_HOST")
|
||||
if not redshift_host:
|
||||
parser.error("Missing required env var: REDSHIFT_HOST")
|
||||
redshift_host = validate_redshift_host(
|
||||
redshift_host,
|
||||
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
|
||||
)
|
||||
|
||||
collect(
|
||||
host=args.host,
|
||||
host=redshift_host,
|
||||
db=args.db,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
|
||||
+71
-6
@@ -20,14 +20,17 @@ Prerequisites:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -43,6 +46,59 @@ SCHEMA_EXCLUSIONS: set[str] = { # ← SUBSTITUTE: add internal schemas
|
||||
"catalog_history",
|
||||
}
|
||||
|
||||
_ALLOWED_REDSHIFT_HOST_RE = re.compile(
|
||||
r"^[a-z0-9][a-z0-9.-]*\.(?:redshift|redshift-serverless)\.[a-z0-9-]+\.amazonaws\.com(?:\.cn)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _sql_literal(value: str) -> str:
|
||||
return "'" + str(value).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _explicitly_allowed_redshift_hosts() -> set[str]:
|
||||
raw_hosts = os.getenv("REDSHIFT_ALLOWED_HOSTS", "")
|
||||
return {host.strip().lower().rstrip(".") for host in raw_hosts.split(",") if host.strip()}
|
||||
|
||||
|
||||
def validate_redshift_host(host: str, *, allow_private: bool = False) -> str:
|
||||
value = str(host).strip()
|
||||
if not value or any(part in value for part in ("/", "\\", "@", ":")):
|
||||
raise ValueError(f"Invalid Redshift host: {host!r}")
|
||||
hostname = value.lower().rstrip(".")
|
||||
allowed_hosts = _explicitly_allowed_redshift_hosts()
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
if hostname in allowed_hosts:
|
||||
return hostname
|
||||
match = _ALLOWED_REDSHIFT_HOST_RE.fullmatch(hostname)
|
||||
if match:
|
||||
return match.group(0)
|
||||
raise ValueError(
|
||||
"Redshift host must be an AWS Redshift endpoint or be listed in REDSHIFT_ALLOWED_HOSTS"
|
||||
)
|
||||
if hostname not in allowed_hosts:
|
||||
raise ValueError("Redshift IP hosts must be listed in REDSHIFT_ALLOWED_HOSTS")
|
||||
blocked = (
|
||||
address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_multicast
|
||||
or address.is_unspecified
|
||||
or address.is_reserved
|
||||
or (address.is_private and not allow_private)
|
||||
)
|
||||
if blocked:
|
||||
raise ValueError(f"Redshift host address is not allowed: {host!r}")
|
||||
return str(address)
|
||||
|
||||
|
||||
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
|
||||
value = int(value)
|
||||
if value < minimum or value > maximum:
|
||||
raise ValueError(f"{field} must be between {minimum} and {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _check_available_memory(min_gb: float = 2.0) -> None:
|
||||
"""Warn if available memory is below the threshold."""
|
||||
@@ -85,7 +141,7 @@ def collect_databases(cursor: Any) -> list[str]:
|
||||
|
||||
|
||||
def collect_tables(cursor: Any, db: str) -> list[dict[str, Any]]:
|
||||
schema_list = ", ".join(f"'{s}'" for s in SCHEMA_EXCLUSIONS)
|
||||
schema_list = ", ".join(_sql_literal(s) for s in sorted(SCHEMA_EXCLUSIONS))
|
||||
return _dictfetch(
|
||||
cursor,
|
||||
f"""
|
||||
@@ -129,6 +185,9 @@ def collect(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Connect to Redshift, collect metadata, write a JSON manifest, and return asset dicts."""
|
||||
_check_available_memory()
|
||||
allow_private_host = os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"}
|
||||
host = validate_redshift_host(host, allow_private=allow_private_host)
|
||||
port = _bounded_int(port, "port", minimum=1, maximum=65535)
|
||||
collected_at = datetime.now(timezone.utc).isoformat()
|
||||
assets: list[dict[str, Any]] = []
|
||||
|
||||
@@ -183,8 +242,7 @@ def collect(
|
||||
"asset_count": len(assets),
|
||||
"assets": assets,
|
||||
}
|
||||
with open(manifest_path, "w") as fh:
|
||||
json.dump(manifest, fh, indent=2)
|
||||
write_json_file(manifest_path, manifest)
|
||||
log.info("Manifest written to %s (%d assets)", manifest_path, len(assets))
|
||||
|
||||
return assets
|
||||
@@ -192,7 +250,6 @@ def collect(
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Collect Redshift metadata to a manifest file")
|
||||
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
|
||||
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
|
||||
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
|
||||
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
|
||||
@@ -200,13 +257,21 @@ def main() -> None:
|
||||
parser.add_argument("--manifest", default="manifest_metadata.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
required = ["host", "db", "user", "password"]
|
||||
required = ["db", "user", "password"]
|
||||
missing = [k for k in required if getattr(args, k) is None]
|
||||
if missing:
|
||||
parser.error(f"Missing required arguments/env vars: {missing}")
|
||||
|
||||
redshift_host = os.getenv("REDSHIFT_HOST")
|
||||
if not redshift_host:
|
||||
parser.error("Missing required env var: REDSHIFT_HOST")
|
||||
redshift_host = validate_redshift_host(
|
||||
redshift_host,
|
||||
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
|
||||
)
|
||||
|
||||
collect(
|
||||
host=args.host,
|
||||
host=redshift_host,
|
||||
db=args.db,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
|
||||
+82
-13
@@ -20,13 +20,16 @@ Prerequisites:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import psycopg2
|
||||
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -38,6 +41,55 @@ LOOKBACK_LAG_HOURS: int = int(os.getenv("LOOKBACK_LAG_HOURS", "1")) # ← SUBSTI
|
||||
BATCH_SIZE: int = int(os.getenv("BATCH_SIZE", "200")) # ← SUBSTITUTE
|
||||
MAX_QUERIES: int = int(os.getenv("MAX_QUERIES", "10000")) # ← SUBSTITUTE
|
||||
|
||||
_ALLOWED_REDSHIFT_HOST_RE = re.compile(
|
||||
r"^[a-z0-9][a-z0-9.-]*\.(?:redshift|redshift-serverless)\.[a-z0-9-]+\.amazonaws\.com(?:\.cn)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _explicitly_allowed_redshift_hosts() -> set[str]:
|
||||
raw_hosts = os.getenv("REDSHIFT_ALLOWED_HOSTS", "")
|
||||
return {host.strip().lower().rstrip(".") for host in raw_hosts.split(",") if host.strip()}
|
||||
|
||||
|
||||
def validate_redshift_host(host: str, *, allow_private: bool = False) -> str:
|
||||
value = str(host).strip()
|
||||
if not value or any(part in value for part in ("/", "\\", "@", ":")):
|
||||
raise ValueError(f"Invalid Redshift host: {host!r}")
|
||||
hostname = value.lower().rstrip(".")
|
||||
allowed_hosts = _explicitly_allowed_redshift_hosts()
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
if hostname in allowed_hosts:
|
||||
return hostname
|
||||
match = _ALLOWED_REDSHIFT_HOST_RE.fullmatch(hostname)
|
||||
if match:
|
||||
return match.group(0)
|
||||
raise ValueError(
|
||||
"Redshift host must be an AWS Redshift endpoint or be listed in REDSHIFT_ALLOWED_HOSTS"
|
||||
)
|
||||
if hostname not in allowed_hosts:
|
||||
raise ValueError("Redshift IP hosts must be listed in REDSHIFT_ALLOWED_HOSTS")
|
||||
blocked = (
|
||||
address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_multicast
|
||||
or address.is_unspecified
|
||||
or address.is_reserved
|
||||
or (address.is_private and not allow_private)
|
||||
)
|
||||
if blocked:
|
||||
raise ValueError(f"Redshift host address is not allowed: {host!r}")
|
||||
return str(address)
|
||||
|
||||
|
||||
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
|
||||
value = int(value)
|
||||
if value < minimum or value > maximum:
|
||||
raise ValueError(f"{field} must be between {minimum} and {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _check_available_memory(min_gb: float = 2.0) -> None:
|
||||
"""Warn if available memory is below the threshold."""
|
||||
@@ -88,9 +140,12 @@ def fetch_query_metadata(
|
||||
max_queries: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch query execution metadata from sys_query_history."""
|
||||
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
|
||||
lag_hours = _bounded_int(lag_hours, "lag_hours", minimum=0, maximum=24 * 7)
|
||||
max_queries = _bounded_int(max_queries, "max_queries", minimum=1, maximum=100000)
|
||||
return _dictfetch(
|
||||
cursor,
|
||||
f"""
|
||||
"""
|
||||
SELECT
|
||||
query_id,
|
||||
start_time,
|
||||
@@ -100,12 +155,13 @@ def fetch_query_metadata(
|
||||
database_name,
|
||||
elapsed_time
|
||||
FROM sys_query_history
|
||||
WHERE start_time >= DATEADD(hour, -{lookback_hours}, GETDATE())
|
||||
AND start_time < DATEADD(hour, -{lag_hours}, GETDATE())
|
||||
WHERE start_time >= DATEADD(hour, -%s, GETDATE())
|
||||
AND start_time < DATEADD(hour, -%s, GETDATE())
|
||||
AND status = 'success'
|
||||
ORDER BY start_time
|
||||
LIMIT {max_queries}
|
||||
LIMIT %s
|
||||
""", # ← SUBSTITUTE: add AND database_name = 'mydb' to narrow scope
|
||||
(lookback_hours, lag_hours, max_queries),
|
||||
)
|
||||
|
||||
|
||||
@@ -114,11 +170,10 @@ def fetch_query_texts_batch(cursor: Any, query_ids: list[int]) -> dict[int, str]
|
||||
if not query_ids:
|
||||
return {}
|
||||
|
||||
# Build a VALUES list for the IN clause to avoid large parameter arrays
|
||||
id_list = ", ".join(str(qid) for qid in query_ids)
|
||||
query_ids = [_bounded_int(qid, "query_id", minimum=1, maximum=2**63 - 1) for qid in query_ids]
|
||||
rows = _dictfetch(
|
||||
cursor,
|
||||
f"""
|
||||
"""
|
||||
SELECT
|
||||
query_id,
|
||||
LISTAGG(
|
||||
@@ -126,9 +181,10 @@ def fetch_query_texts_batch(cursor: Any, query_ids: list[int]) -> dict[int, str]
|
||||
''
|
||||
) WITHIN GROUP (ORDER BY sequence) AS query_text
|
||||
FROM sys_querytext
|
||||
WHERE query_id IN ({id_list})
|
||||
WHERE query_id = ANY(%s)
|
||||
GROUP BY query_id
|
||||
""",
|
||||
(query_ids,),
|
||||
)
|
||||
return {r["query_id"]: r["query_text"] for r in rows if r.get("query_text")}
|
||||
|
||||
@@ -147,6 +203,13 @@ def collect(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Connect to Redshift, collect query logs, write a JSON manifest, and return entries."""
|
||||
_check_available_memory()
|
||||
allow_private_host = os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"}
|
||||
host = validate_redshift_host(host, allow_private=allow_private_host)
|
||||
port = _bounded_int(port, "port", minimum=1, maximum=65535)
|
||||
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
|
||||
lookback_lag_hours = _bounded_int(lookback_lag_hours, "lookback_lag_hours", minimum=0, maximum=24 * 7)
|
||||
batch_size = _bounded_int(batch_size, "batch_size", minimum=1, maximum=10000)
|
||||
max_queries = _bounded_int(max_queries, "max_queries", minimum=1, maximum=100000)
|
||||
collected_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
conn = psycopg2.connect(
|
||||
@@ -195,8 +258,7 @@ def collect(
|
||||
"query_log_count": len(entries),
|
||||
"entries": entries,
|
||||
}
|
||||
with open(manifest_path, "w") as fh:
|
||||
json.dump(manifest, fh, indent=2)
|
||||
write_json_file(manifest_path, manifest)
|
||||
log.info("Manifest written to %s (%d entries)", manifest_path, len(entries))
|
||||
|
||||
return entries
|
||||
@@ -204,7 +266,6 @@ def collect(
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Collect Redshift query logs to a manifest file")
|
||||
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
|
||||
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
|
||||
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
|
||||
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
|
||||
@@ -216,13 +277,21 @@ def main() -> None:
|
||||
parser.add_argument("--manifest", default="manifest_query_logs.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
required = ["host", "db", "user", "password"]
|
||||
required = ["db", "user", "password"]
|
||||
missing = [k for k in required if getattr(args, k) is None]
|
||||
if missing:
|
||||
parser.error(f"Missing required arguments/env vars: {missing}")
|
||||
|
||||
redshift_host = os.getenv("REDSHIFT_HOST")
|
||||
if not redshift_host:
|
||||
parser.error("Missing required env var: REDSHIFT_HOST")
|
||||
redshift_host = validate_redshift_host(
|
||||
redshift_host,
|
||||
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
|
||||
)
|
||||
|
||||
collect(
|
||||
host=args.host,
|
||||
host=redshift_host,
|
||||
db=args.db,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
|
||||
+4
-6
@@ -30,6 +30,7 @@ from pycarlo.features.ingestion.models import (
|
||||
LineageAssetRef,
|
||||
LineageEvent,
|
||||
)
|
||||
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -68,8 +69,7 @@ def push(
|
||||
|
||||
Returns a summary dict with invocation IDs and counts.
|
||||
"""
|
||||
with open(manifest_path) as fh:
|
||||
manifest = json.load(fh)
|
||||
manifest = read_json_file(manifest_path)
|
||||
|
||||
event_dicts: list[dict[str, Any]] = manifest["events"]
|
||||
events = [_event_from_dict(d) for d in event_dicts]
|
||||
@@ -87,8 +87,7 @@ def push(
|
||||
"batch_size": batch_size,
|
||||
}
|
||||
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
|
||||
with open(push_manifest_path, "w") as fh:
|
||||
json.dump(summary, fh, indent=2)
|
||||
write_json_file(push_manifest_path, summary)
|
||||
return summary
|
||||
|
||||
# Split into batches
|
||||
@@ -144,8 +143,7 @@ def push(
|
||||
}
|
||||
|
||||
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
|
||||
with open(push_manifest_path, "w") as fh:
|
||||
json.dump(summary, fh, indent=2)
|
||||
write_json_file(push_manifest_path, summary)
|
||||
log.info("Push result written to %s", push_manifest_path)
|
||||
|
||||
return summary
|
||||
|
||||
+3
-4
@@ -33,6 +33,7 @@ from pycarlo.features.ingestion.models import (
|
||||
AssetVolume,
|
||||
RelationalAsset,
|
||||
)
|
||||
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -88,8 +89,7 @@ def push(
|
||||
|
||||
Returns a summary dict with invocation IDs and counts.
|
||||
"""
|
||||
with open(manifest_path) as fh:
|
||||
manifest = json.load(fh)
|
||||
manifest = read_json_file(manifest_path)
|
||||
|
||||
asset_dicts: list[dict[str, Any]] = manifest["assets"]
|
||||
assets = [_asset_from_dict(d) for d in asset_dicts]
|
||||
@@ -144,8 +144,7 @@ def push(
|
||||
}
|
||||
|
||||
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
|
||||
with open(push_manifest_path, "w") as fh:
|
||||
json.dump(summary, fh, indent=2)
|
||||
write_json_file(push_manifest_path, summary)
|
||||
log.info("Push result written to %s", push_manifest_path)
|
||||
|
||||
return summary
|
||||
|
||||
+4
-6
@@ -28,6 +28,7 @@ from dateutil.parser import isoparse
|
||||
from pycarlo.core import Client, Session
|
||||
from pycarlo.features.ingestion import IngestionService
|
||||
from pycarlo.features.ingestion.models import QueryLogEntry
|
||||
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -88,8 +89,7 @@ def push(
|
||||
|
||||
Returns a summary dict with invocation IDs and counts.
|
||||
"""
|
||||
with open(manifest_path) as fh:
|
||||
manifest = json.load(fh)
|
||||
manifest = read_json_file(manifest_path)
|
||||
|
||||
entry_dicts: list[dict[str, Any]] = manifest["entries"]
|
||||
entries = _build_query_log_entries(entry_dicts)
|
||||
@@ -107,8 +107,7 @@ def push(
|
||||
"batch_size": batch_size,
|
||||
}
|
||||
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
|
||||
with open(push_manifest_path, "w") as fh:
|
||||
json.dump(summary, fh, indent=2)
|
||||
write_json_file(push_manifest_path, summary)
|
||||
return summary
|
||||
|
||||
# Split into batches
|
||||
@@ -162,8 +161,7 @@ def push(
|
||||
}
|
||||
|
||||
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
|
||||
with open(push_manifest_path, "w") as fh:
|
||||
json.dump(summary, fh, indent=2)
|
||||
write_json_file(push_manifest_path, summary)
|
||||
log.info("Push result written to %s", push_manifest_path)
|
||||
|
||||
return summary
|
||||
|
||||
Reference in New Issue
Block a user