📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-06-23 16:03:00 +00:00
parent c4c6a41c21
commit 59e15f8999
557 changed files with 10501 additions and 3168 deletions
@@ -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)
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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
@@ -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
@@ -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