# tools for helping with the scheduled tasks. from typing import List import boto3 from datetime import datetime, timedelta import time from dataclasses import dataclass from terminaltables import SingleTable, AsciiTable from ecs_deploy.ecs import EcsClient from . import task_def as task_definition_json from . import cronitor from tracker import terminal_colors, config from tracker.json_utils import dumpconsole from tracker.utils import null_safe_path, pluck_all, without_keys from tracker.aws_utils import get_recent_task_def_arn_for_script from tracker.terminal_colors import colors, colorise ecs_client = boto3.client("ecs") cw_client = boto3.client("logs") events_client = boto3.client("events") ec2_client = boto3.client("ec2") import json class ColoredTable(SingleTable): def __init__(self, *args, **kwargs): for key in dir(self): if key.startswith("CHAR"): setattr( self, key, terminal_colors.colorise( getattr(self, key), terminal_colors.colors.fg.darkgrey ), ) super().__init__(*args, **kwargs) def is_env_key(key): return key.startswith("WL_") or key.startswith("AWS_") DEFAULT_CLUSTER_NAME = "tracker" DEFAULT_CLUSTER_ARN = "arn:aws:ecs:eu-west-1:322697034778:cluster/tracker" from tracker.logger import get_logger logger = get_logger(__name__) def get_tracker_env_val(task_def, var_name): for env in task_def["containerDefinitions"][0]["environment"]: if env["name"] == var_name: return env["value"] def get_tracker_env_val_dict(task_def): return dict( (env["name"], env["value"]) for env in task_def["containerDefinitions"][0]["environment"] ) def compare_task_def_env_to_template(task_def): td_env = get_tracker_env_val_dict(task_def) tmp_env = task_definition_json.tracker_env diffs = [] for key in set(list(td_env.keys()) + list(tmp_env.keys())): if td_env.get(key) != tmp_env.get(key): # diffs.append([key, repr(td_env.get(key)), repr(tmp_env.get(key))]) diffs.append( [ key, inline_diff( td_env.get(key, ""), tmp_env.get(key, ""), max_chars_for_equal=20, ), ] ) return diffs # def inline_diff(a, b): # import difflib # matcher = difflib.SequenceMatcher(None, a, b) # def process_tag(tag, i1, i2, j1, j2): # if tag == 'replace': # return '{' + terminal_colors.colorise(matcher.a[i1:i2], terminal_colors.colors.fg.green) + ' -> ' + matcher.b[j1:j2] + '}' # if tag == 'delete': # return '{- ' + matcher.a[i1:i2] + '}' # if tag == 'equal': # return matcher.a[i1:i2] # if tag == 'insert': # return '{+ ' + matcher.b[j1:j2] + '}' # assert false, "Unknown tag %r"%tag # return ''.join(process_tag(*t) for t in matcher.get_opcodes()) class Script: def __init__(self, name, schedule, cronitor_id, task_def_overrides): self.name = name self.schedule = schedule self.cronitor_id = cronitor_id self.task_def_overrides = task_def_overrides def get_task_def(self): return {} def arn_to_name(arn): return arn.split("/")[-1] @dataclass class KeyValuePair: name: str value: str @dataclass class ContainerOverride: name: str command: List[str] = None cpu: int = None environment: List[KeyValuePair] = None def to_dict(self): d = {"name": self.name} if self.command is not None: d["command"] = self.command if self.cpu is not None: d["cpu"] = self.cpu if self.environment is not None: d["environment"] = self.environment return d def describe_running_tasks(cluster_name): task_arns = ecs_client.list_tasks(cluster=cluster_name)["taskArns"] tasks = ecs_client.describe_tasks(cluster=cluster_name, tasks=task_arns)["tasks"] return tasks def get_ip_address_for_container_arn(containerArn): ci = ecs_client.describe_container_instances( cluster="tracker", containerInstances=[containerArn] ) ec2id = ci["containerInstances"][0]["ec2InstanceId"] r = ec2_client.describe_instances(InstanceIds=[ec2id]) for res in r["Reservations"]: for inst in res["Instances"]: if inst["InstanceId"] == ec2id: return { "PrivateDnsName": inst["PrivateDnsName"], "PrivateIpAddress": inst["PrivateIpAddress"], "PublicDnsName": inst["PublicDnsName"], "PublicIpAddress": inst["PublicIpAddress"], } raise RuntimeError(f"Could not get instance {ec2id} ip") def get_running_task_named(task_name): tasks = describe_running_tasks("tracker") return [t for t in tasks if task_name in t["group"]] def get_ip_address_for_running_task(task_name): return [ get_ip_address_for_container_arn(t["containerInstanceArn"]) for t in get_running_task_named(task_name) ] def print_vnc_url(task_name="update_instagram"): try: task = get_running_task_named(task_name)[0] except IndexError: print(f"No task named {task_name} running") return ips = get_ip_address_for_container_arn(task["containerInstanceArn"]) public_ip = ips["PublicIpAddress"] selenium_container = [c for c in task["containers"] if c["name"] == "selenium"][0] port = selenium_container["networkBindings"][0]["hostPort"] print(f"vnc://{public_ip}:{port}") class Scheduler: def __init__(self, env_overrides={}, cluster_name=None, cluster_arn=None): self.env_overrides = env_overrides self.cluster_name = cluster_name or DEFAULT_CLUSTER_NAME self.cluster_arn = cluster_arn or DEFAULT_CLUSTER_ARN def update_script(name, schedule): # upsert task def, upsert cw event, upsert cronitor # find cronitor # find task # create new task # de-register old task # find cloudwatch event # update or remove pass def register_cron( self, script_name, minute="0", hour="*", cron_expr=None, include_cronitor=False, with_selenium=False, with_actuary=False, is_tikpuppet=False, tag="latest", memoryReservation=512, trackerPortMappings=[], task_def_dict=None, use_existing_script=False, ): cron_expr = ( cronitor._to_cloudwatch_event_sched(cron_expr) if cron_expr else "cron({} {} * * ? *)".format(minute, hour) ) if use_existing_script: existing = self.get_most_recent_task_def_arn_for_script(script_name) if not existing: raise RuntimeError(f"No TaskDef found for script {script_name}") print(f"Using existing task def {existing}") if include_cronitor: cronmon = cronitor.upsert_cronitor( script_name, min_seconds_allowed=60, cron_sched=cron_expr ) task_environ = {"WL_CRONITOR_ID": cronmon["code"]} else: task_environ = {} if is_tikpuppet: task_def_dict = task_definition_json.tracker_task( script_name=script_name, tag=tag, with_tikpuppet=True, with_tracker=False, environ_dict=task_environ, ) if not use_existing_script: created = self.register_script( script_name, with_selenium=with_selenium, environ_dict=task_environ, with_actuary=with_actuary, tag=tag, memoryReservation=memoryReservation, trackerPortMappings=trackerPortMappings, task_def_dict=task_def_dict, ) else: created = {} self.upsert_event_and_target(script_name, cron_expr) print("Rule:") print( dumpconsole( without_keys( events_client.describe_rule(Name=script_name), "ResponseMetadata" ) ) ) print("Target:") print( dumpconsole(events_client.list_targets_by_rule(Rule=script_name)["Targets"]) ) print("Task Def:") print(dumpconsole(created)) if include_cronitor: print("Cronitor:") print( ( dumpconsole( cronitor.find_cronitor_by_name(task_environ["WL_CRONITOR_ID"]) ) ) ) def upsert_event_and_target(self, script_name, cron_expression, use_family=True): task_def_arn = ":".join( self.get_most_recent_task_def_arn_for_script(script_name).split(":")[0:-1] ) # is idempotent on rule name events_client.put_rule( Name=script_name, ScheduleExpression=cron_expression, # EventPattern='string',. State="ENABLED", Description="Cron event for " + script_name, RoleArn=task_definition_json.ecs_task_role_arn, ) events_client.put_targets( Rule=script_name, Targets=[ dict( Id=script_name, Arn=self.cluster_arn, RoleArn=task_definition_json.ecs_task_role_arn, EcsParameters=dict(TaskCount=1, TaskDefinitionArn=task_def_arn), ) ], ) def register_script( self, script_name, deregister_previous=True, environ_dict={}, with_selenium=False, with_actuary=False, use_previous_cronitor=True, tag="latest", memoryReservation=512, trackerPortMappings=[], task_def_dict=None, ): if use_previous_cronitor and not environ_dict.get("WL_CRONITOR_ID"): task_def_arn = self.get_most_recent_task_def_arn_for_script(script_name) if task_def_arn: task_def = ecs_client.describe_task_definition( taskDefinition=task_def_arn )["taskDefinition"] cronitor_id = get_tracker_env_val(task_def, "WL_CRONITOR_ID") if cronitor_id: environ_dict["WL_CRONITOR_ID"] = cronitor_id task_def_dict = task_def_dict or task_definition_json.tracker_task( script_name, with_selenium=with_selenium, environ_dict=environ_dict, tag=tag, with_actuary=with_actuary, memoryReservation=memoryReservation, trackerPortMappings=trackerPortMappings, ) created = self._register_task_definition(task_def_dict, deregister_previous) return created def _register_task_definition(self, task_def_dict, deregister_previous): created = ecs_client.register_task_definition(**task_def_dict)["taskDefinition"] print( "Successfully registered new script {0[family]}:{0[revision]} {1}" "".format(created, arn_to_name(created["taskDefinitionArn"])) ) if deregister_previous: task_def_arns = ecs_client.list_task_definitions( familyPrefix=created["family"] )["taskDefinitionArns"] for task_arn in task_def_arns: if task_arn != created["taskDefinitionArn"]: logger.info("Deregistering: {}".format(task_arn)) ecs_client.deregister_task_definition(taskDefinition=task_arn) return created def get_most_recent_task_def_arn_for_script(self, script_name): return get_recent_task_def_arn_for_script(script_name) def run_script( self, script_name, force_register=False, tail_logs=False, register_with_selenium=False, container_overrides: List[ContainerOverride] = None, ec2id=None, ): existing = get_recent_task_def_arn_for_script(script_name) if force_register or not existing: task_def_arn = self.register_script( script_name, with_selenium=register_with_selenium )["taskDefinitionArn"] time.sleep(1) else: task_def_arn = existing run_task_kwargs = dict(cluster=self.cluster_name, taskDefinition=task_def_arn) if container_overrides is not None: run_task_kwargs["overrides"] = { "containerOverrides": [o.to_dict() for o in container_overrides] } if ec2id is not None: run_task_kwargs["placementConstraints"] = [ dict(type="memberOf", expression=f"ec2InstanceId == '{ec2id}'") ] response = ecs_client.run_task(**run_task_kwargs) task = null_safe_path(response, "tasks", 0) if not task: logger.error("Something went wrong with task, {}".format(response)) raise Exception container_arn = arn_to_name(task["containers"][0]["containerArn"]) task_id = arn_to_name(task["taskArn"]) logger.info( "Running tracker task: {} / {}".format( arn_to_name(task_def_arn), task_id, container_arn ) ) if tail_logs: self.tail_logs_until_complete(task, task_id) return task def tail_logs_until_complete(self, task, task_id, chunk_size=5, sleep_time=5): next_token = None log_stream_name = None while log_stream_name is None: log_stream_name = self.get_log_stream_name(task_id) if not log_stream_name: logger.error("Could not get log stream name") time.sleep(3) while True: try: logs = self.get_logs_from_stream( log_stream_name, start_time=0, next_token=next_token, limit=chunk_size, ) except Exception as e: print( terminal_colors.colorise( "Logs not ready {}".format(e), terminal_colors.colors.fg.lightcyan, ) ) else: if logs and logs.get("events"): for event in logs["events"]: message = event.get("message") if isinstance(message, str): try: message = json.loads(message) except Exception as e: pass print( terminal_colors.colorise( str(event.get("timestamp", 0))[-10:], terminal_colors.colors.fg.darkgrey, ), dumpconsole(message), ) next_token = logs["nextForwardToken"] else: print( terminal_colors.colorise( "...no new logs....", terminal_colors.colors.fg.darkgrey ) ) curTask = ecs_client.describe_tasks( cluster=self.cluster_name, tasks=[task["taskArn"]] )["tasks"][0] if curTask["lastStatus"] == "STOPPED": print("Task has completed.") break time.sleep(sleep_time) def _describe_task_defs(self, family_prefix=None): return [ ecs_client.describe_task_definition(taskDefinition=td) for td in ecs_client.list_task_definitions(familyPrefix=family_prefix)[ "taskDefinitionArns" ] ]["taskDefinitions"] def get_logs( self, task_id_prefix, start_time=0, time_delta=None, limit=5, start_from_head=True, ): log_stream_name = self.get_log_stream_name(task_id_prefix) return self.get_logs_from_stream( log_stream_name, start_time=start_time, time_delta=time_delta, limit=limit, start_from_head=start_from_head, ) def get_log_stream_name(self, task_id_prefix): task_arn = self.find_task_arn_for_prefix(task_id_prefix) if not task_arn: logger.error("No task found for id prefix: {}".format(task_id_prefix)) return task = ecs_client.describe_tasks(cluster=self.cluster_name, tasks=[task_arn])[ "tasks" ][0] task_def_arn = task["taskDefinitionArn"] task_def = ecs_client.describe_task_definition(taskDefinition=task_def_arn) log_stream_prefix = null_safe_path( task_def, "taskDefinition", "containerDefinitions", 0, "logConfiguration", "options", "awslogs-stream-prefix", ) return "{}/tracker/{}".format(log_stream_prefix, arn_to_name(task_arn)) def get_logs_from_stream( self, log_stream_name, start_time=0, time_delta=None, limit=5, start_from_head=True, next_token=None, ): if not start_time and time_delta: if isinstance(time_delta, dict): time_delta = timedelta(**time_delta) start_time = round((datetime.today() - time_delta).timestamp() * 1000) kw = dict(nextToken=next_token) if next_token else {} return cw_client.get_log_events( logGroupName="ecs", logStreamName=log_stream_name, startTime=start_time, limit=limit, startFromHead=start_from_head, # endTime=123, **kw, ) def find_task_arn_for_prefix(self, task_id_prefix): running_tasks = ecs_client.list_tasks( cluster=self.cluster_name, desiredStatus="RUNNING" )["taskArns"] for arn in running_tasks: if arn_to_name(arn).startswith(task_id_prefix): return arn stopped = ecs_client.list_tasks( cluster=self.cluster_name, desiredStatus="STOPPED" )["taskArns"] for arn in stopped: if arn_to_name(arn).startswith(task_id_prefix): return arn def print_info(self, cluster_arn=None): client = EcsClient() if not cluster_arn: cluster_arns = ecs_client.list_clusters()["clusterArns"] cluster_arn = [ arn for arn in cluster_arns if arn_to_name(arn) == self.cluster_name ][0] task_definitions = ecs_client.list_task_definitions()["taskDefinitionArns"] tasks = describe_running_tasks(self.cluster_name) print(dumpconsole(tasks)) def status(t): if t["lastStatus"] != t["desiredStatus"]: return ( t["lastStatus"] + "\n(" + terminal_colors.colorise( t["desiredStatus"], terminal_colors.colors.fg.red ) + ")" ) return terminal_colors.colorise( t["lastStatus"], terminal_colors.colors.fg.lightgreen ) def datify(t): if not t.get("startedAt"): return "not started" delta = datetime.utcnow() - t["startedAt"].replace(tzinfo=None) hours, remainder = divmod(delta.total_seconds(), 3600) minutes, seconds = divmod(remainder, 60) # Formatted only for hours and minutes as requested print "%s:%s" % (hours, minutes) formatted_time = ( "{startedAt:%b %-m %T}\n{hours} hours {minutes} ago" "".format( startedAt=t["startedAt"], hours=int(hours), minutes=int(minutes) ) ) if hours > 20: formatted_time = terminal_colors.colorise( formatted_time, terminal_colors.colors.bold, terminal_colors.colors.fg.red, ) return formatted_time taskTable = [ [ terminal_colors.colorise(h, terminal_colors.colors.bold) for h in [ "Group / ID", "Status\n(Desired)", "Tracker Command", "Started\nAge", ] ] ] + [ [ "\n".join( [ t["group"], terminal_colors.colorise( arn_to_name(t["taskArn"])[:8], terminal_colors.colors.fg.lightcyan, ), "", ] ), status(t), " \\\n".join( next( ( t for t in t["overrides"]["containerOverrides"] if t["name"] == "tracker" ), {}, ).get("command", []) ), datify(t), ] for t in tasks ] print(taskTable) print( dumpconsole( { "clusterArn": cluster_arn, "taskDefinitions": [arn_to_name(td) for td in task_definitions], } ) ) print( terminal_colors.colorise( "\n--- Running Tasks ---\n", terminal_colors.colors.fg.lightblue ) ) print(ColoredTable(taskTable, "Running Tasks").table) def print_schedule(self, as_table=True): rules = self._get_schedule_rules() if as_table: rules_table = [["Name", "State", "Cron Expr.", "Cronitor", "Image"]] + [ [ "{}\n{}".format( r["Name"], terminal_colors.colorise( r.get("Targets", ""), terminal_colors.colors.fg.darkgrey, ), ), r["State"], r.get("ScheduleExpression"), r.get("Cronitor") or "", (r.get("Image") or "/").split("/")[1], r["Command"][1] if r.get("Command") else "", ] for r in rules ] print(ColoredTable(rules_table, "Scheduled Tasks").table) else: print(dumpconsole(rules)) def _get_schedule_rules(self): rules = pluck_all( events_client.list_rules()["Rules"], "Name", "ScheduleExpression", "State" ) for rule in rules: task_def_arn = self.get_most_recent_task_def_arn_for_script(rule["Name"]) if not task_def_arn: rule["Problems"] = terminal_colors.colorise( "Could not find a task definition for this script.", terminal_colors.colors.fg.red, ) else: task_def = ecs_client.describe_task_definition( taskDefinition=task_def_arn )["taskDefinition"] rule["Cronitor"] = get_tracker_env_val(task_def, "WL_CRONITOR_ID") rule["Image"] = task_def["containerDefinitions"][0]["image"] rule["Command"] = ( task_def["containerDefinitions"][0].get("command") or "" ) rule["Targets"] = ", ".join( [ "/" + arn_to_name( str(null_safe_path(t, "EcsParameters", "TaskDefinitionArn")) ) for t in ( events_client.list_targets_by_rule(Rule=rule["Name"]) or {} ).get("Targets") ] ) rules.sort( key=lambda x: (x.get("ScheduleExpression") or "")[::-1] ) # reverse the cron expr, should make it asc. return rules def compare_all_environments(self): all_diffs = [] for rule in self._get_schedule_rules(): script = rule["Targets"].replace("/tracker-", "") task_def = ecs_client.describe_task_definition( taskDefinition=get_recent_task_def_arn_for_script(script) )["taskDefinition"] diffs = compare_task_def_env_to_template(task_def) if diffs: all_diffs.append([task_def["family"]]) all_diffs.extend(diffs) if all_diffs: print(ColoredTable([["Key", "Difference"]] + all_diffs).table) def reregister_all(self): mapping = {m["name"]: m["code"] for m in cronitor.list_cronitors()["monitors"]} for rule in self._get_schedule_rules(): script = rule["Targets"].replace("/tracker-", "") cronitor_id = mapping.get(script) print("reregistering: {} / {}".format(script, cronitor_id)) kwargs = dict( script_name=script, with_selenium=(script in ("update_instagram", "walk_tiktok")), with_actuary=(script == "update_actuary_streams"), use_previous_cronitor=False, environ_dict=dict(WL_CRONITOR_ID=cronitor_id), tag="latest", ) print("kwargs: {}".format(json.dumps(kwargs, indent=2))) self.register_script(**kwargs) print("done. Sleep for rate limit.\n") time.sleep(2) def modify_tracker_task_definition(self, script, tag=None, memory_reservation=None): """ This method modifies the most recent ask definition at AWS, as opposed to using the local task_def.py """ task_def_json = ecs_client.describe_task_definition( taskDefinition=get_recent_task_def_arn_for_script(script) )["taskDefinition"] if not task_def_json: raise RuntimeError(f"No tasks for script {script}") arn = task_def_json.pop("taskDefinitionArn") for removekey in ( "revision", "status", "requiresAttributes", "compatibilities", ): task_def_json.pop(removekey) changed = False for c in task_def_json["containerDefinitions"]: if c["image"].startswith( "322697034778.dkr.ecr.eu-west-1.amazonaws.com/tracker:" ): if tag is not None: old_tag = c["image"].split(":")[-1] if old_tag != tag: print(f"Setting tag to '{tag}' (was '{old_tag}')") c[ "image" ] = f"322697034778.dkr.ecr.eu-west-1.amazonaws.com/tracker:{tag}" changed = True else: print(f"Tag is already '{tag}'") if memory_reservation is not None: if c["memoryReservation"] != memory_reservation: print( f"Setting memory to {memory_reservation} (was {c['memoryReservation']})" ) c["memoryReservation"] = memory_reservation changed = True else: print(f"Memory already {memory_reservation}") if changed: print(f"Creating new revision from {arn}") self._register_task_definition(task_def_json, deregister_previous=True) else: print(f"No changes needed for {arn}") """ {'taskDefinitionArn': 'arn:aws:ecs:eu-west-1:322697034778:task-definition/tracker-update_soundcloud:8', 'containerDefinitions': [{'name': 'tracker', 'image': '322697034778.dkr.ecr.eu-west-1.amazonaws.com/tracker:candidate', 'cpu': 0, 'memoryReservation': 512, 'links': [], 'portMappings': [], 'essential': True, 'entryPoint': [], 'command': ['/tracker/bin/launch.py', 'update_soundcloud'], 'environment': [{'name': 'AWS_DEFAULT_REGION', 'value': 'eu-west-1'}, {'name': 'AWS_REGION', 'value': 'eu-west-1'}, {'name': 'WL_SECRETS_MANAGER_KEY_NAME', 'value': 'WhitelistKeys'}, {'name': 'WL_CRONITOR_ID', 'value': 'ZqapzV'}], 'mountPoints': [], 'volumesFrom': [], 'privileged': False, 'readonlyRootFilesystem': False, 'dnsServers': [], 'dnsSearchDomains': [], 'dockerSecurityOptions': [], 'logConfiguration': {'logDriver': 'awslogs', 'options': {'awslogs-group': 'ecs', 'awslogs-region': 'eu-west-1', 'awslogs-stream-prefix': 'update_soundcloud'}}}], 'family': 'tracker-update_soundcloud', 'taskRoleArn': 'arn:aws:iam::322697034778:role/ecs-task', 'revision': 8, 'volumes': [{'name': 'shm', 'host': {'sourcePath': '/dev/shm'}}], 'status': 'ACTIVE', 'requiresAttributes': [{'name': 'com.amazonaws.ecs.capability.logging-driver.awslogs'}, {'name': 'com.amazonaws.ecs.capability.ecr-auth'}, {'name': 'com.amazonaws.ecs.capability.docker-remote-api.1.19'}, {'name': 'com.amazonaws.ecs.capability.docker-remote-api.1.17'}, {'name': 'com.amazonaws.ecs.capability.docker-remote-api.1.21'}, {'name': 'com.amazonaws.ecs.capability.task-iam-role'}], 'placementConstraints': [], 'compatibilities': ['EC2'], """ def init_scheduler(): return Scheduler() """ 'update_actuary_streams', 'update_instagram', """ all_tasks = [ "refresh_materialized_views", "spider_urls", "stats", "update_instagram_metrics", "update_soundcloud", "update_soundcloud_import_yesterdays_tracks", "update_soundcloud_new_artist_tracks", "update_soundcloud_track_stats", "update_spy", "update_spy_track_popularity", "update_twitter", "update_yt_channels", ] def inline_diff(a, b, max_chars_for_equal=None): import difflib matcher = difflib.SequenceMatcher(None, a, b) def red(s): return colorise(s, colors.fg.red) def green(s): return colorise(s, colors.fg.green) def grey(s): return colorise(s, colors.fg.darkgrey) def white(s): return colorise(s, colors.fg.lightgrey) def process_tag(tag, i1, i2, j1, j2): if tag == "replace": return ( white("|") + red(matcher.a[i1:i2]) + white("->") + green(matcher.b[j1:j2]) + white("|") ) if tag == "delete": return white("|-") + red(matcher.a[i1:i2]) + white("|") if tag == "equal": samestr = matcher.a[i1:i2] if max_chars_for_equal is not None and len(samestr) > max_chars_for_equal: samestr = ( samestr[0 : int(max_chars_for_equal / 2)] + "..." + samestr[-int(max_chars_for_equal / 2) :] ) return grey(samestr) if tag == "insert": return white("|+") + green(matcher.b[j1:j2]) + white("|") raise ValueError("Unknown tag %r" % tag) return "".join(process_tag(*t) for t in matcher.get_opcodes())