From e4cb2d1eb7abe42e10bdc9e89d0f6bdf97c87e0b Mon Sep 17 00:00:00 2001 From: Jingyi Date: Wed, 22 May 2024 20:22:58 +0800 Subject: [PATCH] merge proxy --- .gitignore | 2 +- Dockerfile.comfy | 10 + build_scripts/comfy/comfy_proxy.py | 1486 +++++++++++++++++ build_scripts/inference/serve | Bin 5050776 -> 5050776 bytes build_scripts/inference/start.sh | 77 +- build_scripts/install_comfy.sh | 5 +- build_scripts/install_sd.sh | 2 +- workshop/comfy_start.sh => comfy_start.sh | 29 +- .../src/api/workflows/delete-workflows.ts | 170 ++ infrastructure/src/shared/workflow.ts | 12 + middleware_api/endpoints/create_endpoint.py | 4 +- middleware_api/service/oas.py | 5 + middleware_api/workflows/delete_workflows.py | 47 + scripts/api.py | 4 +- scripts/main.py | 6 +- test/test_02_api_base/test_12_workflows.py | 43 +- test/utils/api.py | 9 + workshop/Dockerfile.comfy | 7 - workshop/comfy.yaml | 6 +- 19 files changed, 1836 insertions(+), 88 deletions(-) create mode 100755 Dockerfile.comfy create mode 100755 build_scripts/comfy/comfy_proxy.py rename workshop/comfy_start.sh => comfy_start.sh (69%) create mode 100644 infrastructure/src/api/workflows/delete-workflows.ts create mode 100644 middleware_api/workflows/delete_workflows.py delete mode 100755 workshop/Dockerfile.comfy diff --git a/.gitignore b/.gitignore index 58b47d73..3a1804d6 100644 --- a/.gitignore +++ b/.gitignore @@ -48,5 +48,5 @@ test/aigc_webui_inference_images/.env test/**/.env *.iml .DS_Store -/workshop/ComfyUI/ +/ComfyUI/ /.env diff --git a/Dockerfile.comfy b/Dockerfile.comfy new file mode 100755 index 00000000..b7b56b75 --- /dev/null +++ b/Dockerfile.comfy @@ -0,0 +1,10 @@ +ARG AWS_REGION +FROM 366590864501.dkr.ecr.$AWS_REGION.amazonaws.com/esd-inference:dev + +# TODO BYOC +#RUN apt-get update -y && \ +# apt-get install ffmpeg -y && \ +# rm -rf /var/lib/apt/lists/* \ + +COPY build_scripts/inference/start.sh / +RUN chmod +x /start.sh diff --git a/build_scripts/comfy/comfy_proxy.py b/build_scripts/comfy/comfy_proxy.py new file mode 100755 index 00000000..e4a85361 --- /dev/null +++ b/build_scripts/comfy/comfy_proxy.py @@ -0,0 +1,1486 @@ +import concurrent.futures +import signal +import threading + +import boto3 +import requests +from aiohttp import web + +import folder_paths +import server +from execution import PromptExecutor +import execution +import comfy + +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler +import subprocess +from dotenv import load_dotenv + +import fcntl +import hashlib + +import base64 +import datetime +import json +import logging +import os +import sys +import tarfile +import time +import uuid +import gc +from dataclasses import dataclass +from typing import Optional + + +from boto3.dynamodb.conditions import Key + + +DISABLE_AWS_PROXY = 'DISABLE_AWS_PROXY' + +sync_msg_list = [] + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +is_on_sagemaker = os.getenv('ON_SAGEMAKER') == 'true' +is_on_ec2 = os.getenv('ON_EC2') == 'true' + +if is_on_ec2: + env_path = '/etc/environment' + + if 'ENV_FILE_PATH' in os.environ and os.environ.get('ENV_FILE_PATH'): + env_path = os.environ.get('ENV_FILE_PATH') + + load_dotenv('/etc/environment') + logger.info(f"env_path{env_path}") + + env_keys = ['ENV_FILE_PATH', 'COMFY_INPUT_PATH', 'COMFY_MODEL_PATH', 'COMFY_NODE_PATH', 'COMFY_API_URL', + 'COMFY_API_TOKEN', 'COMFY_ENDPOINT', 'COMFY_NEED_SYNC', 'COMFY_NEED_PREPARE', 'COMFY_BUCKET_NAME', + 'MAX_WAIT_TIME', 'MSG_MAX_WAIT_TIME', 'THREAD_MAX_WAIT_TIME', DISABLE_AWS_PROXY, 'DISABLE_AUTO_SYNC'] + + for item in os.environ.keys(): + if item in env_keys: + logger.info(f'evn key: {item} {os.environ.get(item)}') + + DIR3 = "input" + DIR1 = "models" + DIR2 = "custom_nodes" + + if 'COMFY_INPUT_PATH' in os.environ and os.environ.get('COMFY_INPUT_PATH'): + DIR3 = os.environ.get('COMFY_INPUT_PATH') + if 'COMFY_MODEL_PATH' in os.environ and os.environ.get('COMFY_MODEL_PATH'): + DIR1 = os.environ.get('COMFY_MODEL_PATH') + if 'COMFY_NODE_PATH' in os.environ and os.environ.get('COMFY_NODE_PATH'): + DIR2 = os.environ.get('COMFY_NODE_PATH') + + + api_url = os.environ.get('COMFY_API_URL') + api_token = os.environ.get('COMFY_API_TOKEN') + comfy_endpoint = os.environ.get('COMFY_ENDPOINT', 'comfy-real-time-comfy') + comfy_need_sync = os.environ.get('COMFY_NEED_SYNC', True) + comfy_need_prepare = os.environ.get('COMFY_NEED_PREPARE', False) + bucket_name = os.environ.get('COMFY_BUCKET_NAME') + thread_max_wait_time = os.environ.get('THREAD_MAX_WAIT_TIME', 60) + max_wait_time = os.environ.get('MAX_WAIT_TIME', 86400) + msg_max_wait_time = os.environ.get('MSG_MAX_WAIT_TIME', 86400) + is_master_process = os.getenv('MASTER_PROCESS') == 'true' + no_need_sync_files = ['.autosave', '.cache', '.autosave1', '~', '.swp'] + + need_resend_msg_result = [] + PREPARE_ID = 'default' + # additional + PREPARE_MODE = 'additional' + + if not api_url: + raise ValueError("API_URL environment variables must be set.") + + if not api_token: + raise ValueError("API_TOKEN environment variables must be set.") + + if not comfy_endpoint: + raise ValueError("COMFY_ENDPOINT environment variables must be set.") + + headers = {"x-api-key": api_token, "Content-Type": "application/json"} + + + def save_images_locally(response_json, local_folder): + try: + data = response_json.get("data", {}) + prompt_id = data.get("prompt_id") + image_video_data = data.get("image_video_data", {}) + + if not prompt_id or not image_video_data: + logger.info("Missing prompt_id or image_video_data in the response.") + return + + folder_path = os.path.join(local_folder, prompt_id) + os.makedirs(folder_path, exist_ok=True) + + for image_name, image_url in image_video_data.items(): + image_response = requests.get(image_url) + if image_response.status_code == 200: + image_path = os.path.join(folder_path, image_name) + with open(image_path, "wb") as image_file: + image_file.write(image_response.content) + logger.info(f"Image '{image_name}' saved to {image_path}") + else: + logger.info( + f"Failed to download image '{image_name}' from {image_url}. Status code: {image_response.status_code}") + + except Exception as e: + logger.info(f"Error saving images locally: {e}") + + + def calculate_file_hash(file_path): + # 创建一个哈希对象 + hasher = hashlib.sha256() + # 打开文件并逐块更新哈希对象 + with open(file_path, 'rb') as file: + buffer = file.read(65536) # 64KB 的缓冲区大小 + while len(buffer) > 0: + hasher.update(buffer) + buffer = file.read(65536) + # 返回哈希值的十六进制表示 + return hasher.hexdigest() + + + def save_files(prefix, execute, key, target_dir, need_prefix): + if key in execute['data']: + temp_files = execute['data'][key] + for url in temp_files: + loca_file = get_file_name(url) + response = requests.get(url) + # if target_dir not exists, create it + if not os.path.exists(target_dir): + os.makedirs(target_dir) + logger.info(f"Saving file {loca_file} to {target_dir}") + if loca_file.endswith("output_images_will_be_put_here"): + continue + if need_prefix: + with open(f"./{target_dir}/{prefix}_{loca_file}", 'wb') as f: + f.write(response.content) + # current override exist + with open(f"./{target_dir}/{loca_file}", 'wb') as f: + f.write(response.content) + else: + with open(f"./{target_dir}/{loca_file}", 'wb') as f: + f.write(response.content) + + + def get_file_name(url: str): + file_name = url.split('/')[-1] + file_name = file_name.split('?')[0] + return file_name + + + def send_service_msg(server_use, msg): + event = msg.get('event') + data = msg.get('data') + sid = msg.get('sid') if 'sid' in msg else None + server_use.send_sync(event, data, sid) + + + def handle_sync_messages(server_use, msg_array): + already_synced = False + global sync_msg_list + for msg in msg_array: + for item_msg in msg: + event = item_msg.get('event') + data = item_msg.get('data') + sid = item_msg.get('sid') if 'sid' in item_msg else None + if data in sync_msg_list: + continue + sync_msg_list.append(data) + if event == 'finish': + already_synced = True + elif event == 'executed': + global need_resend_msg_result + need_resend_msg_result.append(msg) + server_use.send_sync(event, data, sid) + + return already_synced + + + def execute_proxy(func): + def wrapper(*args, **kwargs): + if 'True' == os.environ.get(DISABLE_AWS_PROXY): + logger.info("disabled aws proxy, use local") + return func(*args, **kwargs) + logger.info(f"enable aws proxy, use aws {comfy_endpoint}") + executor = args[0] + server_use = executor.server + prompt = args[1] + prompt_id = args[2] + extra_data = args[3] + + payload = { + "number": str(server.PromptServer.instance.number), + "prompt": prompt, + "prompt_id": prompt_id, + "extra_data": extra_data, + "endpoint_name": comfy_endpoint, + "need_prepare": comfy_need_prepare, + "need_sync": comfy_need_sync, + "multi_async": False + } + + def send_post_request(url, params): + logger.debug(f"sending post request {url} , params {params}") + get_response = requests.post(url, json=params, headers=headers) + return get_response + + def send_get_request(url): + get_response = requests.get(url, headers=headers) + return get_response + + def check_if_sync_is_already(url): + get_response = send_get_request(url) + prepare_response = get_response.json() + if (prepare_response['statusCode'] == 200 and 'data' in prepare_response and prepare_response['data'] + and prepare_response['data']['prepareSuccess']): + logger.info(f"sync available") + return True + else: + logger.info(f"no sync available for {url} response {prepare_response}") + return False + + def send_error_msg(executor, prompt_id, msg): + mes = { + "prompt_id": prompt_id, + "node_id": "", + "node_type": "on cloud", + "executed": [], + "exception_message": msg, + "exception_type": "", + "traceback": [], + "current_inputs": "", + "current_outputs": "", + } + executor.add_message("execution_error", mes, broadcast=True) + + logger.debug(f"payload is: {payload}") + is_synced = check_if_sync_is_already(f"{api_url}/prepare/{comfy_endpoint}") + if not is_synced: + logger.debug(f"is_synced is {is_synced} stop cloud prompt") + send_error_msg(executor, prompt_id, "Your local environment has not compleated to synchronized on cloud already. Please wait for a moment or click the 'Synchronize' button .") + return + + with concurrent.futures.ThreadPoolExecutor() as executorThread: + execute_future = executorThread.submit(send_post_request, f"{api_url}/executes", payload) + + save_already = False + if comfy_need_sync: + msg_future = executorThread.submit(send_get_request, + f"{api_url}/sync/{prompt_id}") + done, _ = concurrent.futures.wait([execute_future, msg_future], + return_when=concurrent.futures.ALL_COMPLETED) + already_synced = False + global sync_msg_list + sync_msg_list = [] + for future in done: + if future == msg_future: + msg_response = future.result() + logger.info(f"get syc msg: {msg_response.json()}") + if msg_response.status_code == 200: + if 'data' not in msg_response.json() or not msg_response.json().get("data"): + logger.error("there is no response from sync msg by thread ") + time.sleep(1) + else: + logger.debug(msg_response.json()) + already_synced = handle_sync_messages(server_use, msg_response.json().get("data")) + elif future == execute_future: + execute_resp = future.result() + logger.info(f"get execute status: {execute_resp.status_code}") + if execute_resp.status_code == 200 or execute_resp.status_code == 201 or execute_resp.status_code == 202: + i = thread_max_wait_time + while i > 0: + images_response = send_get_request(f"{api_url}/executes/{prompt_id}") + response = images_response.json() + logger.info(f"get execute images: {images_response.status_code}") + if images_response.status_code == 404: + logger.info("no images found already ,waiting sagemaker thread result .....") + time.sleep(3) + i = i - 2 + elif response['data']['status'] == 'failed': + logger.error(f"there is no response on sagemaker from execute thread result !!!!!!!! ") + # send_error_msg(executor, prompt_id, + # f"There may be some errors when valid and execute the prompt on the cloud. Please check the SageMaker logs. error info: {response['data']['message']}") + # no need to send msg anymore + already_synced = True + break + elif response['data']['status'] != 'Completed' and response['data']['status'] != 'success': + logger.info(f"no images found already ,waiting sagemaker thread result, current status is {response['data']['status']}") + time.sleep(2) + i = i - 1 + elif 'data' not in response or not response['data'] or 'status' not in response['data'] or not response['data']['status']: + logger.error(f"there is no response from execute thread result !!!!!!!! {response}") + # no need to send msg anymore + already_synced = True + # send_error_msg(executor, prompt_id,"There may be some errors when executing the prompt on cloud. No images or videos generated.") + break + else: + if ('temp_files' in images_response.json()['data'] and len( + images_response.json()['data']['temp_files']) > 0) or (( + 'output_files' in images_response.json()['data'] and len( + images_response.json()['data']['output_files']) > 0)): + save_files(prompt_id, images_response.json(), 'temp_files', 'temp', False) + save_files(prompt_id, images_response.json(), 'output_files', 'output', True) + else: + send_error_msg(executor, prompt_id, + "There may be some errors when executing the prompt on the cloud. Please check the SageMaker logs.") + # no need to send msg anymore + already_synced = True + logger.debug(images_response.json()) + save_already = True + break + else: + logger.error(f"get execute error: {execute_resp}") + # send_error_msg(executor, prompt_id, "Please valid your prompt and try again.") + # send_error_msg(executor, prompt_id, + # f"There may be some errors when valid and execute the prompt on the cloud. Please check the SageMaker logs. error info: {response['data']['message']}") + # no need to send msg anymore + already_synced = True + break + logger.debug(execute_resp.json()) + + m = msg_max_wait_time + while not already_synced: + msg_response = send_get_request(f"{api_url}/sync/{prompt_id}") + # logger.info(msg_response.json()) + if msg_response.status_code == 200: + if m <= 0: + logger.error("there is no response from sync msg by timeout") + already_synced = True + elif 'data' not in msg_response.json() or not msg_response.json().get("data"): + logger.error("there is no response from sync msg") + time.sleep(1) + m = m - 1 + else: + logger.debug(msg_response.json()) + already_synced = handle_sync_messages(server_use, msg_response.json().get("data")) + logger.info(f"already_synced is :{already_synced}") + time.sleep(1) + m = m - 1 + logger.info(f"check if images are already synced {save_already}") + + if not save_already: + logger.info("check if images are not already synced, please wait") + execute_resp = execute_future.result() + logger.debug(f"execute result :{execute_resp.json()}") + if execute_resp.status_code == 200 or execute_resp.status_code == 201 or execute_resp.status_code == 202: + i = max_wait_time + while i > 0: + images_response = send_get_request(f"{api_url}/executes/{prompt_id}") + response = images_response.json() + logger.debug(response) + if images_response.status_code == 404: + logger.info(f"{i} no images found already ,waiting sagemaker result .....") + i = i - 2 + time.sleep(3) + elif response['data']['status'] == 'failed': + logger.error( + f"there is no response on sagemaker from execute result !!!!!!!! ") + if 'message' in response['data'] and response['data']['message']: + send_error_msg(executor, prompt_id, + f"There may be some errors when valid or execute the prompt on the cloud. Please check the SageMaker logs. errors: {response['data']['message']}") + break + else: + logger.error(f"valid error on sagemaker :{response['data']}") + send_error_msg(executor, prompt_id, + f"There may be some errors when valid or execute the prompt on the cloud. errors") + break + elif response['data']['status'] != 'Completed' and response['data']['status'] != 'success': + logger.info(f"{i} images not already ,waiting sagemaker result .....{response['data']['status'] }") + i = i - 1 + time.sleep(3) + elif 'data' not in response or not response['data'] or 'status' not in response['data'] or not response['data']['status']: + logger.info(f"{i} there is no response from sync executes {response}") + send_error_msg(executor, prompt_id, f"There may be some errors when executing the prompt on the cloud. No images or videos generated. {response['message']}") + break + elif response['data']['status'] == 'Completed' or response['data']['status'] == 'success': + if ('temp_files' in images_response.json()['data'] and len(images_response.json()['data']['temp_files']) > 0) or (('output_files' in images_response.json()['data'] and len(images_response.json()['data']['output_files']) > 0)): + save_files(prompt_id, images_response.json(), 'temp_files', 'temp', False) + save_files(prompt_id, images_response.json(), 'output_files', 'output', True) + break + else: + send_error_msg(executor, prompt_id, + "There may be some errors when executing the prompt on the cloud. Please check the SageMaker logs.") + break + else: + # logger.info( + # f"{i} images not already other,waiting sagemaker result .....{response}") + # i = i - 1 + # time.sleep(3) + send_error_msg(executor, prompt_id, + "You have some errors when execute prompt on cloud . Please check your sagemaker logs.") + break + else: + logger.error(f"get execute error: {execute_resp}") + send_error_msg(executor, prompt_id, "Please valid your prompt and try again.") + logger.info("execute finished") + executorThread.shutdown() + + return wrapper + + + PromptExecutor.execute = execute_proxy(PromptExecutor.execute) + + + def send_sync_proxy(func): + def wrapper(*args, **kwargs): + logger.info(f"Sending sync request----- {args}") + return func(*args, **kwargs) + return wrapper + + + server.PromptServer.send_sync = send_sync_proxy(server.PromptServer.send_sync) + + + def compress_and_upload(folder_path, prepare_version): + for subdir in next(os.walk(folder_path))[1]: + subdir_path = os.path.join(folder_path, subdir) + tar_filename = f"{subdir}.tar.gz" + logger.info(f"Compressing the {tar_filename}") + + # 创建 tar 压缩文件 + with tarfile.open(tar_filename, "w:gz") as tar: + tar.add(subdir_path, arcname=os.path.basename(subdir_path)) + s5cmd_syn_node_command = f's5cmd --log=error cp {tar_filename} "s3://{bucket_name}/comfy/{comfy_endpoint}/{prepare_version}/custom_nodes/"' + logger.info(s5cmd_syn_node_command) + os.system(s5cmd_syn_node_command) + logger.info(f"rm {tar_filename}") + os.remove(tar_filename) + + # for root, dirs, files in os.walk(folder_path): + # for directory in dirs: + # dir_path = os.path.join(root, directory) + # logger.info(f"Compressing the {dir_path}") + # tar_filename = f"{directory}.tar.gz" + # tar_filepath = os.path.join(root, tar_filename) + # with tarfile.open(tar_filepath, "w:gz") as tar: + # tar.add(dir_path, arcname=os.path.basename(dir_path)) + # s5cmd_syn_node_command = f's5cmd --log=error cp {tar_filepath} "s3://{bucket_name}/comfy/{comfy_endpoint}/{timestamp}/custom_nodes/"' + # logger.info(s5cmd_syn_node_command) + # os.system(s5cmd_syn_node_command) + # logger.info(f"rm {tar_filepath}") + # os.remove(tar_filepath) + + + def sync_default_files(): + try: + timestamp = str(int(time.time() * 1000)) + prepare_version = PREPARE_ID if PREPARE_MODE == 'additional' else timestamp + need_prepare = True + prepare_type = 'default' + need_reboot = True + logger.info(f" sync custom nodes files") + # s5cmd_syn_node_command = f's5cmd --log=error sync --delete=true --exclude="*comfy_local_proxy.py" {DIR2}/ "s3://{bucket_name}/comfy/{comfy_endpoint}/{timestamp}/custom_nodes/"' + # logger.info(f"sync custom_nodes files start {s5cmd_syn_node_command}") + # os.system(s5cmd_syn_node_command) + compress_and_upload(f"{DIR2}", prepare_version) + logger.info(f" sync input files") + s5cmd_syn_input_command = f's5cmd --log=error sync --delete=true {DIR3}/ "s3://{bucket_name}/comfy/{comfy_endpoint}/{prepare_version}/input/"' + logger.info(f"sync input files start {s5cmd_syn_input_command}") + os.system(s5cmd_syn_input_command) + logger.info(f" sync models files") + s5cmd_syn_model_command = f's5cmd --log=error sync --delete=true {DIR1}/ "s3://{bucket_name}/comfy/{comfy_endpoint}/{prepare_version}/models/"' + logger.info(f"sync models files start {s5cmd_syn_model_command}") + os.system(s5cmd_syn_model_command) + logger.info(f"Files changed in:: {need_prepare} {DIR2} {DIR1} {DIR3}") + url = api_url + "prepare" + logger.info(f"URL:{url}") + data = {"endpoint_name": comfy_endpoint, "need_reboot": need_reboot, "prepare_id": prepare_version, + "prepare_type": prepare_type} + logger.info(f"prepare params Data: {json.dumps(data, indent=4)}") + result = subprocess.run(["curl", "--location", "--request", "POST", url, "--header", + f"x-api-key: {api_token}", "--data-raw", json.dumps(data)], + capture_output=True, text=True) + logger.info(result.stdout) + return result.stdout + except Exception as e: + logger.info(f"sync_files error {e}") + return None + + + def sync_files(filepath, is_folder, is_auto): + try: + directory = os.path.dirname(filepath) + logger.info(f"Directory changed in: {directory} {filepath}") + if not directory: + logger.info("root path no need to sync files by duplicate opt") + return None + logger.info(f"Files changed in: {filepath}") + timestamp = str(int(time.time() * 1000)) + need_prepare = False + prepare_type = 'default' + need_reboot = False + for ignore_item in no_need_sync_files: + if filepath.endswith(ignore_item): + logger.info(f"no need to sync files by ignore files {filepath} ends by {ignore_item}") + return None + prepare_version = PREPARE_ID if PREPARE_MODE == 'additional' else timestamp + if (str(directory).endswith(f"{DIR2}" if DIR2.startswith("/") else f"/{DIR2}") + or str(filepath) == DIR2 or str(filepath) == f'./{DIR2}' or f"{DIR2}/" in filepath): + logger.info(f" sync custom nodes files: {filepath}") + s5cmd_syn_node_command = f's5cmd --log=error sync --delete=true --exclude="*comfy_local_proxy.py" {DIR2}/ "s3://{bucket_name}/comfy/{comfy_endpoint}/{prepare_version}/custom_nodes/"' + # s5cmd_syn_node_command = f'aws s3 sync {DIR2}/ "s3://{bucket_name}/comfy/{comfy_endpoint}/{timestamp}/custom_nodes/"' + # s5cmd_syn_node_command = f's5cmd sync {DIR2}/* "s3://{bucket_name}/comfy/{comfy_endpoint}/{timestamp}/custom_nodes/"' + + # custom_node文件夹有变化 稍后再同步 + if is_auto and not is_folder_unlocked(directory): + logger.info("sync custom_nodes files is changing ,waiting.... ") + return None + logger.info("sync custom_nodes files start") + logger.info(s5cmd_syn_node_command) + os.system(s5cmd_syn_node_command) + need_prepare = True + need_reboot = True + prepare_type = 'nodes' + elif (str(directory).endswith(f"{DIR3}" if DIR3.startswith("/") else f"/{DIR3}") + or str(filepath) == DIR3 or str(filepath) == f'./{DIR3}' or f"{DIR3}/" in filepath): + logger.info(f" sync input files: {filepath}") + s5cmd_syn_input_command = f's5cmd --log=error sync --delete=true {DIR3}/ "s3://{bucket_name}/comfy/{comfy_endpoint}/{prepare_version}/input/"' + + # 判断文件写完后再同步 + if is_auto: + if bool(is_folder): + can_sync = is_folder_unlocked(filepath) + else: + can_sync = is_file_unlocked(filepath) + if not can_sync: + logger.info("sync input files is changing ,waiting.... ") + return None + logger.info("sync input files start") + logger.info(s5cmd_syn_input_command) + os.system(s5cmd_syn_input_command) + need_prepare = True + prepare_type = 'inputs' + elif (str(directory).endswith(f"{DIR1}" if DIR1.startswith("/") else f"/{DIR1}") + or str(filepath) == DIR1 or str(filepath) == f'./{DIR1}' or f"{DIR1}/" in filepath): + logger.info(f" sync models files: {filepath}") + s5cmd_syn_model_command = f's5cmd --log=error sync --delete=true {DIR1}/ "s3://{bucket_name}/comfy/{comfy_endpoint}/{prepare_version}/models/"' + + # 判断文件写完后再同步 + if is_auto: + if bool(is_folder): + can_sync = is_folder_unlocked(filepath) + else: + can_sync = is_file_unlocked(filepath) + # logger.info(f'is folder {directory} {is_folder} can_sync {can_sync}') + if not can_sync: + logger.info("sync input models is changing ,waiting.... ") + return None + + logger.info("sync models files start") + logger.info(s5cmd_syn_model_command) + os.system(s5cmd_syn_model_command) + need_prepare = True + prepare_type = 'models' + logger.info(f"Files changed in:: {need_prepare} {str(directory)} {DIR2} {DIR1} {DIR3}") + if need_prepare: + url = api_url + "prepare" + logger.info(f"URL:{url}") + data = {"endpoint_name": comfy_endpoint, "need_reboot": need_reboot, "prepare_id": prepare_version, + "prepare_type": prepare_type} + logger.info(f"prepare params Data: {json.dumps(data, indent=4)}") + result = subprocess.run(["curl", "--location", "--request", "POST", url, "--header", + f"x-api-key: {api_token}", "--data-raw", json.dumps(data)], + capture_output=True, text=True) + logger.info(result.stdout) + return result.stdout + return None + except Exception as e: + logger.info(f"sync_files error {e}") + return None + + + def is_folder_unlocked(directory): + # logger.info("check if folder ") + event_handler = MyHandlerWithCheck() + observer = Observer() + observer.schedule(event_handler, directory, recursive=True) + observer.start() + time.sleep(1) + result = False + try: + if event_handler.file_changed: + logger.info(f"folder {directory} is still changing..") + event_handler.file_changed = False + time.sleep(1) + if event_handler.file_changed: + logger.info(f"folder {directory} is still still changing..") + else: + logger.info(f"folder {directory} changing stopped") + result = True + else: + logger.info(f"folder {directory} not stopped") + result = True + except (KeyboardInterrupt, Exception) as e: + logger.info(f"folder {directory} changed exception {e}") + observer.stop() + return result + + + def is_file_unlocked(file_path): + # logger.info("check if file ") + try: + initial_size = os.path.getsize(file_path) + initial_mtime = os.path.getmtime(file_path) + time.sleep(1) + + current_size = os.path.getsize(file_path) + current_mtime = os.path.getmtime(file_path) + if current_size != initial_size or current_mtime != initial_mtime: + logger.info(f"unlock file error {file_path} is changing") + return False + + with open(file_path, 'r') as f: + fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except (IOError, OSError, Exception) as e: + logger.info(f"unlock file error {file_path} is writing") + logger.error(e) + return False + + + class MyHandlerWithCheck(FileSystemEventHandler): + def __init__(self): + self.file_changed = False + + def on_modified(self, event): + logger.info(f"custom_node folder is changing {event.src_path}") + self.file_changed = True + + def on_deleted(self, event): + logger.info(f"custom_node folder is changing {event.src_path}") + self.file_changed = True + + def on_created(self, event): + logger.info(f"custom_node folder is changing {event.src_path}") + self.file_changed = True + + + class MyHandlerWithSync(FileSystemEventHandler): + def on_modified(self, event): + logger.info(f"{datetime.datetime.now()} files modified ,start to sync {event}") + sync_files(event.src_path, event.is_directory, True) + + def on_created(self, event): + logger.info(f"{datetime.datetime.now()} files added ,start to sync {event}") + sync_files(event.src_path, event.is_directory, True) + + def on_deleted(self, event): + logger.info(f"{datetime.datetime.now()} files deleted ,start to sync {event}") + sync_files(event.src_path, event.is_directory, True) + + + stop_event = threading.Event() + + + def check_and_sync(): + logger.info("check_and_sync start") + event_handler = MyHandlerWithSync() + observer = Observer() + try: + observer.schedule(event_handler, DIR1, recursive=True) + observer.schedule(event_handler, DIR2, recursive=True) + observer.schedule(event_handler, DIR3, recursive=True) + observer.start() + while True: + time.sleep(1) + except KeyboardInterrupt: + logger.info("sync Shutting down please restart ComfyUI") + observer.stop() + observer.join() + + + def signal_handler(sig, frame): + logger.info("Received termination signal. Exiting...") + stop_event.set() + + + if os.environ.get('DISABLE_AUTO_SYNC') == 'false': + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + check_sync_thread = threading.Thread(target=check_and_sync) + check_sync_thread.start() + + + @server.PromptServer.instance.routes.get("/reboot") + async def restart(self): + logger.info(f"start to reboot {self}") + try: + subprocess.run(["sudo", "reboot"]) + except Exception as e: + logger.info(f"error reboot {e}") + pass + return os.execv(sys.executable, [sys.executable] + sys.argv) + + + @server.PromptServer.instance.routes.get("/check_prepare") + async def check_prepare(self): + logger.info(f"start to check_prepare {self}") + try: + get_response = requests.get(f"{api_url}/prepare/{comfy_endpoint}", headers=headers) + response = get_response.json() + logger.info(f"check sync response is {response}") + if get_response.status_code == 200 and response['data']['prepareSuccess']: + return web.Response(status=200, content_type='application/json', body=json.dumps({"result": True})) + else: + logger.info(f"check sync response is {response} {response['data']['prepareSuccess']}") + return web.Response(status=500, content_type='application/json', body=json.dumps({"result": False})) + except Exception as e: + logger.info(f"error restart {e}") + pass + return os.execv(sys.executable, [sys.executable] + sys.argv) + + + @server.PromptServer.instance.routes.get("/gc") + async def gc(self): + logger.info(f"start to gc {self}") + try: + logger.info(f"gc start: {time.time()}") + server_instance = server.PromptServer.instance + e = execution.PromptExecutor(server_instance) + e.reset() + comfy.model_management.cleanup_models() + gc.collect() + comfy.model_management.soft_empty_cache() + gc_triggered = True + logger.info(f"gc end: {time.time()}") + except Exception as e: + logger.info(f"error restart {e}") + pass + return os.execv(sys.executable, [sys.executable] + sys.argv) + + + @server.PromptServer.instance.routes.get("/restart") + async def restart(self): + logger.info(f"start to restart {self}") + try: + sys.stdout.close_log() + except Exception as e: + logger.info(f"error restart {e}") + pass + return os.execv(sys.executable, [sys.executable] + sys.argv) + + + @server.PromptServer.instance.routes.get("/sync_env") + async def sync_env(request): + logger.info(f"start to sync_env {request}") + try: + result = sync_default_files() + logger.debug(f"sync result is :{result}") + return web.Response(status=200, content_type='application/json', body=json.dumps({"result": True})) + except Exception as e: + logger.info(f"error sync_env {e}") + pass + return web.Response(status=500, content_type='application/json', body=json.dumps({"result": False})) + + + @server.PromptServer.instance.routes.post("/change_env") + async def change_env(request): + logger.info(f"start to change_env {request}") + json_data = await request.json() + if DISABLE_AWS_PROXY in json_data and json_data[DISABLE_AWS_PROXY] is not None: + logger.info(f"origin evn key DISABLE_AWS_PROXY is :{os.environ.get(DISABLE_AWS_PROXY)} {str(json_data[DISABLE_AWS_PROXY])}") + os.environ[DISABLE_AWS_PROXY] = str(json_data[DISABLE_AWS_PROXY]) + logger.info(f"now evn key DISABLE_AWS_PROXY is :{os.environ.get(DISABLE_AWS_PROXY)}") + return web.Response(status=200, content_type='application/json', body=json.dumps({"result": True})) + + + @server.PromptServer.instance.routes.get("/get_env") + async def get_env(request): + env = os.environ.get(DISABLE_AWS_PROXY, 'False') + return web.Response(status=200, content_type='application/json', body=json.dumps({"env": env})) + + + @server.PromptServer.instance.routes.post("/workflows") + async def release_workflow(request): + if not is_master_process: + return web.Response(status=200, content_type='application/json', + body=json.dumps({"result": False, "message": "only master can release workflow"})) + + logger.info(f"start to release workflow {request}") + try: + json_data = await request.json() + if 'name' not in json_data or not json_data['name']: + raise ValueError("name is required") + + workflow_name = json_data['name'] + payload_json = '' + + if 'payload_json' in json_data: + payload_json = json_data['payload_json'] + + if check_file_exists(f"comfy/workflows/{workflow_name}/lock"): + return web.Response(status=200, content_type='application/json', + body=json.dumps({"result": False, "message": "workflow already exists"})) + + start_time = time.time() + + s5cmd_syn_model_command = (f's5cmd sync ' + f'--delete=true ' + f'--exclude="*.log" ' + f'--exclude="*__pycache__*" ' + f'--exclude="*.cache*" ' + f'"/home/ubuntu/ComfyUI/*" ' + f'"s3://{bucket_name}/comfy/workflows/{workflow_name}/"') + logger.info(f"sync models files start {s5cmd_syn_model_command}") + os.system(s5cmd_syn_model_command) + + end_time = time.time() + cost_time = end_time - start_time + data = { + "payload_json": payload_json, + "image_uri": os.getenv('IMAGE_HASH'), + "name": workflow_name, + } + get_response = requests.post(f"{api_url}/workflows", headers=headers, data=json.dumps(data)) + response = get_response.json() + logger.info(f"release workflow response is {response}") + + if get_response.status_code == 200: + os.system(f'echo "lock" > lock && s5cmd sync lock s3://{bucket_name}/comfy/workflows/{workflow_name}/lock') + + return web.Response(status=200, content_type='application/json', + body=json.dumps({"result": True, "message": "success", "cost_time": cost_time})) + except Exception as e: + return web.Response(status=500, content_type='application/json', + body=json.dumps({"result": False, "message": e})) + + + def check_file_exists(key): + try: + s3 = boto3.client('s3') + s3.head_object(Bucket=bucket_name, Key=key) + return True + except Exception as e: + logger.error(e, exc_info=True) + if e.response['Error']['Code'] == '404': + return False + else: + raise e + + + def restore_commands(): + subprocess.run(["sleep", "5"]) + os.system("rm -rf /home/ubuntu/ComfyUI") + subprocess.run(["pkill", "-f", "python3"]) + + + # RestoreEC2EnvironmentToDefault + @server.PromptServer.instance.routes.post("/restore") + async def release_rebuild_workflow(request): + if not is_master_process: + return web.Response(status=200, content_type='application/json', + body=json.dumps({"result": False, "message": "only master can restore comfy"})) + + logger.info(f"start to restore EC2 {request}") + + try: + thread = threading.Thread(target=restore_commands) + thread.start() + return web.Response(status=200, content_type='application/json', + body=json.dumps({"result": True, "message": "comfy will be restored in 5 seconds"})) + except Exception as e: + return web.Response(status=500, content_type='application/json', + body=json.dumps({"result": False, "message": e})) + + +if is_on_sagemaker: + + global need_sync + global prompt_id + global executing + executing = False + + global reboot + reboot = False + + global last_call_time + last_call_time = None + global gc_triggered + gc_triggered = False + + REGION = os.environ.get('AWS_REGION') + BUCKET = os.environ.get('S3_BUCKET_NAME') + QUEUE_URL = os.environ.get('COMFY_QUEUE_URL') + + GEN_INSTANCE_ID = os.environ.get('ENDPOINT_INSTANCE_ID') if 'ENDPOINT_INSTANCE_ID' in os.environ and os.environ.get('ENDPOINT_INSTANCE_ID') else str(uuid.uuid4()) + ENDPOINT_NAME = os.environ.get('ENDPOINT_NAME') + ENDPOINT_ID = os.environ.get('ENDPOINT_ID') + + INSTANCE_MONITOR_TABLE_NAME = os.environ.get('COMFY_INSTANCE_MONITOR_TABLE') + SYNC_TABLE_NAME = os.environ.get('COMFY_SYNC_TABLE') + + dynamodb = boto3.resource('dynamodb', region_name=REGION) + sync_table = dynamodb.Table(SYNC_TABLE_NAME) + instance_monitor_table = dynamodb.Table(INSTANCE_MONITOR_TABLE_NAME) + + logger = logging.getLogger(__name__) + logger.setLevel(os.environ.get('LOG_LEVEL') or logging.INFO) + + ROOT_PATH = '/home/ubuntu/ComfyUI' + sqs_client = boto3.client('sqs', region_name=REGION) + + GC_WAIT_TIME = 1800 + + + def print_env(): + for key, value in os.environ.items(): + logger.info(f"{key}: {value}") + + + @dataclass + class ComfyResponse: + statusCode: int + message: str + body: Optional[dict] + + + def ok(body: dict): + return web.Response(status=200, content_type='application/json', body=json.dumps(body)) + + + def error(body: dict): + # TODO 500 -》200 because of need resp anyway not exception + return web.Response(status=200, content_type='application/json', body=json.dumps(body)) + + + def sen_sqs_msg(message_body, prompt_id_key): + response = sqs_client.send_message( + QueueUrl=QUEUE_URL, + MessageBody=json.dumps(message_body), + MessageGroupId=prompt_id_key + ) + message_id = response['MessageId'] + return message_id + + + def sen_finish_sqs_msg(prompt_id_key): + global need_sync + # logger.info(f"sen_finish_sqs_msg start... {need_sync},{prompt_id_key}") + if need_sync and QUEUE_URL and REGION: + message_body = {'prompt_id': prompt_id_key, 'event': 'finish', 'data': {"node": None, "prompt_id": prompt_id_key}, + 'sid': None} + message_id = sen_sqs_msg(message_body, prompt_id_key) + logger.info(f"finish message sent {message_id}") + + + async def prepare_comfy_env(sync_item: dict): + try: + request_id = sync_item['request_id'] + logger.info(f"prepare_environment start sync_item:{sync_item}") + prepare_type = sync_item['prepare_type'] + rlt = True + if prepare_type in ['default', 'models']: + sync_models_rlt = sync_s3_files_or_folders_to_local(f'{request_id}/models/*', f'{ROOT_PATH}/models', False) + if not sync_models_rlt: + rlt = False + if prepare_type in ['default', 'inputs']: + sync_inputs_rlt = sync_s3_files_or_folders_to_local(f'{request_id}/input/*', f'{ROOT_PATH}/input', False) + if not sync_inputs_rlt: + rlt = False + if prepare_type in ['default', 'nodes']: + sync_nodes_rlt = sync_s3_files_or_folders_to_local(f'{request_id}/custom_nodes/*', + f'{ROOT_PATH}/custom_nodes', True) + if not sync_nodes_rlt: + rlt = False + if prepare_type == 'custom': + sync_source_path = sync_item['s3_source_path'] + local_target_path = sync_item['local_target_path'] + if not sync_source_path or not local_target_path: + logger.info("s3_source_path and local_target_path should not be empty") + else: + sync_rlt = sync_s3_files_or_folders_to_local(sync_source_path, + f'{ROOT_PATH}/{local_target_path}', False) + if not sync_rlt: + rlt = False + elif prepare_type == 'other': + sync_script = sync_item['sync_script'] + logger.info("sync_script") + # sync_script.startswith('s5cmd') 不允许 + try: + if sync_script and (sync_script.startswith("python3 -m pip") or sync_script.startswith("python -m pip") + or sync_script.startswith("pip install") or sync_script.startswith("apt") + or sync_script.startswith("os.environ") or sync_script.startswith("ls") + or sync_script.startswith("env") or sync_script.startswith("source") + or sync_script.startswith("curl") or sync_script.startswith("wget") + or sync_script.startswith("print") or sync_script.startswith("cat") + or sync_script.startswith("sudo chmod") or sync_script.startswith("chmod") + or sync_script.startswith("/home/ubuntu/ComfyUI/venv/bin/python")): + os.system(sync_script) + elif sync_script and (sync_script.startswith("export ") and len(sync_script.split(" ")) > 2): + sync_script_key = sync_script.split(" ")[1] + sync_script_value = sync_script.split(" ")[2] + os.environ[sync_script_key] = sync_script_value + logger.info(os.environ.get(sync_script_key)) + except Exception as e: + logger.error(f"Exception while execute sync_scripts : {sync_script}") + rlt = False + need_reboot = True if ('need_reboot' in sync_item and sync_item['need_reboot'] + and str(sync_item['need_reboot']).lower() == 'true')else False + global reboot + reboot = need_reboot + if need_reboot: + os.environ['NEED_REBOOT'] = 'true' + else: + os.environ['NEED_REBOOT'] = 'false' + logger.info("prepare_environment end") + os.environ['LAST_SYNC_REQUEST_ID'] = sync_item['request_id'] + os.environ['LAST_SYNC_REQUEST_TIME'] = str(sync_item['request_time']) + return rlt + except Exception as e: + return False + + + def sync_s3_files_or_folders_to_local(s3_path, local_path, need_un_tar): + logger.info("sync_s3_models_or_inputs_to_local start") + # s5cmd_command = f'{ROOT_PATH}/tools/s5cmd sync "s3://{bucket_name}/{s3_path}/*" "{local_path}/"' + if need_un_tar: + s5cmd_command = f's5cmd sync "s3://{BUCKET}/comfy/{ENDPOINT_NAME}/{s3_path}" "{local_path}/"' + else: + s5cmd_command = f's5cmd sync --delete=true "s3://{BUCKET}/comfy/{ENDPOINT_NAME}/{s3_path}" "{local_path}/"' + # s5cmd_command = f's5cmd sync --delete=true "s3://{BUCKET}/comfy/{ENDPOINT_NAME}/{s3_path}" "{local_path}/"' + # s5cmd_command = f's5cmd sync "s3://{BUCKET}/comfy/{ENDPOINT_NAME}/{s3_path}" "{local_path}/"' + try: + logger.info(s5cmd_command) + os.system(s5cmd_command) + logger.info(f'Files copied from "s3://{BUCKET}/comfy/{ENDPOINT_NAME}/{s3_path}" to "{local_path}/"') + if need_un_tar: + for filename in os.listdir(local_path): + if filename.endswith(".tar.gz"): + tar_filepath = os.path.join(local_path, filename) + # extract_path = os.path.splitext(os.path.splitext(tar_filepath)[0])[0] + # os.makedirs(extract_path, exist_ok=True) + # logger.info(f'Extracting extract_path is {extract_path}') + + with tarfile.open(tar_filepath, "r:gz") as tar: + for member in tar.getmembers(): + tar.extract(member, path=local_path) + os.remove(tar_filepath) + logger.info(f'File {tar_filepath} extracted and removed') + return True + except Exception as e: + logger.info(f"Error executing s5cmd command: {e}") + return False + + + def sync_local_outputs_to_s3(s3_path, local_path): + logger.info("sync_local_outputs_to_s3 start") + s5cmd_command = f's5cmd sync "{local_path}/*" "s3://{BUCKET}/comfy/{s3_path}/" ' + try: + logger.info(s5cmd_command) + os.system(s5cmd_command) + logger.info(f'Files copied local to "s3://{BUCKET}/comfy/{s3_path}/" to "{local_path}/"') + clean_cmd = f'rm -rf {local_path}' + os.system(clean_cmd) + logger.info(f'Files removed from local {local_path}') + except Exception as e: + logger.info(f"Error executing s5cmd command: {e}") + + + def sync_local_outputs_to_base64(local_path): + logger.info("sync_local_outputs_to_base64 start") + try: + result = {} + for root, dirs, files in os.walk(local_path): + for file in files: + file_path = os.path.join(root, file) + with open(file_path, "rb") as f: + file_content = f.read() + base64_content = base64.b64encode(file_content).decode('utf-8') + result[file] = base64_content + clean_cmd = f'rm -rf {local_path}' + os.system(clean_cmd) + logger.info(f'Files removed from local {local_path}') + return result + except Exception as e: + logger.info(f"Error executing s5cmd command: {e}") + return {} + + + @server.PromptServer.instance.routes.post("/execute_proxy") + async def execute_proxy(request): + json_data = await request.json() + if 'out_path' in json_data and json_data['out_path'] is not None: + out_path = json_data['out_path'] + else: + out_path = None + logger.info(f"invocations start json_data:{json_data}") + global need_sync + need_sync = json_data["need_sync"] + global prompt_id + prompt_id = json_data["prompt_id"] + try: + global executing + if executing is True: + resp = {"prompt_id": prompt_id, "instance_id": GEN_INSTANCE_ID, "status": "fail", + "message": "the environment is not ready valid[0] is false, need to resync"} + sen_finish_sqs_msg(prompt_id) + return error(resp) + executing = True + logger.info( + f'bucket_name: {BUCKET}, region: {REGION}') + if ('need_prepare' in json_data and json_data['need_prepare'] + and 'prepare_props' in json_data and json_data['prepare_props']): + sync_already = await prepare_comfy_env(json_data['prepare_props']) + if not sync_already: + resp = {"prompt_id": prompt_id, "instance_id": GEN_INSTANCE_ID, "status": "fail", + "message": "the environment is not ready with sync"} + executing = False + sen_finish_sqs_msg(prompt_id) + return error(resp) + server_instance = server.PromptServer.instance + if "number" in json_data: + number = float(json_data['number']) + server_instance.number = number + else: + number = server_instance.number + if "front" in json_data: + if json_data['front']: + number = -number + server_instance.number += 1 + valid = execution.validate_prompt(json_data['prompt']) + logger.info(f"Validating prompt result is {valid}") + if not valid[0]: + resp = {"prompt_id": prompt_id, "instance_id": GEN_INSTANCE_ID, "status": "fail", + "message": "the environment is not ready valid[0] is false, need to resync"} + executing = False + response = {"prompt_id": prompt_id, "number": number, "node_errors": valid[3]} + sen_finish_sqs_msg(prompt_id) + return error(resp) + # if len(valid) == 4 and len(valid[3]) > 0: + # logger.info(f"Validating prompt error there is something error because of :valid: {valid}") + # resp = {"prompt_id": prompt_id, "instance_id": GEN_INSTANCE_ID, "status": "fail", + # "message": f"the valid is error, need to resync or check the workflow :{valid}"} + # executing = False + # return error(resp) + extra_data = {} + client_id = '' + if "extra_data" in json_data: + extra_data = json_data["extra_data"] + if 'client_id' in extra_data and extra_data['client_id']: + client_id = extra_data['client_id'] + if "client_id" in json_data and json_data["client_id"]: + extra_data["client_id"] = json_data["client_id"] + client_id = json_data["client_id"] + + server_instance.client_id = client_id + + prompt_id = json_data['prompt_id'] + server_instance.last_prompt_id = prompt_id + e = execution.PromptExecutor(server_instance) + outputs_to_execute = valid[2] + e.execute(json_data['prompt'], prompt_id, extra_data, outputs_to_execute) + + s3_out_path = f'output/{prompt_id}/{out_path}' if out_path is not None else f'output/{prompt_id}' + s3_temp_path = f'temp/{prompt_id}/{out_path}' if out_path is not None else f'temp/{prompt_id}' + local_out_path = f'{ROOT_PATH}/output/{out_path}' if out_path is not None else f'{ROOT_PATH}/output' + local_temp_path = f'{ROOT_PATH}/temp/{out_path}' if out_path is not None else f'{ROOT_PATH}/temp' + + logger.info(f"s3_out_path is {s3_out_path} and s3_temp_path is {s3_temp_path} and local_out_path is {local_out_path} and local_temp_path is {local_temp_path}") + + sync_local_outputs_to_s3(s3_out_path, local_out_path) + sync_local_outputs_to_s3(s3_temp_path, local_temp_path) + + response_body = { + "prompt_id": prompt_id, + "instance_id": GEN_INSTANCE_ID, + "status": "success", + "output_path": f's3://{BUCKET}/comfy/{s3_out_path}', + "temp_path": f's3://{BUCKET}/comfy/{s3_temp_path}', + } + sen_finish_sqs_msg(prompt_id) + logger.info(f"execute inference response is {response_body}") + executing = False + return ok(response_body) + except Exception as ecp: + logger.info(f"exception occurred {ecp}") + resp = {"prompt_id": prompt_id, "instance_id": GEN_INSTANCE_ID, "status": "fail", + "message": f"exception occurred {ecp}"} + executing = False + return error(resp) + finally: + logger.info(f"gc check: {time.time()}") + try: + global last_call_time, gc_triggered + gc_triggered = False + if last_call_time is None: + logger.info(f"gc check last time is NONE") + last_call_time = time.time() + else: + if time.time() - last_call_time > GC_WAIT_TIME: + if not gc_triggered: + logger.info(f"gc start: {time.time()} - {last_call_time}") + e.reset() + comfy.model_management.cleanup_models() + gc.collect() + comfy.model_management.soft_empty_cache() + gc_triggered = True + logger.info(f"gc end: {time.time()} - {last_call_time}") + last_call_time = time.time() + else: + last_call_time = time.time() + logger.info(f"gc check end: {time.time()}") + except Exception as e: + logger.info(f"gc error: {e}") + + + def get_last_ddb_sync_record(): + sync_response = sync_table.query( + KeyConditionExpression=Key('endpoint_name').eq(ENDPOINT_NAME), + Limit=1, + ScanIndexForward=False + ) + latest_sync_record = sync_response['Items'][0] if ('Items' in sync_response + and len(sync_response['Items']) > 0) else None + if latest_sync_record: + logger.info(f"latest_sync_record is:{latest_sync_record}") + return latest_sync_record + + logger.info("no latest_sync_record found") + return None + + + def get_latest_ddb_instance_monitor_record(): + key_condition_expression = ('endpoint_name = :endpoint_name_val ' + 'AND gen_instance_id = :gen_instance_id_val') + expression_attribute_values = { + ':endpoint_name_val': ENDPOINT_NAME, + ':gen_instance_id_val': GEN_INSTANCE_ID + } + instance_monitor_response = instance_monitor_table.query( + KeyConditionExpression=key_condition_expression, + ExpressionAttributeValues=expression_attribute_values + ) + instance_monitor_record = instance_monitor_response['Items'][0] \ + if ('Items' in instance_monitor_response and len(instance_monitor_response['Items']) > 0) else None + + if instance_monitor_record: + logger.info(f"instance_monitor_record is {instance_monitor_record}") + return instance_monitor_record + + logger.info("no instance_monitor_record found") + return None + + + def save_sync_instance_monitor(last_sync_request_id: str, sync_status: str): + item = { + 'endpoint_id': ENDPOINT_ID, + 'endpoint_name': ENDPOINT_NAME, + 'gen_instance_id': GEN_INSTANCE_ID, + 'sync_status': sync_status, + 'last_sync_request_id': last_sync_request_id, + 'last_sync_time': datetime.datetime.now().isoformat(), + 'sync_list': [], + 'create_time': datetime.datetime.now().isoformat(), + 'last_heartbeat_time': datetime.datetime.now().isoformat() + } + save_resp = instance_monitor_table.put_item(Item=item) + logger.info(f"save instance item {save_resp}") + return save_resp + + + def update_sync_instance_monitor(instance_monitor_record): + # 更新记录 + update_expression = ("SET sync_status = :new_sync_status, last_sync_request_id = :sync_request_id, " + "sync_list = :sync_list, last_sync_time = :sync_time, last_heartbeat_time = :heartbeat_time") + expression_attribute_values = { + ":new_sync_status": instance_monitor_record['sync_status'], + ":sync_request_id": instance_monitor_record['last_sync_request_id'], + ":sync_list": instance_monitor_record['sync_list'], + ":sync_time": datetime.datetime.now().isoformat(), + ":heartbeat_time": datetime.datetime.now().isoformat(), + } + + response = instance_monitor_table.update_item( + Key={'endpoint_name': ENDPOINT_NAME, + 'gen_instance_id': GEN_INSTANCE_ID}, + UpdateExpression=update_expression, + ExpressionAttributeValues=expression_attribute_values + ) + logger.info(f"update_sync_instance_monitor :{response}") + return response + + + def sync_instance_monitor_status(need_save: bool): + try: + logger.info(f"sync_instance_monitor_status {datetime.datetime.now()}") + if need_save: + save_sync_instance_monitor('', 'init') + else: + update_expression = ("SET last_heartbeat_time = :heartbeat_time") + expression_attribute_values = { + ":heartbeat_time": datetime.datetime.now().isoformat(), + } + instance_monitor_table.update_item( + Key={'endpoint_name': ENDPOINT_NAME, + 'gen_instance_id': GEN_INSTANCE_ID}, + UpdateExpression=update_expression, + ExpressionAttributeValues=expression_attribute_values + ) + except Exception as e: + logger.info(f"sync_instance_monitor_status error :{e}") + + + @server.PromptServer.instance.routes.post("/reboot") + async def restart(self): + logger.debug(f"start to reboot!!!!!!!! {self}") + global executing + if executing is True: + logger.info(f"other inference doing cannot reboot!!!!!!!!") + return ok({"message": "other inference doing cannot reboot"}) + need_reboot = os.environ.get('NEED_REBOOT') + if need_reboot and need_reboot.lower() != 'true': + logger.info("no need to reboot by os") + return ok({"message": "no need to reboot by os"}) + global reboot + if reboot is False: + logger.info("no need to reboot by global constant") + return ok({"message": "no need to reboot by constant"}) + + logger.debug("rebooting !!!!!!!!") + try: + sys.stdout.close_log() + except Exception as e: + logger.info(f"error reboot!!!!!!!! {e}") + pass + return os.execv(sys.executable, [sys.executable] + sys.argv) + + + # must be sync invoke and use the env to check + @server.PromptServer.instance.routes.post("/sync_instance") + async def sync_instance(request): + if not BUCKET: + logger.error("No bucket provided ,wait and try again") + resp = {"status": "success", "message": "syncing"} + return ok(resp) + + if 'ALREADY_SYNC' in os.environ and os.environ.get('ALREADY_SYNC').lower() == 'false': + resp = {"status": "success", "message": "syncing"} + logger.error("other process doing ,wait and try again") + return ok(resp) + + os.environ['ALREADY_SYNC'] = 'false' + logger.info(f"sync_instance start !! {datetime.datetime.now().isoformat()} {request}") + try: + last_sync_record = get_last_ddb_sync_record() + if not last_sync_record: + logger.info("no last sync record found do not need sync") + sync_instance_monitor_status(True) + resp = {"status": "success", "message": "no sync"} + os.environ['ALREADY_SYNC'] = 'true' + return ok(resp) + + if ('request_id' in last_sync_record and last_sync_record['request_id'] + and os.environ.get('LAST_SYNC_REQUEST_ID') + and os.environ.get('LAST_SYNC_REQUEST_ID') == last_sync_record['request_id'] + and os.environ.get('LAST_SYNC_REQUEST_TIME') + and os.environ.get('LAST_SYNC_REQUEST_TIME') == str(last_sync_record['request_time'])): + logger.info("last sync record already sync by os check") + sync_instance_monitor_status(False) + resp = {"status": "success", "message": "no sync env"} + os.environ['ALREADY_SYNC'] = 'true' + return ok(resp) + + instance_monitor_record = get_latest_ddb_instance_monitor_record() + if not instance_monitor_record: + sync_already = await prepare_comfy_env(last_sync_record) + if sync_already: + logger.info("should init prepare instance_monitor_record") + sync_status = 'success' if sync_already else 'failed' + save_sync_instance_monitor(last_sync_record['request_id'], sync_status) + else: + sync_instance_monitor_status(False) + else: + if ('last_sync_request_id' in instance_monitor_record + and instance_monitor_record['last_sync_request_id'] + and instance_monitor_record['last_sync_request_id'] == last_sync_record['request_id'] + and instance_monitor_record['sync_status'] + and instance_monitor_record['sync_status'] == 'success' + and os.environ.get('LAST_SYNC_REQUEST_TIME') + and os.environ.get('LAST_SYNC_REQUEST_TIME') == str(last_sync_record['request_time'])): + logger.info("last sync record already sync") + sync_instance_monitor_status(False) + resp = {"status": "success", "message": "no sync ddb"} + os.environ['ALREADY_SYNC'] = 'true' + return ok(resp) + + sync_already = await prepare_comfy_env(last_sync_record) + instance_monitor_record['sync_status'] = 'success' if sync_already else 'failed' + instance_monitor_record['last_sync_request_id'] = last_sync_record['request_id'] + sync_list = instance_monitor_record['sync_list'] if ('sync_list' in instance_monitor_record + and instance_monitor_record['sync_list']) else [] + sync_list.append(last_sync_record['request_id']) + + instance_monitor_record['sync_list'] = sync_list + logger.info("should update prepare instance_monitor_record") + update_sync_instance_monitor(instance_monitor_record) + os.environ['ALREADY_SYNC'] = 'true' + resp = {"status": "success", "message": "sync"} + return ok(resp) + except Exception as e: + logger.info("exception occurred", e) + os.environ['ALREADY_SYNC'] = 'true' + resp = {"status": "fail", "message": "sync"} + return error(resp) + + + def validate_prompt_proxy(func): + def wrapper(*args, **kwargs): + # 在这里添加您的代理逻辑 + logger.info("validate_prompt_proxy start...") + # 调用原始函数 + result = func(*args, **kwargs) + # 在这里添加执行后的操作 + logger.info("validate_prompt_proxy end...") + return result + + return wrapper + + + execution.validate_prompt = validate_prompt_proxy(execution.validate_prompt) + + + def send_sync_proxy(func): + def wrapper(*args, **kwargs): + logger.debug(f"Sending sync request!!!!!!! {args}") + global need_sync + global prompt_id + logger.info(f"send_sync_proxy start... {need_sync},{prompt_id} {args}") + func(*args, **kwargs) + if need_sync and QUEUE_URL and REGION: + logger.debug(f"send_sync_proxy params... {QUEUE_URL},{REGION},{need_sync},{prompt_id}") + event = args[1] + data = args[2] + sid = args[3] if len(args) == 4 else None + message_body = {'prompt_id': prompt_id, 'event': event, 'data': data, 'sid': sid} + message_id = sen_sqs_msg(message_body, prompt_id) + logger.info(f'send_sync_proxy message_id :{message_id} message_body: {message_body}') + logger.debug(f"send_sync_proxy end...") + + return wrapper + + + server.PromptServer.send_sync = send_sync_proxy(server.PromptServer.send_sync) + + + def get_save_imge_path_proxy(func): + def wrapper(*args, **kwargs): + logger.info(f"get_save_imge_path_proxy args : {args} kwargs : {kwargs}") + full_output_folder, filename, counter, subfolder, filename_prefix = func(*args, **kwargs) + global prompt_id + filename_prefix_new = filename_prefix + "_" + str(prompt_id) + logger.info(f"get_save_imge_path_proxy filename_prefix new : {filename_prefix_new}") + return full_output_folder, filename, counter, subfolder, filename_prefix_new + + return wrapper + + + folder_paths.get_save_image_path = get_save_imge_path_proxy(folder_paths.get_save_image_path) diff --git a/build_scripts/inference/serve b/build_scripts/inference/serve index 84293c89320f17d6f51166c317531623a72d7a13..dfbbe4e0b70b8b3761dfbbe9ce510b6958bec36e 100755 GIT binary patch delta 62485 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%0D|7&hVG7hbs(bN8J$ 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 delta 62515 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/dev/null 2>&1; then - echo "ECR repository '$repository_name' already exists." +if aws ecr describe-repositories --region "$AWS_REGION" --repository-names "$CONTAINER_NAME" >/dev/null 2>&1; then + echo "ECR repository '$CONTAINER_NAME' already exists." else - echo "ECR repository '$repository_name' does not exist. Creating..." - aws ecr create-repository --repository-name --region "$AWS_REGION" "$repository_name" - echo "ECR repository '$repository_name' created successfully." + echo "ECR repository '$CONTAINER_NAME' does not exist. Creating..." + aws ecr create-repository --repository-name --region "$AWS_REGION" "$CONTAINER_NAME" | jq . + echo "ECR repository '$CONTAINER_NAME' created successfully." fi aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "366590864501.dkr.ecr.$AWS_REGION.amazonaws.com" @@ -32,7 +33,7 @@ docker build -f Dockerfile.comfy \ image_hash=$(docker inspect "$image" | jq -r ".[0].Id") image_hash=${image_hash:7} -release_image="$ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$repository_name:$image_hash" +release_image="$ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$CONTAINER_NAME:$image_hash" docker tag "$image" "$release_image" aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "$ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com" @@ -46,13 +47,16 @@ echo "Starting container..." local_volume="./ComfyUI" # local vol can be replace with your local directory +# -v ./build_scripts/comfy/comfy_local_proxy.py:/home/ubuntu/ComfyUI/custom_nodes/comfy_local_proxy.py \ + docker run -v ~/.aws:/root/.aws \ - -v $local_volume:/home/ubuntu/ComfyUI \ + -v "$local_volume":/home/ubuntu/ComfyUI \ + -v ./build_scripts/inference/start.sh:/start.sh \ --gpus all \ -e "IMAGE_HASH=$release_image" \ -e "ESD_VERSION=$ESD_VERSION" \ -e "SERVICE_TYPE=comfy" \ - -e "COMFY_EC2=true" \ + -e "ON_EC2=true" \ -e "S3_BUCKET_NAME=$COMFY_BUCKET_NAME" \ -e "AWS_REGION=$AWS_REGION" \ -e "AWS_DEFAULT_REGION=$AWS_REGION" \ @@ -61,6 +65,7 @@ docker run -v ~/.aws:/root/.aws \ -e "COMFY_ENDPOINT=$COMFY_ENDPOINT" \ -e "COMFY_BUCKET_NAME=$COMFY_BUCKET_NAME" \ -e "PROCESS_NUMBER=$PROCESS_NUMBER" \ + -e "WORKFLOW_NAME=$WORKFLOW_NAME" \ --name "$CONTAINER_NAME" \ -p 8188-8288:8188-8288 \ "$image" diff --git a/infrastructure/src/api/workflows/delete-workflows.ts b/infrastructure/src/api/workflows/delete-workflows.ts new file mode 100644 index 00000000..3b6b3598 --- /dev/null +++ b/infrastructure/src/api/workflows/delete-workflows.ts @@ -0,0 +1,170 @@ +import {PythonFunction} from '@aws-cdk/aws-lambda-python-alpha'; +import {Aws, aws_lambda, Duration} from 'aws-cdk-lib'; +import {JsonSchemaType, JsonSchemaVersion, LambdaIntegration, Model, Resource} from 'aws-cdk-lib/aws-apigateway'; +import {Table} from 'aws-cdk-lib/aws-dynamodb'; +import {Effect, PolicyStatement, Role, ServicePrincipal} from 'aws-cdk-lib/aws-iam'; +import {Architecture, LayerVersion, Runtime} from 'aws-cdk-lib/aws-lambda'; +import {Construct} from 'constructs'; +import {ApiModels} from '../../shared/models'; +import {SCHEMA_WORKFLOW_NAME} from '../../shared/schema'; +import {ApiValidators} from '../../shared/validator'; + +export interface DeleteWorkflowsApiProps { + router: Resource; + httpMethod: string; + workflowsTable: Table; + multiUserTable: Table; + commonLayer: LayerVersion; +} + +export class DeleteWorkflowsApi { + private readonly router: Resource; + private readonly httpMethod: string; + private readonly scope: Construct; + private readonly workflowsTable: Table; + private readonly multiUserTable: Table; + private readonly layer: LayerVersion; + private readonly baseId: string; + + constructor(scope: Construct, id: string, props: DeleteWorkflowsApiProps) { + this.scope = scope; + this.baseId = id; + this.router = props.router; + this.httpMethod = props.httpMethod; + this.workflowsTable = props.workflowsTable; + this.multiUserTable = props.multiUserTable; + this.layer = props.commonLayer; + + const lambdaFunction = this.apiLambda(); + + const lambdaIntegration = new LambdaIntegration( + lambdaFunction, + { + proxy: true, + }, + ); + + this.router.addMethod(this.httpMethod, lambdaIntegration, { + apiKeyRequired: true, + requestValidator: ApiValidators.bodyValidator, + requestModels: { + 'application/json': this.createRequestBodyModel(), + }, + operationName: 'DeleteWorkflows', + methodResponses: [ + ApiModels.methodResponses204(), + ApiModels.methodResponses400(), + ApiModels.methodResponses401(), + ApiModels.methodResponses403(), + ], + }); + } + + private iamRole(): Role { + + const newRole = new Role(this.scope, `${this.baseId}-role`, { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + }); + + + newRole.addToPolicy(new PolicyStatement({ + actions: [ + 'dynamodb:Query', + 'dynamodb:GetItem', + 'dynamodb:PutItem', + 'dynamodb:DeleteItem', + 'dynamodb:UpdateItem', + 'dynamodb:Describe*', + 'dynamodb:List*', + ], + resources: [ + this.workflowsTable.tableArn, + `${this.workflowsTable.tableArn}/*`, + this.multiUserTable.tableArn, + ], + })); + + newRole.addToPolicy(new PolicyStatement({ + actions: [ + 's3:Get*', + 's3:List*', + 's3:PutObject', + 's3:GetObject', + 's3:DeleteObject', + ], + resources: [ + '*', + ], + })); + + newRole.addToPolicy(new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'cloudwatch:DeleteAlarms', + 'cloudwatch:DescribeAlarms', + 'cloudwatch:DeleteDashboards', + ], + resources: [ + '*', + ], + })); + + newRole.addToPolicy(new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:PutLogEvents', + 'logs:DeleteLogGroup', + ], + resources: [`arn:${Aws.PARTITION}:logs:${Aws.REGION}:${Aws.ACCOUNT_ID}:log-group:*:*`], + })); + + return newRole; + } + + private createRequestBodyModel(): Model { + return new Model(this.scope, `${this.baseId}-model`, { + restApi: this.router.api, + modelName: this.baseId, + description: `Request Model ${this.baseId}`, + schema: { + schema: JsonSchemaVersion.DRAFT7, + title: this.baseId, + type: JsonSchemaType.OBJECT, + properties: { + workflow_name_list: { + type: JsonSchemaType.ARRAY, + items: SCHEMA_WORKFLOW_NAME, + minItems: 1, + maxItems: 10, + }, + }, + required: [ + 'workflow_name_list', + ], + }, + contentType: 'application/json', + }); + } + + private apiLambda() { + return new PythonFunction(this.scope, `${this.baseId}-lambda`, { + entry: '../middleware_api/workflows', + architecture: Architecture.X86_64, + runtime: Runtime.PYTHON_3_10, + index: 'delete_workflows.py', + handler: 'handler', + timeout: Duration.seconds(900), + role: this.iamRole(), + memorySize: 2048, + tracing: aws_lambda.Tracing.ACTIVE, + layers: [this.layer], + environment:{ + WORKFLOWS_TABLE: this.workflowsTable.tableName, + } + }); + } + + +} diff --git a/infrastructure/src/shared/workflow.ts b/infrastructure/src/shared/workflow.ts index 3a920c5f..12947ff2 100644 --- a/infrastructure/src/shared/workflow.ts +++ b/infrastructure/src/shared/workflow.ts @@ -6,6 +6,7 @@ import {Construct} from 'constructs'; import {ResourceProvider} from './resource-provider'; import {CreateWorkflowApi} from "../api/workflows/create-workflow"; import {ListWorkflowsApi} from "../api/workflows/list-workflows"; +import {DeleteWorkflowsApi} from "../api/workflows/delete-workflows"; export interface WorkflowProps extends StackProps { routers: { [key: string]: Resource }; @@ -45,6 +46,17 @@ export class Workflow { }, ); + new DeleteWorkflowsApi( + scope, 'DeleteWorkflows', + { + workflowsTable: props.workflowsTable, + commonLayer: props.commonLayer, + multiUserTable: props.multiUserTable, + httpMethod: 'DELETE', + router: props.routers.workflows, + }, + ); + } } diff --git a/middleware_api/endpoints/create_endpoint.py b/middleware_api/endpoints/create_endpoint.py index a2e44f7c..c346057a 100644 --- a/middleware_api/endpoints/create_endpoint.py +++ b/middleware_api/endpoints/create_endpoint.py @@ -245,13 +245,13 @@ def _create_sagemaker_model(name, model_data_url, endpoint_name, endpoint_id, ev 'ESD_VERSION': esd_version, 'ESD_COMMIT_ID': esd_commit_id, 'SERVICE_TYPE': event.service_type, - 'ON_DOCKER': 'true', + 'ON_SAGEMAKER': 'true', 'AWS_REGION': aws_region, 'AWS_DEFAULT_REGION': aws_region, } if event.workflow: - environment['APP_SOURCE'] = event.workflow.s3_location + environment['WORKFLOW_NAME'] = event.workflow.name environment['APP_CWD'] = '/home/ubuntu/ComfyUI' primary_container = { diff --git a/middleware_api/service/oas.py b/middleware_api/service/oas.py index fee5856c..0b8145cd 100644 --- a/middleware_api/service/oas.py +++ b/middleware_api/service/oas.py @@ -485,6 +485,11 @@ operations = { tags=["Workflows"], description="List Workflows with Parameters", ), + "DeleteWorkflows": APISchema( + summary="Delete Workflows", + tags=["Workflows"], + description="Delete specify Workflows", + ), } diff --git a/middleware_api/workflows/delete_workflows.py b/middleware_api/workflows/delete_workflows.py new file mode 100644 index 00000000..00a4dbdd --- /dev/null +++ b/middleware_api/workflows/delete_workflows.py @@ -0,0 +1,47 @@ +import json +import logging +import os +from dataclasses import dataclass + +import boto3 +from aws_lambda_powertools import Tracer + +from common.ddb_service.client import DynamoDbUtilsService +from common.response import no_content +from libs.utils import response_error + +tracer = Tracer() +workflows_table = os.environ.get('WORKFLOWS_TABLE') + +logger = logging.getLogger(__name__) +logger.setLevel(os.environ.get('LOG_LEVEL') or logging.ERROR) + +ddb_service = DynamoDbUtilsService(logger=logger) +esd_version = os.environ.get("ESD_VERSION") +s3_resource = boto3.resource('s3') +bucket_name = os.environ.get('S3_BUCKET_NAME') +s3_bucket = s3_resource.Bucket(bucket_name) + + +@dataclass +class DeleteWorkflowsEvent: + workflow_name_list: [str] + + +@tracer.capture_lambda_handler +def handler(raw_event, ctx): + try: + logger.info(json.dumps(raw_event)) + + event = DeleteWorkflowsEvent(**json.loads(raw_event['body'])) + + for name in event.workflow_name_list: + s3_bucket.objects.filter(Prefix=f"comfy/workflows/{name}/").delete() + ddb_service.delete_item( + table=workflows_table, + keys={'name': name}, + ) + + return no_content(message="Workflows Deleted") + except Exception as e: + return response_error(e) diff --git a/scripts/api.py b/scripts/api.py index bca65644..a18389b0 100644 --- a/scripts/api.py +++ b/scripts/api.py @@ -526,8 +526,8 @@ try: import modules.script_callbacks as script_callbacks script_callbacks.on_app_started(sagemaker_api) - on_docker = os.environ.get('ON_DOCKER', "false") - if on_docker == "true": + on_sagemaker = os.environ.get('ON_SAGEMAKER', "false") + if on_sagemaker == "true": from modules import shared shared.opts.data.update(control_net_max_models_num=10) script_callbacks.on_app_started(move_model_to_tmp) diff --git a/scripts/main.py b/scripts/main.py index a7e6ad1b..a47addc6 100644 --- a/scripts/main.py +++ b/scripts/main.py @@ -855,8 +855,8 @@ class SageMakerUI(scripts.Script): return sagemaker_inputs_components def before_process(self, p, *args): - on_docker = os.environ.get('ON_DOCKER', "false") - if on_docker == "true": + on_sagemaker = os.environ.get('ON_SAGEMAKER', "false") + if on_sagemaker == "true": return # check if endpoint is InService @@ -1055,7 +1055,7 @@ def fetch_user_data(): time.sleep(30) -if os.environ.get('ON_DOCKER', "false") != "true": +if os.environ.get('ON_SAGEMAKER', "false") != "true": from aws_extension.auth_service.simple_cloud_auth import cloud_auth_manager if cloud_auth_manager.enableAuth: cmd_opts.gradio_auth = cloud_auth_manager.create_config() diff --git a/test/test_02_api_base/test_12_workflows.py b/test/test_02_api_base/test_12_workflows.py index b6e4fbb8..7ebb26bc 100644 --- a/test/test_02_api_base/test_12_workflows.py +++ b/test/test_02_api_base/test_12_workflows.py @@ -21,26 +21,45 @@ class TestComfyWorkflowApiBase: resp = self.api.create_workflow() assert resp.status_code == 403, resp.dumps() - def test_2_list_workflows_without_key(self): - resp = self.api.list_workflows() - assert resp.status_code == 403, resp.dumps() - - def test_3_create_workflow_with_bad_key(self): + def test_2_create_workflow_with_bad_key(self): headers = {'x-api-key': "bad_key"} resp = self.api.create_workflow(headers) assert resp.status_code == 403, resp.dumps() - def test_4_list_workflows_with_bad_key(self): - headers = {'x-api-key': "bad_key"} - resp = self.api.list_workflows(headers) - assert resp.status_code == 403, resp.dumps() - - def test_5_create_workflow_with_bad_request(self): + def test_3_create_workflow_with_bad_request(self): headers = {'x-api-key': config.api_key} resp = self.api.create_workflow(headers) assert resp.status_code == 400, resp.dumps() - def test_6_list_executes_with_ok(self): + def test_4_list_workflows_without_key(self): + resp = self.api.list_workflows() + assert resp.status_code == 403, resp.dumps() + + def test_5_list_workflows_with_bad_key(self): + headers = {'x-api-key': "bad_key"} + resp = self.api.list_workflows(headers) + assert resp.status_code == 403, resp.dumps() + + def test_6_list_workflows_with_ok(self): headers = {'x-api-key': config.api_key} resp = self.api.list_workflows(headers) assert resp.status_code == 200, resp.dumps() + + def test_7_delete_workflows_without_key(self): + resp = self.api.delete_workflows() + assert resp.status_code == 403, resp.dumps() + + def test_8_delete_workflows_with_bad_key(self): + headers = {'x-api-key': "bad_key"} + resp = self.api.delete_workflows(headers) + assert resp.status_code == 403, resp.dumps() + + def test_9_delete_workflows_with_ok(self): + headers = {'x-api-key': config.api_key} + data = { + "workflow_name_list": [ + "workflow_name" + ], + } + resp = self.api.delete_workflows(headers=headers, data=data) + assert resp.status_code == 204, resp.dumps() diff --git a/test/utils/api.py b/test/utils/api.py index f98baa6e..32b6bd36 100644 --- a/test/utils/api.py +++ b/test/utils/api.py @@ -92,6 +92,15 @@ class Api: data=data ) + def delete_workflows(self, headers=None, data=None): + return self.req( + "DELETE", + "workflows", + headers=headers, + operation_id='DeleteWorkflows', + data=data + ) + def delete_users(self, headers=None, data=None): return self.req( "DELETE", diff --git a/workshop/Dockerfile.comfy b/workshop/Dockerfile.comfy deleted file mode 100755 index f0bf8d0f..00000000 --- a/workshop/Dockerfile.comfy +++ /dev/null @@ -1,7 +0,0 @@ -ARG AWS_REGION -FROM 366590864501.dkr.ecr.$AWS_REGION.amazonaws.com/esd-inference:dev - -# TODO BYOC -RUN apt-get update -y && \ - apt-get install ffmpeg -y && \ - rm -rf /var/lib/apt/lists/* diff --git a/workshop/comfy.yaml b/workshop/comfy.yaml index 25afd67a..21ddc795 100644 --- a/workshop/comfy.yaml +++ b/workshop/comfy.yaml @@ -56,6 +56,9 @@ Parameters: - latest - dev Default: latest + WorkflowName: + Description: Bind Workflow Name + Type: String Mappings: RegionToAmiId: @@ -218,6 +221,7 @@ Resources: echo "export AWS_REGION=${AWS::Region}" >> /etc/environment echo "export PROCESS_NUMBER=${ProcessNumber}" >> /etc/environment echo "export ESD_VERSION=${EsdVersion}" >> /etc/environment + echo "export WORKFLOW_NAME=${WorkflowName}" >> /etc/environment source /etc/environment @@ -243,7 +247,7 @@ Resources: StartLimitIntervalSec=0 [Service] - WorkingDirectory=/root/stable-diffusion-aws-extension/workshop/ + WorkingDirectory=/root/stable-diffusion-aws-extension/ ExecStart=bash comfy_start.sh Type=simple Restart=always