Files
shuffle-cracked/functions/newworker/main.py
T
2020-05-11 19:17:35 +02:00

584 lines
24 KiB
Python

import asyncio
import logging
import json
import sys
import os
import signal
import requests
import os
import time
from collections import deque
from inspect import getcoroutinelocals
from google.cloud import pubsub
import aiohttp
import aioredis
from message_types import message_dumps, message_loads, NodeStatusMessage, WorkflowStatusMessage, StatusEnum
from helpers import get_walkoff_auth_header
from redis_helpers import connect_to_redis_pool, xdel, deref_stream_message
from workflow_types import (Node, Action, Condition, Transform, Parameter, Trigger,
ParameterVariant, Workflow, workflow_dumps, workflow_loads, ConditionException)
logging.basicConfig(level=logging.INFO, format="{asctime} - {name} - {levelname}:{message}", style='{')
logger = logging.getLogger("WORKER")
# logging.getLogger("asyncio").setLevel(logging.DEBUG)
# logger.setLevel(logging.DEBUG)
CONTAINER_ID = ""#os.getenv("HOSTNAME")
APIKEY = ""#os.getenv("FUNCTION_APIKEY")
# FIXME
#apiurl = "http://localhost:5001"
apiurl = "https://shuffler.io"
class Worker:
def __init__(self, workflow: Workflow = None, start_action: str = None, redis: aioredis.Redis = None,
session: aiohttp.ClientSession = None):
self.workflow = workflow
self.start_action = start_action if start_action is not None else self.workflow.start
self.results_stream = f"{workflow.execution_id}:results"
self.parallel_accumulator = {}
self.accumulator = {}
self.parallel_in_process = {}
self.in_process = {}
self.redis = redis
self.streams = set()
self.scheduling_tasks = set()
self.results_getter_task = None
self.parallel_tasks = set()
self.workflow_tasks = set()
self.execution_task = None
self.session = session
self.token = None
self.parent_map = {}
self.cancelled = []
self.results = {}
self.execution_id = ""
self.workflow_id = ""
self.id = ""
self.locations = []
self.project_id = ""
self.authorization = ""
self.start_id = start_action.id if start_action is not None else ""
async def cancel_subgraph(self, node):
"""
Cancels the task related to the current node as well as the tasks related to every child of that node.
Also removes them from the worker's internal in_process queue.
"""
# dependents = self.workflow.get_dependents(node)
cancelled_tasks = set()
self.cancelled.append(node.id)
to_cancel = await self.cancel_helper(node, [node.id])
for task in self.scheduling_tasks:
for _, arg in getcoroutinelocals(task._coro).items():
if isinstance(arg, Node):
if arg.id in to_cancel:
self.in_process.pop(arg.id)
self.accumulator[arg.id] = None
self.cancelled.append(arg.id)
task.cancel()
cancelled_tasks.add(task)
await asyncio.gather(*cancelled_tasks, return_exceptions=True)
# This is a very specific one, that might be fucked up by an action named the same thing.
# Its this way because of a weird translation from Triggers to Actions that didn't
# really work very well
def handle_user_input_node(self, node):
print("Handle user input. Params: %d!" % len(node.parameters))
data = ""
options = ""
actiontypes = []
for parameter in node.parameters:
print("Param: %s" % parameter)
if parameter.name == "alertinfo":
data = parameter.value
elif parameter.name == "options":
options = parameter.value
elif parameter.name == "type":
actiontypes = parameter.value.split(",")
print("Data: ", data)
print("Options: ", options)
print("Types: ", actiontypes)
executed = False
headers = {
"Authorization": "Bearer %s" % APIKEY,
"Content-Type": "application/json",
}
for actiontype in actiontypes:
if actiontype == "email":
print("SEND EMAIL!")
#apiurl = "http://localhost:5001"
mailurl = "%s/functions/sendmail" % apiurl
data = {
"targets": ["frikky@shuffler.io"],
"body": data,
"subject": "Shuffle alert requires input!",
"type": "User input",
"sender_company": "Shuffle",
"reference_execution": self.execution_id,
"workflow_id": self.workflow_id,
"execution_type": options,
"start": node.id,
}
# Add it to actionResult here because of start time!
params = self.dereference_params_pubsub(node)
ret = requests.post(mailurl, headers=headers, json=data)
logger.debug("Ret: %s" % ret.text)
logger.debug("Status: %d" % ret.status_code)
if ret.status_code == 200 or ret.status_code == 201:
executed = True
elif actiontype.lower() == "sms":
print("Handle SMS!")
executed = True
if executed:
actionurl = "%s/api/v1/streams" % apiurl
action = {
"name": node.name,
"app_name": node.app_name,
"app_version": node.app_version,
"label": node.label,
"environment": node.environment,
"id": node.id,
}
action_result = {
"action": action,
"authorization": self.authorization,
"execution_id": self.execution_id,
"result": "",
"started_at": int(time.time()),
"status": "WAITING",
}
actionret = requests.post(actionurl, headers=headers, json=action_result)
logger.debug("Actionret: %d", actionret.status_code)
logger.debug("Actionret: %s", actionret.text)
print("SHOULD KILL THE EXECUTION (stop this branch)!")
def execute_workflow_pubsub(self):
"""
Do a simple BFS to visit and schedule each node in the workflow. We assume every node will run and thus preemptively schedule them all. We will clean up any nodes that will not run due to conditions or triggers
"""
visited = {self.start_action}
queue = deque([self.start_action])
self.scheduling_tasks = set()
while queue:
node = queue.pop()
logger.debug("NODE INFO: %s, %s, %s, %s" % (node.name, node.app_name, node.app_version, node.label))
parents = {n.id: n for n in self.workflow.predecessors(node)} if node is not self.start_action and node.id is not self.workflow.start else {}
children = {n.id: n for n in self.workflow.successors(node)}
for parent_id in parents:
if node.id not in self.parent_map.keys():
self.parent_map[node.id] = 1
else:
self.parent_map[node.id] = self.parent_map[node.id] + 1
self.in_process[node.id] = node
if isinstance(node, Action):
node.execution_id = self.workflow.execution_id # the app needs this as a key for the redis queue
# Custom for trigger actions
if node.name == "User Input" and node.app_name == "User Input":
logger.info("Handling user input!")
# Skipping new nodes
if self.start_id != node.id:
self.handle_user_input_node(node)
break
else:
logger.info("Skipping user input as its start node!")
print("NAME: %s, ENV: %s, LABEL" % (node.name, node.environment))
if node.environment == "cloud":
self.scheduling_tasks.add(self.schedule_node_pubsub(node, parents, children))
print("EXIT NAME: %s, ENV: %s, LABEL" % (node.name, node.environment))
for child in sorted(children.values(), reverse=True):
if child not in visited:
queue.appendleft(child)
visited.add(child)
# Checks whether all actions are finished
finished = self.get_action_results_pubsub()
if finished:
print("Got finished and will return!")
break
def dereference_params_pubsub(self, action: Action):
param_ret = []
global_vars = {}
print(action.parameters)
for param in action.parameters:
data = {"value": param.value, "name": param.name, "action_field": param.action_field, "variant": "STATIC_VALUE"}
if param.variant == ParameterVariant.STATIC_VALUE:
data["variant"] = "STATIC_VALUE"
elif param.variant == ParameterVariant.ACTION_RESULT:
data["variant"] = "ACTION_RESULT"
elif param.variant == ParameterVariant.WORKFLOW_VARIABLE:
data["variant"] = "WORKFLOW_VARIABLE"
elif param.variant == ParameterVariant.GLOBAL:
data["variant"] = "GLOBAL"
else:
logger.error(f"Unable to dereference parameter:{param} for action:{action}")
break
param_ret.append(data)
return param_ret
def abort(self):
logger.info("ABORTING %s BECAUSE OF ERROR WITH FUNCTION EXECUTION" % self.execution_id)
url = f"{apiurl}/api/v1/workflows/{self.workflow_id}/executions/{self.execution_id}/abort"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {APIKEY}"
}
ret = requests.get(url, headers=headers, timeout=5)
logger.info("Aborted with status: %d and text:\n%s" % (ret.status_code, ret.text))
sys.exit(0)
def schedule_node_pubsub(self, node, parents, children):
""" Waits until all dependencies of an action are met and then schedules the action """
logger.info(f"Scheduling node {node.id} ({node.name})...")
logger.info(self.accumulator)
while not all(parent.id in self.accumulator for parent in parents.values()):
time.sleep(1)
#await asyncio.sleep(0)
logger.info(f"Node {node.id} ({node.name}) ready to execute.")
# node has more than one parent, check if both parent nodes have been cancelled
if len(parents) > 1:
count = 0
for parent in parents:
if parent in self.cancelled:
count = count + 1
if count == self.parent_map[node.id]:
self.cancel_subgraph(node)
print(type(node))
if isinstance(node, Action):
print("NODE: %s" % node)
params = self.dereference_params_pubsub(node)
print("PARAMS: %s" % params)
# Added authorization to send to function
message = {
"parameters": params,
"execution_id": self.execution_id,
"authorization": self.authorization,
"node_project": self.project_id,
"name": node.name,
"app_name": node.app_name,
"app_version": node.app_version,
"id": node.id,
"label": node.name,
}
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {APIKEY}"
}
# Uses version for production apps, but ID for private apps
functionname = f"{node.app_name}-{node.app_version}"
if not node.sharing:
functionname = f"{node.app_name}-{node.private_id}"
print(f"Functionname (pre): {functionname}")
functionname = functionname.replace("_", "-")
functionname = functionname.replace(":", "-")
functionname = functionname.replace(".", "-")
functionname = functionname.replace(" ", "-")
print(f"Functionname (post): {functionname}")
logger.info(self.locations)
logger.info(self.project_id)
for location in self.locations:
url = f"https://{location}-{self.project_id}.cloudfunctions.net/{functionname}"
#print(message)
try:
ret = requests.post(url, headers=headers, json=message)
# If any error at all, just quit the entire thing (abort)
if ret.status_code == 500 or ret.status_code == 401:
logger.info("Status: %d. There is an error with ret when starting %s. Should cancel execution and exit. RAW: %s" % (ret.status_code, url, ret.text))
self.abort()
except requests.exceptions.ReadTimeout as e:
logger.debug(e)
logger.info("There is an error with ret (readtimeout). Should cancel execution and exit.")
self.abort()
except requests.exceptions.ConnectionError as e:
logger.debug(e)
logger.info("There is an error with ret (connectionerror). Should cancel execution and exit.")
self.abort()
#logger.debug(ret.text)
logger.debug(ret.status_code)
# FIXME - only in one location, e.g. eu-west?
break
group = f"{node.app_name}:{node.app_version}"
stream = f"{node.execution_id}:{group}"
logger.info(f"Scheduled {node}")
def get_action_results_pubsub(self):
""" Continuously monitors the results queue until all scheduled actions have been completed """
results_stream = f"{self.workflow.execution_id}:results"
# 1. Get the results for the workflowexecution. POST with authorization and ID should do the trick
# 2. Check whether the whole thing is still executing
# 3. Check whether the status of self.in_process is updated, if so, remove it from in progress
# 4. Schedule the next nodes somehow
print(len(self.in_process), len(self.parallel_in_process))
print(self.in_process, len(self.parallel_in_process))
url = f"{apiurl}/api/v1/streams/results"
#if self.project_id != "":
# url = f"https://{self.project_id}.appspot.com/api/v1/streams/results"
#
headers = {"Content-Type": "application/json"}
# Uses workflow specific authorization generated for priviliged access
message = {"authorization": self.authorization, "execution_id": self.execution_id}
sleeptime = 2
logger.info(url)
logger.info(f"Waiting {sleeptime} seconds for new updates in the nodestream...")
while len(self.in_process) > 0 or len(self.parallel_in_process) > 0:
# Ask for all nodes, and check every single one that's in progress
print("Items in process: %s" % self.in_process)
ret = requests.post(url, headers=headers, json=message)
if ret.status_code != 200:
logger.exception("Something went wrong getting workflow status for %s with auth %s. Raw: %s. Status: %d" % (self.execution_id, self.authorization, ret.text, ret.status_code))
time.sleep(sleeptime)
continue
# PAUSED, AWAITING_DATA, PENDING, COMPLETED, ABORTED, EXECUTING, SUCCESS, FAILURE
# FIXME - have this?
if ret.json()["status"] == "FINISHED" or ret.json()["status"] == "ABORTED" or ret.json()["status"] == "FAILURE":
print("Entire thing is done with status %s - exiting" % ret.json()["status"])
return True
self.results = ret.json()
# FIXME - REMOVE COMMENTS
# FIXME - This might be wrong for multiple reasons
if self.results.get("results") == "" or self.results.get("results") == None:
print("Couldn't find results in results - getting new")
logger.info(self.results)
self.results["results"] = []
#print("IS IT DONE? - RETURNING TRUE")
#return
for node_message in self.results["results"]:
# Ensure that the received NodeStatusMessage is for an action we launched
#print(node_message)
#print(self.in_process)
# FIXME - might be an issue with same kind of node with same ID here
if node_message["action"]["id"] in self.in_process:
if node_message["status"] == "EXECUTING":
logger.info(f"Got EXECUTING result for: {node_message['action']['name']}-{node_message['execution_id']}")
elif node_message["status"] == "WAITING":
# This is just for user-inputted items
logger.info("Should only be here the SECOND time around (after user inputted)!")
logger.info(f"Got WAITING result for: {node_message['action']['name']}-{node_message['execution_id']}. Updating it to SUCCESS now that a user continued.")
self.accumulator[node_message["action"]["id"]] = "SUCCESS"
self.in_process.pop(node_message["action"]["id"], None)
logger.debug("start_id: %s, node.id: %s", self.start_id, node_message["action"]["id"])
if self.start_id == node_message["action"]["id"]:
logger.info("HANDLING USER INPUT AS START NODE - SETTING TO SUCCESS!")
# Check if its the same, then update it to success
headers = {
"Authorization": "Bearer %s" % APIKEY,
"Content-Type": "application/json",
}
# Set it to successful here?
actionurl = "%s/api/v1/streams" % apiurl
action_result = node_message
action_result["status"] = "SUCCESS"
action_result["authorization"] = self.authorization
action_result["completed_at"] = int(time.time())
action_result["result"] = "User clicked continue!"
actionret = requests.post(actionurl, headers=headers, json=action_result)
elif node_message["status"] == "SKIPPED":
# FIXME - handle SKIPPED - these are
logger.info(f"GOT SKIPPEED result for: {node_message['action']['name']}-{node_message['execution_id']}")
elif node_message["status"] == "SUCCESS":
# Adds the data to accumulator with success AND
# removes the successful ones, which breaks the loop
self.accumulator[node_message["action"]["id"]] = node_message["result"]
logger.info(f"Worker received result for: {node_message['action']['name']}-{node_message['execution_id']}: {node_message['result']}")
self.in_process.pop(node_message["action"]["id"], None)
elif node_message["status"] == "FAILURE":
self.accumulator[node_message["action"]["id"]] = node_message["result"]
# FIXME - cancel nodes
#await self.cancel_subgraph(self.workflow.nodes[node_message.node_id]) # kill the children!
logger.info(f"Worker received error \"{node_message['result']}\" for: {node_message['action']['name']}-"
f"{node_message['execution_id']}")
else:
logger.error(f"Unknown message status received: {node_message}")
node_message = None
time.sleep(sleeptime)
return False
def abort(message, workflow_id, execution_id):
logger.info("ABORTING %s BECAUSE OF ERROR WITH FUNCTION STARTUP" % execution_id)
logger.info("Message: %s" % message)
url = f"{apiurl}/api/v1/workflows/{workflow_id}/executions/{execution_id}/abort"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {APIKEY}"
}
ret = requests.get(url, headers=headers, timeout=5)
logger.info("Aborted with status: %d and text:\n%s" % (ret.status_code, ret.text))
sys.exit(0)
def run_function(message):
messagedata = message
# Raise exception?
if messagedata["type"] != "workflow":
return f"Wrong type" % e, 500
# Required fields
execution_id = messagedata["execution_id"]
workflow_id = messagedata["workflow_id"]
# FIXME - add exception handler -> abort
workflow = workflow_loads(json.dumps(messagedata["workflow"]))
id = messagedata["workflow"]["id"]
locations = messagedata["locations"]
project_id = messagedata["project_id"]
authorization = messagedata["authorization"]
execution_id = messagedata["execution_id"]
workflow_id = messagedata["workflow_id"]
logger.info("Exec_id: %s, authorization: %s" % (execution_id, authorization))
if execution_id == None:
logger.info("NO EXECUTION ID")
abort("NO EXECUTION ID", workflow_id, execution_id)
if len(locations) <= 0:
logger.info("NO LOCATIONS")
abort("NO LOCATIONS", workflow_id, execution_id)
if not project_id:
logger.info("NO PROJECT_ID")
abort("NO PROJECT_ID", workflow_id, execution_id)
if not authorization:
logger.info("NO AUTHORIZATION")
abort("NO AUTHORIZATION", workflow_id, execution_id)
if not workflow_id:
logger.info("NO workflow_id")
abort("NO WORKFLOW_ID", workflow_id, execution_id)
worker = Worker(workflow)
worker.locations = locations
worker.execution_id = execution_id
worker.id = id
worker.project_id = project_id
worker.authorization = authorization
worker.workflow_id = workflow_id
try:
worker.start_id = messagedata["start"]
logger.debug("Start node is %s!" % messagedata["start"])
except KeyError:
try:
worker.start_id = messagedata["workflow"]["start"]
except KeyError:
pass
logger.info("STARTING EXECUTION TASK FOR %s" % execution_id)
try:
worker.execution_task = worker.execute_workflow_pubsub()
except Exception as e:
logger.error("Execution exception: %s" % e)
abort(e, workflow_id, execution_id)
# def abort(self):
logger.info(worker.execution_task)
return f"OK", 200
def authorization(data, context):
logger.info("JUST STARTED")
# Rofl
import base64
data = base64.b64decode(data['data']).decode('utf-8')
return main(data)
def main(data):
import argparse
LOG_LEVELS = ("debug", "info", "error", "warn", "fatal", "DEBUG", "INFO", "ERROR", "WARN", "FATAL")
parser = argparse.ArgumentParser()
parser.add_argument("--log-level", dest="log_level", choices=LOG_LEVELS, default="DEBUG")
parser.add_argument("--debug", "-d", dest="debug", action="store_true",
help="Enables debug level logging for the umpire as well as asyncio debug mode.")
args = parser.parse_args()
logger.setLevel(args.log_level.upper())
logger.info("STARTED")
if isinstance(data, str):
data = json.loads(data)
return run_function(data)
def test():
# Used for testing
with open("data.json", "r") as tmp:
print(main(tmp.read()))
if __name__ == "__main__":
test()