llm_topic_modelling / cdk /config /lambda /lambda_function.py
seanpedrickcase's picture
Sync: Update gradio to 6.20.0
595d1ed
Raw
History Blame Contribute Delete
6.87 kB
import os
import urllib.parse
import boto3
ecs = boto3.client("ecs")
s3 = boto3.client("s3")
# Move static config to Lambda env vars for easier ops
BUCKET = os.environ.get("BUCKET", "lambeth-llm-topic-modelling")
# Durable config (app_defaults.env) lives on the log/config bucket so it is not
# deleted by the output bucket object-expiration lifecycle rule.
LOG_BUCKET = os.environ.get("LOG_BUCKET") or BUCKET
INPUT_PREFIX = os.environ.get("INPUT_PREFIX", "input/")
ENV_PREFIX = os.environ.get("ENV_PREFIX", f"{INPUT_PREFIX}config/")
GENERAL_ENV_PREFIX = os.environ.get("GENERAL_ENV_PREFIX", "general-config/")
ENV_SUFFIX = os.environ.get("ENV_SUFFIX", ".env")
DEFAULT_PARAMS_KEY = os.environ.get(
"DEFAULT_PARAMS_KEY", f"{GENERAL_ENV_PREFIX}app_defaults{ENV_SUFFIX}"
)
CLUSTER = os.environ.get("ECS_CLUSTER", "analytics_fargate_cluster")
TASK_DEF = os.environ.get("ECS_TASK_DEF", "llm_topic_modelling_fargate_def:5")
SUBNETS = os.environ.get(
"SUBNETS", "subnet-083650a35f93a0ee5,subnet-0a41ba70470cabab8"
).split(",")
SECURITY_GROUPS = os.environ.get("SECURITY_GROUPS", "sg-051effb4c5e752df1").split(",")
ECS_ASSIGN_PUBLIC_IP = os.environ.get("ECS_ASSIGN_PUBLIC_IP", "DISABLED").upper()
DEFAULT_INPUT_S3_URI = os.environ.get(
"DEFAULT_INPUT_S3_URI",
f"s3://{BUCKET}/{INPUT_PREFIX}dummy_consultation_response.xlsx",
)
DEFAULT_TASK_TYPE = os.environ.get("DEFAULT_TASK_TYPE", "extract")
CONTAINER_NAME = os.environ.get("CONTAINER_NAME", "llm_topic_modelling_container")
def _key_matches(key: str) -> bool:
return key.startswith(ENV_PREFIX) and key.endswith(ENV_SUFFIX)
def _derive_runtime_params_from_key(key: str) -> dict:
"""Optional convention: decide task type from file name."""
basename = key.split("/")[-1].lower()
if "train" in basename:
task_type = "train"
elif "infer" in basename or "staging" in basename:
task_type = "infer"
else:
task_type = DEFAULT_TASK_TYPE
return {
"DIRECT_MODE_TASK": task_type,
"DIRECT_MODE_INPUT_FILE": DEFAULT_INPUT_S3_URI,
}
def _parse_dotenv(dotenv_bytes: bytes, bucket: str, input_prefix: str) -> dict:
"""
Parse a basic .env and prepend S3 paths to specific keys if they have values.
"""
env = {}
text = dotenv_bytes.decode("utf-8", errors="replace")
# 1. Parse the file (Existing Logic)
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export ") :].strip()
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1]
env[key] = value
# 2. Apply S3 Prefix Logic (New Logic)
# We define the base S3 path string using the provided arguments
s3_base = f"s3://{bucket}/{input_prefix}"
# List of keys that need modification
keys_to_modify = ["DIRECT_MODE_INPUT_FILE", "DIRECT_MODE_CANDIDATE_TOPICS"]
for key in keys_to_modify:
# env.get(key) returns the value.
# The 'if' check ensures the key exists AND is not an empty string.
if env.get(key):
env[key] = s3_base + env[key]
return env
def _build_environment_array(*env_dicts):
"""Merge dictionaries left→right and produce ECS environment array format."""
merged = {}
for d in env_dicts:
merged.update(d or {})
# ECS override format: [{"name": "...","value":"..."}]
return [{"name": k, "value": v} for k, v in merged.items()]
def lambda_handler(event, context):
runs = []
# Parse default env file with default config.
# Read from LOG_BUCKET (log/config bucket) so app_defaults.env survives
# output-bucket lifecycle expiration.
print(f"Loading default params from s3://{LOG_BUCKET}/{DEFAULT_PARAMS_KEY}")
default_obj = s3.get_object(Bucket=LOG_BUCKET, Key=DEFAULT_PARAMS_KEY)
default_dotenv_bytes = default_obj["Body"].read()
# Parse .env → dict of env vars (input paths still resolve against output BUCKET)
default_file_env = _parse_dotenv(default_dotenv_bytes, BUCKET, INPUT_PREFIX)
for record in event.get("Records", []):
# Parse env file from user
s3rec = record.get("s3", {})
bucket = s3rec.get("bucket", {}).get("name")
raw_key = s3rec.get("object", {}).get("key")
if not bucket or not raw_key:
print(f"Object in bucket {bucket} and key {raw_key} not found, exiting.")
continue
key = urllib.parse.unquote_plus(raw_key)
# combined_key = ENV_PREFIX + key
combined_key = key
print("combined_key:", combined_key)
# Only process the intended prefix/suffix
if not _key_matches(combined_key):
print(f"Key does not match: {combined_key}, exiting")
continue
# Fetch the .env file content from S3
obj = s3.get_object(Bucket=bucket, Key=combined_key)
dotenv_bytes = obj["Body"].read()
# Parse .env → dict of env vars
file_env = _parse_dotenv(dotenv_bytes, BUCKET, INPUT_PREFIX)
# Optionally derive run-time params from naming
# run_params = _derive_runtime_params_from_key(key)
# Merge: file env (dominant), overwrites default values.
environment = _build_environment_array(file_env, default_file_env)
print("Combined environment variables:", environment)
# Execute ECS Fargate task with fully dynamic environment overrides
response = ecs.run_task(
cluster=CLUSTER,
launchType="FARGATE",
taskDefinition=TASK_DEF,
networkConfiguration={
"awsvpcConfiguration": {
"subnets": SUBNETS,
"securityGroups": SECURITY_GROUPS,
"assignPublicIp": (
ECS_ASSIGN_PUBLIC_IP
if ECS_ASSIGN_PUBLIC_IP in ("ENABLED", "DISABLED")
else "DISABLED"
),
}
},
overrides={
"containerOverrides": [
{
"name": CONTAINER_NAME,
"environment": environment,
}
]
},
)
runs.append(
{
"bucket": bucket,
"key": key,
"taskArns": [t["taskArn"] for t in response.get("tasks", [])],
"failures": response.get("failures", []),
"envCount": len(environment),
}
)
return {"runs": runs}