├── tests
├── __init__.py
├── conftest.py
└── test_handler.py
├── .github
├── FUNDING.yml
└── workflows
│ └── build-docker-image.yml
├── examples
├── .env.example
├── custom.py
├── txt2img.py
└── util.py
├── requirements.txt
├── docs
├── api
│ └── webhook.md
├── deploying.md
├── examples
│ ├── runpod.md
│ └── local.md
├── building.md
├── testing.md
└── installing.md
├── pytest.ini
├── schemas
└── input.py
├── docker-bake.hcl
├── .coveragerc
├── start.sh
├── workflows
├── txt2img.json
└── img2img.json
├── Dockerfile
├── scripts
└── install.sh
├── .gitignore
├── api_example.py
├── README.md
├── Runpod_ComfyUI_Worker.postman_collection.json
├── handler.py
└── LICENSE
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | # Unit tests for runpod-worker-comfyui
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: ashleykleynhans
4 | buy_me_a_coffee: ashleyk
5 |
--------------------------------------------------------------------------------
/examples/.env.example:
--------------------------------------------------------------------------------
1 | RUNPOD_API_KEY=INSERT_YOUR_RUNPOD_API_KEY_HERE
2 | RUNPOD_ENDPOINT_ID=INSERT_YOUR_RUNPOD_ENDPOINT_ID_HERE
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Pillow
2 | requests
3 | python-dotenv
4 | runpod==1.7.10
5 |
6 | # Testing
7 | pytest
8 | pytest-cov
9 | pytest-mock
10 | coverage[toml]
11 |
--------------------------------------------------------------------------------
/docs/api/webhook.md:
--------------------------------------------------------------------------------
1 | # Using a Webhook
2 |
3 | You can add a `webhook` field to your payload that
4 | contains the URI for your webhook callbacks, and then the
5 | Serverless Endpoint will POST the response JSON to that
6 | URI.
7 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | testpaths = tests
3 | python_files = test_*.py
4 | python_classes = Test*
5 | python_functions = test_*
6 | addopts = -v --tb=short --strict-markers --cov=. --cov-report=term-missing
7 | markers =
8 | slow: marks tests as slow (deselect with '-m "not slow"')
9 | integration: marks tests as integration tests
10 |
--------------------------------------------------------------------------------
/schemas/input.py:
--------------------------------------------------------------------------------
1 | INPUT_SCHEMA = {
2 | 'workflow': {
3 | 'type': str,
4 | 'required': False,
5 | 'default': 'txt2img',
6 | 'constraints': lambda workflow: workflow in [
7 | 'default',
8 | 'txt2img',
9 | 'custom'
10 | ]
11 | },
12 | 'payload': {
13 | 'type': dict,
14 | 'required': True
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/docker-bake.hcl:
--------------------------------------------------------------------------------
1 | variable "REGISTRY" {
2 | default = "docker.io"
3 | }
4 |
5 | variable "REGISTRY_USER" {
6 | default = "ashleykza"
7 | }
8 |
9 | variable "APP" {
10 | default = "runpod-worker-comfyui"
11 | }
12 |
13 | variable "RELEASE" {
14 | default = "3.7.0"
15 | }
16 |
17 | target "default" {
18 | dockerfile = "Dockerfile"
19 | tags = ["${REGISTRY}/${REGISTRY_USER}/${APP}:${RELEASE}"]
20 | }
21 |
--------------------------------------------------------------------------------
/docs/deploying.md:
--------------------------------------------------------------------------------
1 | ## Deploying on Runpod Serverless
2 |
3 | 1. Go to [Runpod Serverless Console](https://www.runpod.io/console/serverless).
4 | 2. Create a Template (Templates > New Template).
5 | 3. Create an Endpoint (Endpoints > New Endpoint). You need to select Network Volume that you have created [here](installing.md).
6 | 4. Once the Worker is up, you can start making API calls.
7 |
8 | Read more about Runpod Serverless [here](https://trapdoor.cloud/getting-started-with-runpod-serverless/).
9 |
--------------------------------------------------------------------------------
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | source = .
3 | branch = True
4 | omit =
5 | tests/*
6 | venv/*
7 | */__pycache__/*
8 | *.pyc
9 | setup.py
10 | conftest.py
11 | api_example.py
12 |
13 | [report]
14 | exclude_lines =
15 | pragma: no cover
16 | def __repr__
17 | raise AssertionError
18 | raise NotImplementedError
19 | if __name__ == .__main__.:
20 | if TYPE_CHECKING:
21 | fail_under = 0
22 | show_missing = True
23 | skip_covered = False
24 |
25 | [html]
26 | directory = htmlcov
27 |
28 | [xml]
29 | output = coverage.xml
30 |
--------------------------------------------------------------------------------
/examples/custom.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | from util import post_request
3 | import random
4 | import json
5 |
6 |
7 | with open('comfyui-payload.json', 'r') as payload_file:
8 | payload_json = payload_file.read()
9 |
10 | if __name__ == '__main__':
11 | payload = json.loads(payload_json)
12 | if not 'input' in payload:
13 | payload = {
14 | "input": payload
15 | }
16 |
17 | #print(json.dumps(payload, indent=2, default=str))
18 |
19 | # seed = random.randrange(1, 1000000)
20 |
21 | # payload["input"]["payload"]["3"]["inputs"]["seed"] = seed
22 | # payload["input"]["payload"]["53"]["inputs"]["seed"] = seed
23 |
24 | post_request(payload)
25 |
--------------------------------------------------------------------------------
/start.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | echo "Worker Initiated"
4 |
5 | echo "Symlinking files from Network Volume"
6 | rm -rf /workspace && \
7 | ln -s /runpod-volume /workspace
8 |
9 | echo "Starting ComfyUI API"
10 | source /workspace/venv/bin/activate
11 | TCMALLOC="$(ldconfig -p | grep -Po "libtcmalloc.so.\d" | head -n 1)"
12 | export LD_PRELOAD="${TCMALLOC}"
13 | export PYTHONUNBUFFERED=true
14 | export HF_HOME="/workspace"
15 |
16 | # Set InSPyReNet background-removal model path to the model downloaded
17 | # from Google drive into the Docker container
18 | export TRANSPARENT_BACKGROUND_FILE_PATH=/root/.transparent-background
19 |
20 | cd /workspace/ComfyUI
21 | python main.py --port 3000 --temp-directory /tmp > /workspace/logs/comfyui-serverless.log 2>&1 &
22 | deactivate
23 |
24 | echo "Starting Runpod Handler"
25 | python3 -u /handler.py
26 |
--------------------------------------------------------------------------------
/examples/txt2img.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | from util import post_request
3 | import random
4 |
5 |
6 | if __name__ == '__main__':
7 | payload = {
8 | "input": {
9 | "workflow": "txt2img",
10 | "payload": {
11 | "seed": random.randrange(1, 1000000),
12 | "steps": 20,
13 | "cfg_scale": 8,
14 | "sampler_name": "euler",
15 | "ckpt_name": "deliberate_v2.safetensors",
16 | "batch_size": 1,
17 | "width": 512,
18 | "height": 512,
19 | "prompt": "masterpiece best quality man wearing a hat",
20 | "negative_prompt": "bad hands"
21 | }
22 | },
23 | "webhookV2": "https://4c23-45-222-4-0.ngrok-free.app?token=helloworld"
24 | }
25 |
26 | post_request(payload)
27 |
--------------------------------------------------------------------------------
/docs/examples/runpod.md:
--------------------------------------------------------------------------------
1 | # Runpod Examples
2 |
3 | ## Create and activate Python venv
4 |
5 | ```bash
6 | python3 -m venv venv
7 | source venv/bin/activate
8 | ```
9 |
10 | ## Install Requirements
11 |
12 | ```bash
13 | pip install -r requirements.txt
14 | ```
15 |
16 | ## Add Runpod credentials to .env
17 |
18 | Copy the `.env.example` file to '.env' as
19 | follows:
20 |
21 | ```bash
22 | cd examples
23 | cp .env.example .env
24 | ```
25 |
26 | Edit the .env file and add your Runpod API key to
27 | `RUNPOD_API_KEY` and your endpoint ID to
28 | `RUNPOD_ENDPOINT_ID`. Without these credentials,
29 | the examples will attempt to run locally instead of
30 | on Runpod.
31 |
32 | ## Run example scripts
33 |
34 | Once the venv is created and activated, the requirements
35 | installed, and the credentials added to the .env
36 | file, you can run a script, for example:
37 |
38 | ```bash
39 | python txt2img.py
40 | ```
41 |
42 | You obviously need to edit the payload within the
43 | script to achieve the desired results.
--------------------------------------------------------------------------------
/docs/examples/local.md:
--------------------------------------------------------------------------------
1 | # Local Examples
2 |
3 | ## Create and activate Python venv
4 |
5 | ```bash
6 | python3 -m venv venv
7 | source venv/bin/activate
8 | ```
9 |
10 | ## Install Requirements
11 |
12 | ```bash
13 | pip install -r requirements.txt
14 | ```
15 |
16 | ## Remove credentials from .env
17 |
18 | If you have added your `RUNPOD_API_KEY` and
19 | `RUNPOD_ENDPOINT_ID` to the `.env` file within
20 | this directory, you should first comment them
21 | out before attempting to test locally. If
22 | the .env file exists and the values are provided,
23 | the examples will attempt to send the requests to
24 | your Runpod endpoint instead of running locally.
25 |
26 | ## Run example scripts
27 |
28 | Once the venv is created and activated, the requirements
29 | installed, and the credentials removed from the .env
30 | file, you can change directory to the `examples` directory
31 | and run a script, for example:
32 |
33 | ```bash
34 | cd examples
35 | python txt2img.py
36 | ```
37 |
38 | You obviously need to edit the payload within the
39 | script to achieve the desired results.
--------------------------------------------------------------------------------
/docs/building.md:
--------------------------------------------------------------------------------
1 | ## Building the Docker image
2 |
3 | You can either build this Docker image yourself, your alternatively,
4 | you can use my pre-built image:
5 |
6 | ```
7 | ashleykza/runpod-worker-comfyui:3.7.0
8 | ```
9 |
10 | If you choose to build it yourself:
11 |
12 | 1. Sign up for a Docker hub account if you don't already have one.
13 | 2. Build the Docker image on your local machine and push to Docker hub:
14 | ```bash
15 | # Clone the repo
16 | git clone https://github.com/ashleykleynhans/runpod-worker-comfyui.git
17 | cd runpod-worker-comfyui
18 |
19 | # Build and push
20 | docker build -t dockerhub-username/runpod-worker-comfyui:1.0.0 .
21 | docker login
22 | docker push dockerhub-username/runpod-worker-comfyui:1.0.0
23 | ```
24 |
25 | If you're building on an M1 or M2 Mac, there will be an architecture
26 | mismatch because they are `arm64`, but Runpod runs on `amd64`
27 | architecture, so you will have to add the `--plaform` as follows:
28 |
29 | ```bash
30 | docker buildx build --push -t dockerhub-username/runpod-worker-comfyui:1.0.0 . --platform linux/amd64
31 | ```
32 |
--------------------------------------------------------------------------------
/docs/testing.md:
--------------------------------------------------------------------------------
1 | # Unit Tests
2 |
3 | ## Setup
4 |
5 | Create and activate a Python virtual environment:
6 |
7 | ```bash
8 | python3 -m venv venv
9 | source venv/bin/activate
10 | ```
11 |
12 | Install the requirements:
13 |
14 | ```bash
15 | pip install -r requirements.txt
16 | ```
17 |
18 | ## Running Tests
19 |
20 | Run all tests with verbose output and coverage:
21 |
22 | ```bash
23 | pytest -v
24 | ```
25 |
26 | Coverage is automatically included and will display after the test results.
27 |
28 | ## Running Specific Tests
29 |
30 | Run a specific test file:
31 |
32 | ```bash
33 | pytest tests/test_handler.py -v
34 | ```
35 |
36 | Run a specific test class:
37 |
38 | ```bash
39 | pytest tests/test_handler.py::TestGetOutputImages -v
40 | ```
41 |
42 | Run a specific test:
43 |
44 | ```bash
45 | pytest tests/test_handler.py::TestGetOutputImages::test_single_image_output -v
46 | ```
47 |
48 | ## Coverage Reports
49 |
50 | Generate an HTML coverage report:
51 |
52 | ```bash
53 | pytest --cov-report=html
54 | open htmlcov/index.html
55 | ```
56 |
57 | Generate an XML coverage report (for CI):
58 |
59 | ```bash
60 | pytest --cov-report=xml
61 | ```
62 |
63 | ## Test Markers
64 |
65 | Skip slow tests:
66 |
67 | ```bash
68 | pytest -v -m "not slow"
69 | ```
70 |
71 | Run only integration tests:
72 |
73 | ```bash
74 | pytest -v -m integration
75 | ```
76 |
--------------------------------------------------------------------------------
/workflows/txt2img.json:
--------------------------------------------------------------------------------
1 | {
2 | "3": {
3 | "inputs": {
4 | "seed": 319150848061774,
5 | "steps": 20,
6 | "cfg": 8,
7 | "sampler_name": "euler",
8 | "scheduler": "normal",
9 | "denoise": 1,
10 | "model": [
11 | "4",
12 | 0
13 | ],
14 | "positive": [
15 | "6",
16 | 0
17 | ],
18 | "negative": [
19 | "7",
20 | 0
21 | ],
22 | "latent_image": [
23 | "5",
24 | 0
25 | ]
26 | },
27 | "class_type": "KSampler"
28 | },
29 | "4": {
30 | "inputs": {
31 | "ckpt_name": "sd_xl_base_1.0.safetensors"
32 | },
33 | "class_type": "CheckpointLoaderSimple"
34 | },
35 | "5": {
36 | "inputs": {
37 | "width": 512,
38 | "height": 512,
39 | "batch_size": 1
40 | },
41 | "class_type": "EmptyLatentImage"
42 | },
43 | "6": {
44 | "inputs": {
45 | "text": "beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
46 | "clip": [
47 | "4",
48 | 1
49 | ]
50 | },
51 | "class_type": "CLIPTextEncode"
52 | },
53 | "7": {
54 | "inputs": {
55 | "text": "text, watermark",
56 | "clip": [
57 | "4",
58 | 1
59 | ]
60 | },
61 | "class_type": "CLIPTextEncode"
62 | },
63 | "8": {
64 | "inputs": {
65 | "samples": [
66 | "3",
67 | 0
68 | ],
69 | "vae": [
70 | "4",
71 | 2
72 | ]
73 | },
74 | "class_type": "VAEDecode"
75 | },
76 | "9": {
77 | "inputs": {
78 | "filename_prefix": "ComfyUI",
79 | "images": [
80 | "8",
81 | 0
82 | ]
83 | },
84 | "class_type": "SaveImage"
85 | }
86 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04
2 |
3 | ENV DEBIAN_FRONTEND=noninteractive \
4 | PIP_PREFER_BINARY=1 \
5 | PYTHONUNBUFFERED=1
6 |
7 | SHELL ["/bin/bash", "-o", "pipefail", "-c"]
8 |
9 | WORKDIR /
10 |
11 | # Upgrade apt packages and install required dependencies
12 | RUN apt update && \
13 | apt upgrade -y && \
14 | apt install -y \
15 | python3-dev \
16 | python3-pip \
17 | fonts-dejavu-core \
18 | rsync \
19 | git \
20 | jq \
21 | moreutils \
22 | aria2 \
23 | wget \
24 | curl \
25 | libglib2.0-0 \
26 | libsm6 \
27 | libgl1 \
28 | libxrender1 \
29 | libxext6 \
30 | ffmpeg \
31 | libgoogle-perftools4 \
32 | libtcmalloc-minimal4 \
33 | procps && \
34 | apt-get autoremove -y && \
35 | rm -rf /var/lib/apt/lists/* && \
36 | apt-get clean -y
37 |
38 | # Set Python
39 | RUN ln -s /usr/bin/python3.10 /usr/bin/python
40 |
41 | # Install Worker dependencies
42 | RUN pip install requests runpod==1.7.10
43 |
44 | # Install InSPyReNet transparent background model used by the transparent-background Python
45 | # module (https://github.com/plemeri/transparent-background) so it doesn't have to be
46 | # downloaded from Google Drive at run time - this increases stability and performance.
47 | RUN pip install gdown && \
48 | mkdir -p /root/.transparent-background && \
49 | gdown 13oBl5MTVcWER3YU4fSxW3ATlVfueFQPY -O /root/.transparent-background/ckpt_base.pth
50 |
51 | # Add Runpod Handler and Docker container start script
52 | COPY start.sh handler.py ./
53 |
54 | # Add validation schemas
55 | COPY schemas /schemas
56 |
57 | # Add workflows
58 | COPY workflows /workflows
59 |
60 | # Start the container
61 | RUN chmod +x /start.sh
62 | ENTRYPOINT /start.sh
63 |
--------------------------------------------------------------------------------
/workflows/img2img.json:
--------------------------------------------------------------------------------
1 | {
2 | "1": {
3 | "inputs": {
4 | "ckpt_name": "sd_xl_base_1.0.safetensors"
5 | },
6 | "class_type": "CheckpointLoaderSimple"
7 | },
8 | "2": {
9 | "inputs": {
10 | "width": 1024,
11 | "height": 1024,
12 | "crop_w": 0,
13 | "crop_h": 0,
14 | "target_width": 1024,
15 | "target_height": 1024,
16 | "text_g": "50 years old",
17 | "text_l": "50 years old",
18 | "clip": [
19 | "1",
20 | 1
21 | ]
22 | },
23 | "class_type": "CLIPTextEncodeSDXL"
24 | },
25 | "4": {
26 | "inputs": {
27 | "width": 1024,
28 | "height": 1024,
29 | "crop_w": 0,
30 | "crop_h": 0,
31 | "target_width": 1024,
32 | "target_height": 1024,
33 | "text_g": "",
34 | "text_l": "",
35 | "clip": [
36 | "1",
37 | 1
38 | ]
39 | },
40 | "class_type": "CLIPTextEncodeSDXL"
41 | },
42 | "8": {
43 | "inputs": {
44 | "samples": [
45 | "13",
46 | 0
47 | ],
48 | "vae": [
49 | "1",
50 | 2
51 | ]
52 | },
53 | "class_type": "VAEDecode"
54 | },
55 | "10": {
56 | "inputs": {
57 | "image": "input.jpg",
58 | "choose file to upload": "image"
59 | },
60 | "class_type": "LoadImage"
61 | },
62 | "11": {
63 | "inputs": {
64 | "side_length": 1024,
65 | "side": "Longest",
66 | "upscale_method": "nearest-exact",
67 | "crop": "disabled",
68 | "image": [
69 | "10",
70 | 0
71 | ]
72 | },
73 | "class_type": "Image scale to side"
74 | },
75 | "12": {
76 | "inputs": {
77 | "pixels": [
78 | "11",
79 | 0
80 | ],
81 | "vae": [
82 | "1",
83 | 2
84 | ]
85 | },
86 | "class_type": "VAEEncode"
87 | },
88 | "13": {
89 | "inputs": {
90 | "seed": 808132588416725,
91 | "steps": 20,
92 | "cfg": 8,
93 | "sampler_name": "euler",
94 | "scheduler": "normal",
95 | "denoise": 0.45,
96 | "model": [
97 | "1",
98 | 0
99 | ],
100 | "positive": [
101 | "2",
102 | 0
103 | ],
104 | "negative": [
105 | "4",
106 | 0
107 | ],
108 | "latent_image": [
109 | "12",
110 | 0
111 | ]
112 | },
113 | "class_type": "KSampler"
114 | },
115 | "17": {
116 | "inputs": {
117 | "filename_prefix": "ComfyUI",
118 | "images": [
119 | "8",
120 | 0
121 | ]
122 | },
123 | "class_type": "SaveImage"
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/scripts/install.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | echo "Deleting ComfyUI"
3 | rm -rf /workspace/ComfyUI
4 |
5 | echo "Deleting venv"
6 | rm -rf /workspace/venv
7 |
8 | echo "Cloning ComfyUI repo to /workspace"
9 | cd /workspace
10 | git clone --depth=1 https://github.com/comfyanonymous/ComfyUI.git
11 |
12 | echo "Installing Ubuntu updates"
13 | apt update
14 | apt -y upgrade
15 |
16 | echo "Creating and activating venv"
17 | cd ComfyUI
18 | python -m venv /workspace/venv
19 | source /workspace/venv/bin/activate
20 |
21 | echo "Installing Torch"
22 | pip3 install --no-cache-dir torch==2.6.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124
23 |
24 | echo "Installing xformers"
25 | pip3 install --no-cache-dir xformers==0.0.29.post3 --index-url https://download.pytorch.org/whl/cu124
26 |
27 | echo "Installing ComfyUI"
28 | pip3 install -r requirements.txt
29 |
30 | echo "Installing ComfyUI Manager"
31 | git clone https://github.com/ltdrdata/ComfyUI-Manager.git custom_nodes/ComfyUI-Manager
32 | cd custom_nodes/ComfyUI-Manager
33 | pip3 install -r requirements.txt
34 |
35 | echo "Installing Runpod Serverless dependencies"
36 | pip3 install huggingface_hub runpod
37 |
38 | echo "Downloading SD 1.5 base model"
39 | cd /workspace/ComfyUI/models/checkpoints
40 | wget https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned.safetensors
41 |
42 | echo "Downloading Deliberate v2 model"
43 | wget -O deliberate_v2.safetensors https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate_v2.safetensors
44 |
45 | echo "Downloading SDXL base model"
46 | wget https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors
47 |
48 | echo "Downloading SDXL Refiner"
49 | wget https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors
50 |
51 | echo "Downloading SD 1.5 VAE"
52 | cd /workspace/ComfyUI/models/vae
53 | wget https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors
54 |
55 | echo "Downloading SDXL VAE"
56 | wget https://huggingface.co/madebyollin/sdxl-vae-fp16-fix/resolve/main/sdxl_vae.safetensors
57 |
58 | echo "Downloading SD 1.5 ControlNet models"
59 | cd /workspace/ComfyUI/models/controlnet
60 | wget https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11p_sd15_openpose.pth
61 | wget https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11p_sd15_canny.pth
62 | wget https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11f1p_sd15_depth.pth
63 | wget https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11p_sd15_inpaint.pth
64 | wget https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11p_sd15_lineart.pth
65 | wget https://huggingface.co/ioclab/ioc-controlnet/resolve/main/models/control_v1p_sd15_brightness.safetensors
66 | wget https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11f1e_sd15_tile.pth
67 |
68 | echo "Downloading SDXL ControlNet models"
69 | wget https://huggingface.co/lllyasviel/sd_control_collection/resolve/main/diffusers_xl_canny_full.safetensors
70 |
71 | echo "Downloading Upscalers"
72 | cd /workspace/ComfyUI/models/upscale_models
73 | wget https://huggingface.co/ashleykleynhans/upscalers/resolve/main/4x-UltraSharp.pth
74 | wget https://huggingface.co/ashleykleynhans/upscalers/resolve/main/lollypop.pth
75 |
76 | echo "Creating log directory"
77 | mkdir -p /workspace/logs
78 |
--------------------------------------------------------------------------------
/docs/installing.md:
--------------------------------------------------------------------------------
1 | # Install ComfyUI on your Network Volume
2 |
3 | 1. [Create a Runpod Account](https://runpod.io?ref=2xxro4sy).
4 | 2. Create a [Runpod Network Volume](https://www.runpod.io/console/user/storage).
5 | 3. Attach the Network Volume to a Secure Cloud [GPU pod](https://www.runpod.io/console/gpu-secure-cloud).
6 | 4. Select the Runpod Pytorch 2 template.
7 | 5. Deploy the GPU Cloud pod.
8 | 6. Once the pod is up, open a Terminal and install the required
9 | dependencies. This can either be done by using the installation
10 | script, or manually.
11 |
12 | ## Automatic Installation Script
13 |
14 | You can run this automatic installation script which will
15 | automatically install all of the dependencies that get installed
16 | manually below, and then you don't need to follow any of the
17 | manual instructions.
18 |
19 | ```bash
20 | wget https://raw.githubusercontent.com/ashleykleynhans/runpod-worker-comfyui/main/scripts/install.sh
21 | chmod +x install.sh
22 | ./install.sh
23 | ```
24 |
25 | ## Manual Installation
26 |
27 | You only need to complete the steps below if you did not run the
28 | automatic installation script above.
29 |
30 | 1. Install the ComfyUI:
31 | ```bash
32 | # Clone the repo
33 | cd /workspace
34 | git clone --depth=1 https://github.com/comfyanonymous/ComfyUI.git
35 |
36 | # Upgrade Python
37 | apt update
38 | apt -y upgrade
39 |
40 | # Ensure Python version is 3.10.12
41 | python3 -V
42 |
43 | # Create and activate venv
44 | cd ComfyUI
45 | python -m venv /workspace/venv
46 | source /workspace/venv/bin/activate
47 |
48 | # Install Torch and xformers
49 | pip3 install --no-cache-dir torch==2.6.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124
50 | pip3 install --no-cache-dir xformers==0.0.29.post3 --index-url https://download.pytorch.org/whl/cu124
51 |
52 | # Install ComfyUI
53 | pip3 install -r requirements.txt
54 |
55 | # Installing ComfyUI Manager
56 | git clone https://github.com/ltdrdata/ComfyUI-Manager.git custom_nodes/ComfyUI-Manager
57 | cd custom_nodes/ComfyUI-Manager
58 | pip3 install -r requirements.txt
59 | ```
60 | 2. Install the Serverless dependencies:
61 | ```bash
62 | pip3 install huggingface_hub runpod
63 | ```
64 | 3. Download some checkpoints:
65 | ```bash
66 | cd /workspace/ComfyUI/models/checkpoints
67 | wget https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned.safetensors
68 | wget https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors
69 | wget https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors
70 | wget -O deliberate_v2.safetensors https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate_v2.safetensors
71 | ```
72 | 4. Download VAEs for SD 1.5 and SDXL:
73 | ```bash
74 | cd /workspace/ComfyUI/models/vae
75 | wget https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors
76 | wget https://huggingface.co/madebyollin/sdxl-vae-fp16-fix/resolve/main/sdxl_vae.safetensors
77 | ```
78 | 5. Download ControlNet models, for example `canny` for SD 1.5 as well as SDXL:
79 | ```bash
80 | cd /workspace/ComfyUI/models/controlnet
81 | wget https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11p_sd15_canny.pth
82 | wget https://huggingface.co/lllyasviel/sd_control_collection/resolve/main/diffusers_xl_canny_full.safetensors
83 | ```
84 | 6. Create logs directory:
85 | ```bash
86 | mkdir -p /workspace/logs
87 | ```
88 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | import sys
3 | import os
4 | from unittest.mock import MagicMock, patch
5 |
6 | # Add parent directory to path for imports
7 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8 |
9 |
10 | @pytest.fixture
11 | def mock_runpod_logger():
12 | """Mock Runpod logger."""
13 | with patch('handler.RunPodLogger') as mock_logger:
14 | mock_instance = MagicMock()
15 | mock_logger.return_value = mock_instance
16 | yield mock_instance
17 |
18 |
19 | @pytest.fixture
20 | def sample_event():
21 | """Sample Runpod event for testing."""
22 | return {
23 | 'id': 'test-job-123',
24 | 'input': {
25 | 'workflow': 'custom',
26 | 'payload': {
27 | '3': {
28 | 'class_type': 'KSampler',
29 | 'inputs': {
30 | 'seed': 12345,
31 | 'steps': 20,
32 | 'cfg': 7.5,
33 | 'sampler_name': 'euler'
34 | }
35 | },
36 | '9': {
37 | 'class_type': 'SaveImage',
38 | 'inputs': {
39 | 'filename_prefix': 'test'
40 | }
41 | }
42 | }
43 | }
44 | }
45 |
46 |
47 | @pytest.fixture
48 | def sample_txt2img_event():
49 | """Sample txt2img event for testing."""
50 | return {
51 | 'id': 'test-job-456',
52 | 'input': {
53 | 'workflow': 'txt2img',
54 | 'payload': {
55 | 'seed': 12345,
56 | 'steps': 20,
57 | 'cfg_scale': 7.5,
58 | 'sampler_name': 'euler',
59 | 'ckpt_name': 'model.safetensors',
60 | 'batch_size': 1,
61 | 'width': 512,
62 | 'height': 512,
63 | 'prompt': 'a beautiful landscape',
64 | 'negative_prompt': 'ugly, blurry'
65 | }
66 | }
67 | }
68 |
69 |
70 | @pytest.fixture
71 | def mock_comfyui_success_response():
72 | """Mock successful ComfyUI queue response."""
73 | return {
74 | 'prompt_id': 'test-prompt-123'
75 | }
76 |
77 |
78 | @pytest.fixture
79 | def mock_comfyui_history_success():
80 | """Mock successful ComfyUI history response."""
81 | return {
82 | 'test-prompt-123': {
83 | 'status': {
84 | 'status_str': 'success',
85 | 'completed': True,
86 | 'messages': []
87 | },
88 | 'outputs': {
89 | '9': {
90 | 'images': [
91 | {
92 | 'filename': 'test_00001_.png',
93 | 'type': 'output'
94 | }
95 | ]
96 | }
97 | }
98 | }
99 | }
100 |
101 |
102 | @pytest.fixture
103 | def mock_comfyui_history_error():
104 | """Mock failed ComfyUI history response."""
105 | return {
106 | 'test-prompt-123': {
107 | 'status': {
108 | 'status_str': 'error',
109 | 'completed': False,
110 | 'messages': [
111 | ['execution_error', {
112 | 'node_type': 'TestNode',
113 | 'exception_message': 'Test error message'
114 | }]
115 | ]
116 | },
117 | 'outputs': {}
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | .idea/
161 | .DS_Store
162 | *.jpeg
163 | *.png
164 |
--------------------------------------------------------------------------------
/api_example.py:
--------------------------------------------------------------------------------
1 | import json
2 | import time
3 | import requests
4 | import random
5 |
6 | """
7 | This is the ComfyUI api prompt format.
8 |
9 | If you want it for a specific workflow you can "enable dev mode options"
10 | in the settings of the UI (gear beside the "Queue Size: ") this will enable
11 | a button on the UI to save workflows in api format.
12 |
13 | keep in mind ComfyUI is pre alpha software so this format will change a bit.
14 |
15 | this is the one for the default workflow
16 | """
17 |
18 | BASE_URI = "http://example.com"
19 | FILENAME_PREFIX = "RUNPOD"
20 |
21 | prompt_text = """
22 | {{
23 | "3": {{
24 | "class_type": "KSampler",
25 | "inputs": {{
26 | "cfg": 8,
27 | "denoise": 1,
28 | "latent_image": [
29 | "5",
30 | 0
31 | ],
32 | "model": [
33 | "4",
34 | 0
35 | ],
36 | "negative": [
37 | "7",
38 | 0
39 | ],
40 | "positive": [
41 | "6",
42 | 0
43 | ],
44 | "sampler_name": "euler",
45 | "scheduler": "normal",
46 | "seed": 8566257,
47 | "steps": 20
48 | }}
49 | }},
50 | "4": {{
51 | "class_type": "CheckpointLoaderSimple",
52 | "inputs": {{
53 | "ckpt_name": "v1-5-pruned.safetensors"
54 | }}
55 | }},
56 | "5": {{
57 | "class_type": "EmptyLatentImage",
58 | "inputs": {{
59 | "batch_size": 1,
60 | "height": 512,
61 | "width": 512
62 | }}
63 | }},
64 | "6": {{
65 | "class_type": "CLIPTextEncode",
66 | "inputs": {{
67 | "clip": [
68 | "4",
69 | 1
70 | ],
71 | "text": "masterpiece best quality girl"
72 | }}
73 | }},
74 | "7": {{
75 | "class_type": "CLIPTextEncode",
76 | "inputs": {{
77 | "clip": [
78 | "4",
79 | 1
80 | ],
81 | "text": "bad hands"
82 | }}
83 | }},
84 | "8": {{
85 | "class_type": "VAEDecode",
86 | "inputs": {{
87 | "samples": [
88 | "3",
89 | 0
90 | ],
91 | "vae": [
92 | "4",
93 | 2
94 | ]
95 | }}
96 | }},
97 | "9": {{
98 | "class_type": "SaveImage",
99 | "inputs": {{
100 | "filename_prefix": "{FILENAME_PREFIX}",
101 | "images": [
102 | "8",
103 | 0
104 | ]
105 | }}
106 | }}
107 | }}
108 | """.format(FILENAME_PREFIX=FILENAME_PREFIX)
109 |
110 |
111 | def queue_prompt(prompt):
112 | return requests.post(
113 | f"{BASE_URI}/prompt",
114 | json={
115 | "prompt": prompt
116 | }
117 | )
118 |
119 |
120 | if __name__ == "__main__":
121 | prompt = json.loads(prompt_text)
122 | # set the text prompt for our positive CLIPTextEncode
123 | prompt["6"]["inputs"]["text"] = "masterpiece best quality man wearing a hat"
124 |
125 | # set the seed for our KSampler node
126 | prompt["3"]["inputs"]["seed"] = random.randrange(1, 1000000)
127 |
128 | print('Queuing prompt')
129 | queue_response = queue_prompt(prompt)
130 | resp_json = queue_response.json()
131 |
132 | if queue_response.status_code == 200:
133 | prompt_id = resp_json['prompt_id']
134 | print(f'Prompt queued successfully: {prompt_id}')
135 |
136 | while True:
137 | print(f'Getting status of prompt: {prompt_id}')
138 |
139 | r = requests.get(
140 | f"{BASE_URI}/history/{prompt_id}"
141 | )
142 |
143 | resp_json = r.json()
144 |
145 | if r.status_code == 200 and len(resp_json):
146 | break
147 |
148 | time.sleep(1)
149 |
150 | print(r.status_code)
151 |
152 | print(json.dumps(resp_json, indent=4, default=str))
153 | else:
154 | print(f'ERROR: HTTP: {queue_response.status_code}')
155 | print(json.dumps(resp_json, indent=4, default=str))
156 |
--------------------------------------------------------------------------------
/examples/util.py:
--------------------------------------------------------------------------------
1 | import io
2 | import time
3 | import json
4 | import uuid
5 | import base64
6 | import requests
7 | from PIL import Image
8 | from dotenv import dotenv_values
9 |
10 | OUTPUT_FORMAT = 'JPEG'
11 | STATUS_IN_QUEUE = 'IN_QUEUE'
12 | STATUS_IN_PROGRESS = 'IN_PROGRESS'
13 | STATUS_FAILED = 'FAILED'
14 | STATUS_CANCELLED = 'CANCELLED'
15 | STATUS_COMPLETED = 'COMPLETED'
16 | STATUS_TIMED_OUT = 'TIMED_OUT'
17 |
18 |
19 | class Timer:
20 | def __init__(self):
21 | self.start = time.time()
22 |
23 | def restart(self):
24 | self.start = time.time()
25 |
26 | def get_elapsed_time(self):
27 | end = time.time()
28 | return round(end - self.start, 1)
29 |
30 |
31 | def encode_image_to_base64(image_path):
32 | with open(image_path, 'rb') as image_file:
33 | return str(base64.b64encode(image_file.read()).decode('utf-8'))
34 |
35 |
36 | def save_result_images(resp_json):
37 | for output_image in resp_json['output']['images']:
38 | img = Image.open(io.BytesIO(base64.b64decode(output_image)))
39 | file_extension = 'jpeg' if OUTPUT_FORMAT == 'JPEG' else 'png'
40 | output_file = f'{uuid.uuid4()}.{file_extension}'
41 |
42 | with open(output_file, 'wb') as f:
43 | print(f'Saving image: {output_file}')
44 | img.save(f, format=OUTPUT_FORMAT)
45 |
46 |
47 | def handle_response(resp_json, timer):
48 | if resp_json['output'] is not None and 'images' in resp_json['output']:
49 | save_result_images(resp_json)
50 | else:
51 | print(json.dumps(resp_json, indent=4, default=str))
52 |
53 | total_time = timer.get_elapsed_time()
54 | print(f'Total time taken for Runpod Serverless API call {total_time} seconds')
55 |
56 |
57 | def post_request(payload):
58 | timer = Timer()
59 | env = dotenv_values('.env')
60 | runpod_api_key = env.get('RUNPOD_API_KEY', None)
61 | runpod_endpoint_id = env.get('RUNPOD_ENDPOINT_ID', None)
62 |
63 | if runpod_api_key is not None and runpod_endpoint_id is not None:
64 | base_url = f'https://api.runpod.ai/v2/{runpod_endpoint_id}'
65 | else:
66 | base_url = f'http://127.0.0.1:8000'
67 |
68 | r = requests.post(
69 | f'{base_url}/runsync',
70 | headers={
71 | 'Authorization': f'Bearer {runpod_api_key}'
72 | },
73 | json=payload
74 | )
75 |
76 | print(f'Status code: {r.status_code}')
77 |
78 | if r.status_code == 200:
79 | resp_json = r.json()
80 |
81 | if 'output' in resp_json:
82 | handle_response(resp_json, timer)
83 | else:
84 | job_status = resp_json.get('status', STATUS_FAILED)
85 | print(f'Job status: {job_status}')
86 |
87 | if job_status == STATUS_IN_QUEUE or job_status == STATUS_IN_PROGRESS:
88 | request_id = resp_json['id']
89 | request_in_queue = True
90 |
91 | while request_in_queue:
92 | r = requests.get(
93 | f'{base_url}/status/{request_id}',
94 | headers={
95 | 'Authorization': f'Bearer {runpod_api_key}'
96 | },
97 | )
98 |
99 | print(f'Status code from Runpod status endpoint: {r.status_code}')
100 |
101 | if r.status_code == 200:
102 | resp_json = r.json()
103 | job_status = resp_json.get('status', STATUS_FAILED)
104 |
105 | if job_status == STATUS_IN_QUEUE or job_status == STATUS_IN_PROGRESS:
106 | print(f'Runpod request {request_id} is {job_status}, sleeping for 5 seconds...')
107 | time.sleep(5)
108 | elif job_status == STATUS_FAILED:
109 | request_in_queue = False
110 | print(f'Runpod request {request_id} failed')
111 | print(json.dumps(resp_json, indent=4, default=str))
112 | elif job_status == STATUS_COMPLETED:
113 | request_in_queue = False
114 | print(f'Runpod request {request_id} completed')
115 | handle_response(resp_json, timer)
116 | elif job_status == STATUS_TIMED_OUT:
117 | request_in_queue = False
118 | print(f'ERROR: Runpod request {request_id} timed out')
119 | else:
120 | request_in_queue = False
121 | print(f'ERROR: Invalid status response from Runpod status endpoint')
122 | print(json.dumps(resp_json, indent=4, default=str))
123 | elif job_status == STATUS_COMPLETED \
124 | and 'output' in resp_json \
125 | and 'status' in resp_json['output'] \
126 | and resp_json['output']['status'] == 'error':
127 | print(f'ERROR: {resp_json["output"]["message"]}')
128 | elif job_status == STATUS_FAILED:
129 | print('ERROR: Job FAILED!')
130 |
131 | try:
132 | error = json.loads(resp_json['error'])
133 | print(error['error_type'])
134 | print(error['error_message'])
135 | print(error['error_traceback'])
136 | except Exception as e:
137 | print(json.dumps(resp_json, indent=4, default=str))
138 | else:
139 | print(json.dumps(resp_json, indent=4, default=str))
140 | else:
141 | print(f'ERROR: {r.content}')
142 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # ComfyUI | Stable Diffusion | Runpod Serverless Worker
4 |
5 | This is the source code for a [Runpod](https://runpod.io?ref=2xxro4sy)
6 | Serverless worker that uses the [ComfyUI API](
7 | https://github.com/comfyanonymous/ComfyUI) for inference.
8 |
9 | 
10 | 
11 |
12 |
13 |
14 | ## Model
15 |
16 | The model(s) for inference will be loaded from a Runpod
17 | Network Volume.
18 |
19 | ## Examples
20 |
21 | 1. [Local Examples](docs/examples/local.md)
22 | 2. [Runpod Examples](docs/examples/runpod.md)
23 |
24 | ## Unit Tests
25 |
26 | See [Unit Tests](docs/testing.md) for instructions on running the test suite.
27 |
28 | ## Installing, Building and Deploying the Serverless Worker
29 |
30 | 1. [Install ComfyUI on your Network Volume](
31 | docs/installing.md)
32 | 2. [Building the Docker image](docs/building.md)
33 | 3. [Deploying on Runpod Serverless](docs/deploying.md)
34 |
35 | ## Runpod API Endpoint
36 |
37 | You can send requests to your Runpod API Endpoint using the `/run`
38 | or `/runsync` endpoints.
39 |
40 | Requests sent to the `/run` endpoint will be handled asynchronously,
41 | and are non-blocking operations. Your first response status will always
42 | be `IN_QUEUE`. You need to send subsequent requests to the `/status`
43 | endpoint to get further status updates, and eventually the `COMPLETED`
44 | status will be returned if your request is successful.
45 |
46 | Requests sent to the `/runsync` endpoint will be handled synchronously
47 | and are blocking operations. If they are processed by a worker within
48 | 90 seconds, the result will be returned in the response, but if
49 | the processing time exceeds 90 seconds, you will need to handle the
50 | response and request status updates from the `/status` endpoint until
51 | you receive the `COMPLETED` status which indicates that your request
52 | was successful.
53 |
54 | ### Optional Webhook Callbacks
55 |
56 | You can optionally [Enable a Webhook](docs/api/webhook.md).
57 |
58 | ### Endpoint Status Codes
59 |
60 | | Status | Description |
61 | |-------------|---------------------------------------------------------------------------------------------------------------------------------|
62 | | IN_QUEUE | Request is in the queue waiting to be picked up by a worker. You can call the `/status` endpoint to check for status updates. |
63 | | IN_PROGRESS | Request is currently being processed by a worker. You can call the `/status` endpoint to check for status updates. |
64 | | FAILED | The request failed, most likely due to encountering an error. |
65 | | CANCELLED | The request was cancelled. This usually happens when you call the `/cancel` endpoint to cancel the request. |
66 | | TIMED_OUT | The request timed out. This usually happens when your handler throws some kind of exception that does return a valid response. |
67 | | COMPLETED | The request completed successfully and the output is available in the `output` field of the response. |
68 |
69 | ## Serverless Handler
70 |
71 | The serverless handler (`handler.py`) is a Python script that handles
72 | the API requests to your Endpoint using the [runpod](https://github.com/runpod/runpod-python)
73 | Python library. It defines a function `handler(event)` that takes an
74 | API request (event), runs the inference using the model(s) from your
75 | Network Volume with the `input`, and returns the `output`
76 | in the JSON response.
77 |
78 | ## Acknowledgements
79 |
80 | - [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
81 | - [Generative Labs YouTube Tutorials](https://www.youtube.com/@generativelabs)
82 |
83 | ## Additional Resources
84 |
85 | - [Postman Collection for this Worker](Runpod_ComfyUI_Worker.postman_collection.json)
86 | - [Generative Labs YouTube Tutorials](https://www.youtube.com/@generativelabs)
87 | - [Getting Started With Runpod Serverless](https://trapdoor.cloud/getting-started-with-runpod-serverless/)
88 | - [Serverless | Create a Custom Basic API](https://blog.runpod.io/serverless-create-a-basic-api/)
89 |
90 | ## Community and Contributing
91 |
92 | Pull requests and issues on [GitHub](https://github.com/ashleykleynhans/runpod-worker-comfyui)
93 | are welcome. Bug fixes and new features are encouraged.
94 |
95 | ## Appreciate my work?
96 |
97 |
--------------------------------------------------------------------------------
/.github/workflows/build-docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Build and Push Docker Images
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths:
8 | - 'docker-bake.hcl'
9 | workflow_dispatch:
10 |
11 | jobs:
12 | detect-changes:
13 | runs-on: ubuntu-latest
14 | outputs:
15 | release: ${{ steps.extract.outputs.release }}
16 | app: ${{ steps.extract.outputs.app }}
17 | registry_user: ${{ steps.extract.outputs.registry_user }}
18 | should_build: ${{ steps.check.outputs.should_build }}
19 | steps:
20 | - name: Checkout code
21 | uses: actions/checkout@v4
22 | with:
23 | fetch-depth: 2
24 |
25 | - name: Extract variables from docker-bake.hcl
26 | id: extract
27 | run: |
28 | RELEASE=$(grep 'variable "RELEASE"' -A 2 docker-bake.hcl | grep 'default' | sed 's/.*"\(.*\)"/\1/')
29 | APP=$(grep 'variable "APP"' -A 2 docker-bake.hcl | grep 'default' | sed 's/.*"\(.*\)"/\1/')
30 | REGISTRY_USER=$(grep 'variable "REGISTRY_USER"' -A 2 docker-bake.hcl | grep 'default' | sed 's/.*"\(.*\)"/\1/')
31 |
32 | echo "release=${RELEASE}" >> $GITHUB_OUTPUT
33 | echo "app=${APP}" >> $GITHUB_OUTPUT
34 | echo "registry_user=${REGISTRY_USER}" >> $GITHUB_OUTPUT
35 |
36 | echo "Current RELEASE: ${RELEASE}"
37 | echo "Current APP: ${APP}"
38 | echo "Current REGISTRY_USER: ${REGISTRY_USER}"
39 |
40 | - name: Check if RELEASE changed
41 | id: check
42 | run: |
43 | # For manual triggers, always build
44 | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
45 | echo "should_build=true" >> $GITHUB_OUTPUT
46 | echo "Manual trigger - will build"
47 | exit 0
48 | fi
49 |
50 | # Check if docker-bake.hcl was modified
51 | if ! git diff HEAD^ HEAD --name-only | grep -q "docker-bake.hcl"; then
52 | echo "should_build=false" >> $GITHUB_OUTPUT
53 | echo "docker-bake.hcl not modified - skipping build"
54 | exit 0
55 | fi
56 |
57 | # Get RELEASE value from current commit
58 | CURRENT_RELEASE=$(grep 'variable "RELEASE"' -A 2 docker-bake.hcl | grep 'default' | sed 's/.*"\(.*\)"/\1/')
59 |
60 | # Get RELEASE value from previous commit
61 | git show HEAD^:docker-bake.hcl > /tmp/docker-bake-prev.hcl
62 | PREV_RELEASE=$(grep 'variable "RELEASE"' -A 2 /tmp/docker-bake-prev.hcl | grep 'default' | sed 's/.*"\(.*\)"/\1/')
63 |
64 | echo "Previous RELEASE: ${PREV_RELEASE}"
65 | echo "Current RELEASE: ${CURRENT_RELEASE}"
66 |
67 | # Compare RELEASE values
68 | if [ "${PREV_RELEASE}" != "${CURRENT_RELEASE}" ]; then
69 | echo "should_build=true" >> $GITHUB_OUTPUT
70 | echo "RELEASE changed from '${PREV_RELEASE}' to '${CURRENT_RELEASE}' - will build"
71 | else
72 | # Also check for any other relevant changes (APP, REGISTRY_USER, or structural changes)
73 | if git diff HEAD^ HEAD docker-bake.hcl | grep -E '^\+|^\-' | grep -v '^+++\|^---' | grep -q .; then
74 | echo "should_build=true" >> $GITHUB_OUTPUT
75 | echo "Other changes detected in docker-bake.hcl - will build"
76 | else
77 | echo "should_build=false" >> $GITHUB_OUTPUT
78 | echo "No significant changes detected - skipping build"
79 | fi
80 | fi
81 |
82 | build-and-push:
83 | needs: detect-changes
84 | if: needs.detect-changes.outputs.should_build == 'true'
85 | runs-on: ubuntu-latest
86 | permissions:
87 | contents: read
88 | packages: write
89 | outputs:
90 | images: ${{ steps.list-images.outputs.images }}
91 | images_json: ${{ steps.list-images.outputs.images_json }}
92 |
93 | steps:
94 | - name: Checkout code
95 | uses: actions/checkout@v4
96 |
97 | - name: Free up disk space
98 | run: |
99 | sudo swapoff -a
100 | sudo rm -rf /swapfile /usr/share/dotnet /usr/local/lib/android /opt/ghc
101 | sudo apt clean
102 | df -h
103 |
104 | - name: Set up Docker Buildx
105 | uses: docker/setup-buildx-action@v3
106 | with:
107 | driver-opts: |
108 | image=moby/buildkit:latest
109 | network=host
110 | buildkitd-flags: --debug
111 |
112 | - name: Log in to GitHub Container Registry
113 | uses: docker/login-action@v3
114 | with:
115 | registry: ghcr.io
116 | username: ${{ github.actor }}
117 | password: ${{ secrets.GITHUB_TOKEN }}
118 |
119 | - name: Create temporary docker-bake override file
120 | run: |
121 | # Create an override file that forces ghcr.io
122 | cat > docker-bake.override.hcl << 'EOF'
123 | target "default" {
124 | inherits = ["default"]
125 | tags = ["ghcr.io/${{ github.repository_owner }}/${{ needs.detect-changes.outputs.app }}:${{ needs.detect-changes.outputs.release }}"]
126 | }
127 | EOF
128 |
129 | echo "Override file created:"
130 | cat docker-bake.override.hcl
131 |
132 | - name: Build and push Docker image
133 | run: |
134 | echo "Building and pushing Docker image to ghcr.io..."
135 |
136 | # Show what will be built
137 | echo "Configuration:"
138 | docker buildx bake -f docker-bake.hcl -f docker-bake.override.hcl --print default
139 |
140 | echo ""
141 | echo "Starting build..."
142 |
143 | # Build with both files - override will take precedence for tags
144 | docker buildx bake -f docker-bake.hcl -f docker-bake.override.hcl --push default
145 |
146 | echo "Build completed!"
147 |
148 | - name: Verify image was pushed
149 | run: |
150 | IMAGE="ghcr.io/${{ github.repository_owner }}/${{ needs.detect-changes.outputs.app }}:${{ needs.detect-changes.outputs.release }}"
151 | echo "Verifying image: $IMAGE"
152 |
153 | # Wait for registry to update
154 | sleep 10
155 |
156 | # Try to pull the image
157 | docker pull $IMAGE && echo "✅ SUCCESS: Image verified: $IMAGE" || echo "❌ FAILED: Could not pull image: $IMAGE"
158 |
159 | - name: List built image
160 | id: list-images
161 | run: |
162 | RELEASE="${{ needs.detect-changes.outputs.release }}"
163 | APP="${{ needs.detect-changes.outputs.app }}"
164 | REGISTRY="ghcr.io"
165 | REGISTRY_USER="${{ github.repository_owner }}"
166 |
167 | # Create the image name
168 | IMAGE="${REGISTRY}/${REGISTRY_USER}/${APP}:${RELEASE}"
169 |
170 | # Create JSON for easier parsing by third-party tools
171 | IMAGES_JSON=$(echo "[\"${IMAGE}\"]" | jq -c '.')
172 |
173 | # Output for GitHub Actions
174 | echo "images<> $GITHUB_OUTPUT
175 | echo "$IMAGE" >> $GITHUB_OUTPUT
176 | echo "EOF" >> $GITHUB_OUTPUT
177 |
178 | echo "images_json=${IMAGES_JSON}" >> $GITHUB_OUTPUT
179 |
180 | # Also write to a file that can be downloaded
181 | echo "$IMAGE" > built-images.txt
182 | echo "$IMAGES_JSON" > built-images.json
183 |
184 | - name: Upload image list artifacts
185 | uses: actions/upload-artifact@v4
186 | with:
187 | name: docker-images-list
188 | path: |
189 | built-images.txt
190 | built-images.json
191 | retention-days: 90
192 |
193 | - name: Display built image
194 | run: |
195 | echo "Successfully built and pushed the following image:"
196 | cat built-images.txt
197 | echo ""
198 | echo "JSON format:"
199 | cat built-images.json
200 |
201 | create-summary:
202 | needs: [detect-changes, build-and-push]
203 | if: needs.detect-changes.outputs.should_build == 'true'
204 | runs-on: ubuntu-latest
205 | steps:
206 | - name: Create job summary
207 | run: |
208 | echo "## Docker Image Built" >> $GITHUB_STEP_SUMMARY
209 | echo "" >> $GITHUB_STEP_SUMMARY
210 | echo "**Application:** ${{ needs.detect-changes.outputs.app }}" >> $GITHUB_STEP_SUMMARY
211 | echo "**Release:** ${{ needs.detect-changes.outputs.release }}" >> $GITHUB_STEP_SUMMARY
212 | echo "" >> $GITHUB_STEP_SUMMARY
213 | echo "### Image Pushed to GitHub Container Registry:" >> $GITHUB_STEP_SUMMARY
214 | echo '```' >> $GITHUB_STEP_SUMMARY
215 | echo "${{ needs.build-and-push.outputs.images }}" >> $GITHUB_STEP_SUMMARY
216 | echo '```' >> $GITHUB_STEP_SUMMARY
217 | echo "" >> $GITHUB_STEP_SUMMARY
218 | echo "### To pull this image:" >> $GITHUB_STEP_SUMMARY
219 | echo '```bash' >> $GITHUB_STEP_SUMMARY
220 | echo "docker pull ${{ needs.build-and-push.outputs.images }}" >> $GITHUB_STEP_SUMMARY
221 | echo '```' >> $GITHUB_STEP_SUMMARY
222 | echo "" >> $GITHUB_STEP_SUMMARY
223 | echo "**Note:** If the image is private, make it public at:" >> $GITHUB_STEP_SUMMARY
224 | echo "https://github.com/${{ github.repository_owner }}?tab=packages" >> $GITHUB_STEP_SUMMARY
--------------------------------------------------------------------------------
/Runpod_ComfyUI_Worker.postman_collection.json:
--------------------------------------------------------------------------------
1 | {
2 | "info": {
3 | "_postman_id": "3db3a479-64d9-4652-bcc3-dd72e3e0a612",
4 | "name": "Runpod ComfyUI Worker",
5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
6 | "_exporter_id": "446783"
7 | },
8 | "item": [
9 | {
10 | "name": "Health",
11 | "request": {
12 | "method": "GET",
13 | "header": [
14 | {
15 | "key": "Content-Type",
16 | "value": "application/json",
17 | "type": "default"
18 | },
19 | {
20 | "key": "Authorization",
21 | "value": "Bearer {{api_key}}",
22 | "type": "default"
23 | }
24 | ],
25 | "url": {
26 | "raw": "https://api.runpod.ai/v2/{{serverless_api_id}}/health",
27 | "protocol": "https",
28 | "host": [
29 | "api",
30 | "runpod",
31 | "ai"
32 | ],
33 | "path": [
34 | "v2",
35 | "{{serverless_api_id}}",
36 | "health"
37 | ]
38 | }
39 | },
40 | "response": []
41 | },
42 | {
43 | "name": "Purge Queue",
44 | "request": {
45 | "method": "POST",
46 | "header": [
47 | {
48 | "key": "Content-Type",
49 | "value": "application/json",
50 | "type": "default"
51 | },
52 | {
53 | "key": "Authorization",
54 | "value": "Bearer {{api_key}}",
55 | "type": "default"
56 | }
57 | ],
58 | "url": {
59 | "raw": "https://api.runpod.ai/v2/{{serverless_api_id}}/purge-queue",
60 | "protocol": "https",
61 | "host": [
62 | "api",
63 | "runpod",
64 | "ai"
65 | ],
66 | "path": [
67 | "v2",
68 | "{{serverless_api_id}}",
69 | "purge-queue"
70 | ]
71 | }
72 | },
73 | "response": []
74 | },
75 | {
76 | "name": "Get Status",
77 | "request": {
78 | "method": "POST",
79 | "header": [
80 | {
81 | "key": "Content-Type",
82 | "value": "application/json",
83 | "type": "default"
84 | },
85 | {
86 | "key": "Authorization",
87 | "value": "Bearer {{api_key}}",
88 | "type": "default"
89 | }
90 | ],
91 | "url": {
92 | "raw": "https://api.runpod.ai/v2/{{serverless_api_id}}/status/:id",
93 | "protocol": "https",
94 | "host": [
95 | "api",
96 | "runpod",
97 | "ai"
98 | ],
99 | "path": [
100 | "v2",
101 | "{{serverless_api_id}}",
102 | "status",
103 | ":id"
104 | ],
105 | "variable": [
106 | {
107 | "key": "id",
108 | "value": null
109 | }
110 | ]
111 | }
112 | },
113 | "response": []
114 | },
115 | {
116 | "name": "Cancel",
117 | "request": {
118 | "method": "POST",
119 | "header": [
120 | {
121 | "key": "Content-Type",
122 | "value": "application/json",
123 | "type": "default"
124 | },
125 | {
126 | "key": "Authorization",
127 | "value": "Bearer {{api_key}}",
128 | "type": "default"
129 | }
130 | ],
131 | "body": {
132 | "mode": "raw",
133 | "raw": "{}"
134 | },
135 | "url": {
136 | "raw": "https://api.runpod.ai/v2/{{serverless_api_id}}/cancel/sync-cebd79cd-96ec-46d3-9860-fc8178552261",
137 | "protocol": "https",
138 | "host": [
139 | "api",
140 | "runpod",
141 | "ai"
142 | ],
143 | "path": [
144 | "v2",
145 | "{{serverless_api_id}}",
146 | "cancel",
147 | "sync-cebd79cd-96ec-46d3-9860-fc8178552261"
148 | ]
149 | }
150 | },
151 | "response": []
152 | },
153 | {
154 | "name": "Text to Image Workflow (sync)",
155 | "event": [
156 | {
157 | "listen": "test",
158 | "script": {
159 | "exec": [
160 | "try {",
161 | " let template = `{{status}}
",
162 | "
",
163 | " `;",
164 | "",
165 | " pm.visualizer.set(template, { ",
166 | " img: pm.response.json()[\"output\"][\"images\"][0],",
167 | " status: pm.response.json()[\"status\"]",
168 | " });",
169 | "} catch(e) {",
170 | " //console.log(\"Couldn't yet load template.\")",
171 | "}"
172 | ],
173 | "type": "text/javascript"
174 | }
175 | }
176 | ],
177 | "request": {
178 | "method": "POST",
179 | "header": [
180 | {
181 | "key": "Content-Type",
182 | "value": "application/json",
183 | "type": "default"
184 | },
185 | {
186 | "key": "Authorization",
187 | "value": "Bearer {{api_key}}",
188 | "type": "default"
189 | }
190 | ],
191 | "body": {
192 | "mode": "raw",
193 | "raw": "{\n \"input\": {\n \"workflow\": \"txt2img\",\n \"payload\": {\n \"seed\": 289363,\n \"steps\": 20,\n \"cfg_scale\": 8,\n \"sampler_name\": \"euler\",\n \"ckpt_name\": \"deliberate_v2.safetensors\",\n \"batch_size\": 1,\n \"width\": 512,\n \"height\": 512,\n \"prompt\": \"masterpiece best quality man wearing a hat\",\n \"negative_prompt\": \"bad hands\"\n }\n }\n}"
194 | },
195 | "url": {
196 | "raw": "https://api.runpod.ai/v2/{{serverless_api_id}}/runsync",
197 | "protocol": "https",
198 | "host": [
199 | "api",
200 | "runpod",
201 | "ai"
202 | ],
203 | "path": [
204 | "v2",
205 | "{{serverless_api_id}}",
206 | "runsync"
207 | ]
208 | }
209 | },
210 | "response": []
211 | },
212 | {
213 | "name": "Custom Workflow (sync)",
214 | "event": [
215 | {
216 | "listen": "test",
217 | "script": {
218 | "exec": [
219 | "try {",
220 | " let template = `{{status}}
",
221 | "
",
222 | " `;",
223 | "",
224 | " pm.visualizer.set(template, { ",
225 | " img: pm.response.json()[\"output\"][\"images\"][0],",
226 | " status: pm.response.json()[\"status\"]",
227 | " });",
228 | "} catch(e) {",
229 | " //console.log(\"Couldn't yet load template.\")",
230 | "}"
231 | ],
232 | "type": "text/javascript"
233 | }
234 | }
235 | ],
236 | "request": {
237 | "method": "POST",
238 | "header": [
239 | {
240 | "key": "Content-Type",
241 | "value": "application/json",
242 | "type": "default"
243 | },
244 | {
245 | "key": "Authorization",
246 | "value": "Bearer {{api_key}}",
247 | "type": "default"
248 | }
249 | ],
250 | "body": {
251 | "mode": "raw",
252 | "raw": "{\n \"input\": {\n \"workflow\": \"custom\",\n \"payload\": {\n \"3\": {\n \"class_type\": \"KSampler\",\n \"inputs\": {\n \"cfg\": 8,\n \"denoise\": 1,\n \"latent_image\": [\n \"5\",\n 0\n ],\n \"model\": [\n \"4\",\n 0\n ],\n \"negative\": [\n \"7\",\n 0\n ],\n \"positive\": [\n \"6\",\n 0\n ],\n \"sampler_name\": \"euler\",\n \"scheduler\": \"normal\",\n \"seed\": 900532,\n \"steps\": 20\n }\n },\n \"4\": {\n \"class_type\": \"CheckpointLoaderSimple\",\n \"inputs\": {\n \"ckpt_name\": \"deliberate_v2.safetensors\"\n }\n },\n \"5\": {\n \"class_type\": \"EmptyLatentImage\",\n \"inputs\": {\n \"batch_size\": 1,\n \"height\": 512,\n \"width\": 512\n }\n },\n \"6\": {\n \"class_type\": \"CLIPTextEncode\",\n \"inputs\": {\n \"clip\": [\n \"4\",\n 1\n ],\n \"text\": \"masterpiece best quality man wearing a hat\"\n }\n },\n \"7\": {\n \"class_type\": \"CLIPTextEncode\",\n \"inputs\": {\n \"clip\": [\n \"4\",\n 1\n ],\n \"text\": \"bad hands\"\n }\n },\n \"8\": {\n \"class_type\": \"VAEDecode\",\n \"inputs\": {\n \"samples\": [\n \"3\",\n 0\n ],\n \"vae\": [\n \"4\",\n 2\n ]\n }\n },\n \"9\": {\n \"class_type\": \"SaveImage\",\n \"inputs\": {\n \"filename_prefix\": \"RUNPOD\",\n \"images\": [\n \"8\",\n 0\n ]\n }\n }\n }\n }\n}"
253 | },
254 | "url": {
255 | "raw": "https://api.runpod.ai/v2/{{serverless_api_id}}/runsync",
256 | "protocol": "https",
257 | "host": [
258 | "api",
259 | "runpod",
260 | "ai"
261 | ],
262 | "path": [
263 | "v2",
264 | "{{serverless_api_id}}",
265 | "runsync"
266 | ]
267 | }
268 | },
269 | "response": []
270 | }
271 | ],
272 | "event": [
273 | {
274 | "listen": "prerequest",
275 | "script": {
276 | "type": "text/javascript",
277 | "exec": [
278 | ""
279 | ]
280 | }
281 | },
282 | {
283 | "listen": "test",
284 | "script": {
285 | "type": "text/javascript",
286 | "exec": [
287 | ""
288 | ]
289 | }
290 | }
291 | ],
292 | "variable": [
293 | {
294 | "key": "serverless_api_id",
295 | "value": "",
296 | "type": "default"
297 | },
298 | {
299 | "key": "api_key",
300 | "value": "",
301 | "type": "default"
302 | }
303 | ]
304 | }
305 |
--------------------------------------------------------------------------------
/handler.py:
--------------------------------------------------------------------------------
1 | import os
2 | import shutil
3 | import time
4 | import requests
5 | import traceback
6 | import json
7 | import base64
8 | import uuid
9 | import logging
10 | import logging.handlers
11 | import runpod
12 | from runpod.serverless.utils.rp_validator import validate
13 | from runpod.serverless.modules.rp_logger import RunPodLogger
14 | from requests.adapters import HTTPAdapter, Retry
15 | from schemas.input import INPUT_SCHEMA
16 |
17 |
18 | APP_NAME = 'runpod-worker-comfyui'
19 | BASE_URI = 'http://127.0.0.1:3000'
20 | VOLUME_MOUNT_PATH = '/runpod-volume'
21 | LOG_FILE = 'comfyui-worker.log'
22 | TIMEOUT = 600
23 | LOG_LEVEL = 'INFO'
24 | DISK_MIN_FREE_BYTES = 500 * 1024 * 1024 # 500MB in bytes
25 |
26 |
27 | # ---------------------------------------------------------------------------- #
28 | # Custom Log Handler #
29 | # ---------------------------------------------------------------------------- #
30 | class SnapLogHandler(logging.Handler):
31 | def __init__(self, app_name: str):
32 | super().__init__()
33 | self.app_name = app_name
34 | self.rp_logger = RunPodLogger()
35 | self.rp_logger.set_level(LOG_LEVEL)
36 | self.runpod_endpoint_id = os.getenv('RUNPOD_ENDPOINT_ID')
37 | self.runpod_cpu_count = os.getenv('RUNPOD_CPU_COUNT')
38 | self.runpod_pod_id = os.getenv('RUNPOD_POD_ID')
39 | self.runpod_gpu_size = os.getenv('RUNPOD_GPU_SIZE')
40 | self.runpod_mem_gb = os.getenv('RUNPOD_MEM_GB')
41 | self.runpod_gpu_count = os.getenv('RUNPOD_GPU_COUNT')
42 | self.runpod_volume_id = os.getenv('RUNPOD_VOLUME_ID')
43 | self.runpod_pod_hostname = os.getenv('RUNPOD_POD_HOSTNAME')
44 | self.runpod_debug_level = os.getenv('RUNPOD_DEBUG_LEVEL')
45 | self.runpod_dc_id = os.getenv('RUNPOD_DC_ID')
46 | self.runpod_gpu_name = os.getenv('RUNPOD_GPU_NAME')
47 | self.log_api_endpoint = os.getenv('LOG_API_ENDPOINT')
48 | self.log_api_timeout = os.getenv('LOG_API_TIMEOUT', 5)
49 | self.log_api_timeout = int(self.log_api_timeout)
50 | self.log_token = os.getenv('LOG_API_TOKEN')
51 |
52 | def emit(self, record):
53 | runpod_job_id = os.getenv('RUNPOD_JOB_ID')
54 |
55 | try:
56 | # Handle string formatting and extra arguments
57 | if hasattr(record, 'msg') and hasattr(record, 'args'):
58 | if record.args:
59 | try:
60 | # Try to format the message with args
61 | if isinstance(record.args, dict):
62 | message = record.msg % record.args if '%' in str(record.msg) else str(record.msg)
63 | else:
64 | message = str(record.msg) % record.args if '%' in str(record.msg) else str(record.msg)
65 | except (TypeError, ValueError):
66 | # If formatting fails, just use the message as-is
67 | message = str(record.msg)
68 | else:
69 | message = str(record.msg)
70 | else:
71 | message = str(record)
72 |
73 | # Only log to RunPod logger if the length of the log entry is >= 1000 characters
74 | if len(message) <= 1000:
75 | level_mapping = {
76 | logging.DEBUG: self.rp_logger.debug,
77 | logging.INFO: self.rp_logger.info,
78 | logging.WARNING: self.rp_logger.warn,
79 | logging.ERROR: self.rp_logger.error,
80 | logging.CRITICAL: self.rp_logger.error
81 | }
82 |
83 | # Wrapper to invoke RunPodLogger logging
84 | rp_logger = level_mapping.get(record.levelno, self.rp_logger.info)
85 |
86 | if runpod_job_id:
87 | rp_logger(message, runpod_job_id)
88 | else:
89 | rp_logger(message)
90 |
91 | if self.log_api_endpoint:
92 | try:
93 | headers = {'Authorization': f'Bearer {self.log_token}'}
94 |
95 | log_payload = {
96 | 'app_name': self.app_name,
97 | 'log_asctime': self.formatter.formatTime(record),
98 | 'log_levelname': record.levelname,
99 | 'log_message': message,
100 | 'runpod_endpoint_id': self.runpod_endpoint_id,
101 | 'runpod_cpu_count': self.runpod_cpu_count,
102 | 'runpod_pod_id': self.runpod_pod_id,
103 | 'runpod_gpu_size': self.runpod_gpu_size,
104 | 'runpod_mem_gb': self.runpod_mem_gb,
105 | 'runpod_gpu_count': self.runpod_gpu_count,
106 | 'runpod_volume_id': self.runpod_volume_id,
107 | 'runpod_pod_hostname': self.runpod_pod_hostname,
108 | 'runpod_debug_level': self.runpod_debug_level,
109 | 'runpod_dc_id': self.runpod_dc_id,
110 | 'runpod_gpu_name': self.runpod_gpu_name,
111 | 'runpod_job_id': runpod_job_id
112 | }
113 |
114 | response = requests.post(
115 | self.log_api_endpoint,
116 | json=log_payload,
117 | headers=headers,
118 | timeout=self.log_api_timeout
119 | )
120 |
121 | if response.status_code != 200:
122 | self.rp_logger.error(f'Failed to send log to API. Status code: {response.status_code}')
123 | except requests.Timeout:
124 | self.rp_logger.error(f'Timeout error sending log to API (timeout={self.log_api_timeout}s)')
125 | except Exception as e:
126 | self.rp_logger.error(f'Error sending log to API: {str(e)}')
127 | else:
128 | self.rp_logger.warn('LOG_API_ENDPOINT environment variable is not set, not logging to API')
129 | except Exception as e:
130 | # Add error handling for message formatting
131 | self.rp_logger.error(f'Error in log formatting: {str(e)}')
132 |
133 |
134 | # ---------------------------------------------------------------------------- #
135 | # ComfyUI Functions #
136 | # ---------------------------------------------------------------------------- #
137 | def wait_for_service(url):
138 | retries = 0
139 |
140 | while True:
141 | try:
142 | requests.get(url)
143 | return
144 | except requests.exceptions.RequestException:
145 | retries += 1
146 |
147 | # Only log every 15 retries so the logs don't get spammed
148 | if retries % 15 == 0:
149 | logging.info('Service not ready yet. Retrying...')
150 | except Exception as err:
151 | logging.error(f'Error: {err}')
152 |
153 | time.sleep(0.2)
154 |
155 |
156 | def send_get_request(endpoint):
157 | return session.get(
158 | url=f'{BASE_URI}/{endpoint}',
159 | timeout=TIMEOUT
160 | )
161 |
162 |
163 | def send_post_request(endpoint, payload):
164 | return session.post(
165 | url=f'{BASE_URI}/{endpoint}',
166 | json=payload,
167 | timeout=TIMEOUT
168 | )
169 |
170 |
171 | def get_txt2img_payload(workflow, payload):
172 | workflow["3"]["inputs"]["seed"] = payload["seed"]
173 | workflow["3"]["inputs"]["steps"] = payload["steps"]
174 | workflow["3"]["inputs"]["cfg"] = payload["cfg_scale"]
175 | workflow["3"]["inputs"]["sampler_name"] = payload["sampler_name"]
176 | workflow["4"]["inputs"]["ckpt_name"] = payload["ckpt_name"]
177 | workflow["5"]["inputs"]["batch_size"] = payload["batch_size"]
178 | workflow["5"]["inputs"]["width"] = payload["width"]
179 | workflow["5"]["inputs"]["height"] = payload["height"]
180 | workflow["6"]["inputs"]["text"] = payload["prompt"]
181 | workflow["7"]["inputs"]["text"] = payload["negative_prompt"]
182 | return workflow
183 |
184 |
185 | def get_img2img_payload(workflow, payload):
186 | workflow["13"]["inputs"]["seed"] = payload["seed"]
187 | workflow["13"]["inputs"]["steps"] = payload["steps"]
188 | workflow["13"]["inputs"]["cfg"] = payload["cfg_scale"]
189 | workflow["13"]["inputs"]["sampler_name"] = payload["sampler_name"]
190 | workflow["13"]["inputs"]["scheduler"] = payload["scheduler"]
191 | workflow["13"]["inputs"]["denoise"] = payload["denoise"]
192 | workflow["1"]["inputs"]["ckpt_name"] = payload["ckpt_name"]
193 | workflow["2"]["inputs"]["width"] = payload["width"]
194 | workflow["2"]["inputs"]["height"] = payload["height"]
195 | workflow["2"]["inputs"]["target_width"] = payload["width"]
196 | workflow["2"]["inputs"]["target_height"] = payload["height"]
197 | workflow["4"]["inputs"]["width"] = payload["width"]
198 | workflow["4"]["inputs"]["height"] = payload["height"]
199 | workflow["4"]["inputs"]["target_width"] = payload["width"]
200 | workflow["4"]["inputs"]["target_height"] = payload["height"]
201 | workflow["6"]["inputs"]["text"] = payload["prompt"]
202 | workflow["7"]["inputs"]["text"] = payload["negative_prompt"]
203 | return workflow
204 |
205 |
206 | def get_workflow_payload(workflow_name, payload):
207 | with open(f'/workflows/{workflow_name}.json', 'r') as json_file:
208 | workflow = json.load(json_file)
209 |
210 | if workflow_name == 'txt2img':
211 | workflow = get_txt2img_payload(workflow, payload)
212 |
213 | return workflow
214 |
215 |
216 | def get_output_images(output):
217 | """
218 | Get the output images
219 | """
220 | images = []
221 |
222 | for key, value in output.items():
223 | if 'images' in value and isinstance(value['images'], list):
224 | images.append(value['images'][0])
225 |
226 | return images
227 |
228 |
229 |
230 | def create_unique_filename_prefix(payload):
231 | """
232 | Create a unique filename prefix for each request to avoid a race condition where
233 | more than one request completes at the same time, which can either result in the
234 | incorrect output being returned, or the output image not being found.
235 | """
236 | for key, value in payload.items():
237 | class_type = value.get('class_type')
238 |
239 | if class_type == 'SaveImage':
240 | payload[key]['inputs']['filename_prefix'] = str(uuid.uuid4())
241 |
242 |
243 | # ---------------------------------------------------------------------------- #
244 | # Telemetry functions #
245 | # ---------------------------------------------------------------------------- #
246 | def get_container_memory_info(job_id=None):
247 | """
248 | Get memory information that's actually allocated to the container using cgroups.
249 | Returns a dictionary with memory stats in GB.
250 | Also logs the memory information directly.
251 | """
252 | try:
253 | mem_info = {}
254 |
255 | # First try to get host memory information as fallback
256 | try:
257 | with open('/proc/meminfo', 'r') as f:
258 | meminfo = f.readlines()
259 |
260 | for line in meminfo:
261 | if 'MemTotal:' in line:
262 | mem_info['total'] = int(line.split()[1]) / (1024 * 1024) # Convert from KB to GB
263 | elif 'MemAvailable:' in line:
264 | mem_info['available'] = int(line.split()[1]) / (1024 * 1024) # Convert from KB to GB
265 | elif 'MemFree:' in line:
266 | mem_info['free'] = int(line.split()[1]) / (1024 * 1024) # Convert from KB to GB
267 |
268 | # Calculate used memory (may be overridden by container-specific value below)
269 | if 'total' in mem_info and 'free' in mem_info:
270 | mem_info['used'] = mem_info['total'] - mem_info['free']
271 | except Exception as e:
272 | logging.warning(f"Failed to read host memory info: {str(e)}", job_id)
273 |
274 | # Try cgroups v2 path first (modern Docker)
275 | try:
276 | with open('/sys/fs/cgroup/memory.max', 'r') as f:
277 | max_mem = f.read().strip()
278 | if max_mem != 'max': # If set to 'max', it means unlimited
279 | mem_info['limit'] = int(max_mem) / (1024 * 1024 * 1024) # Convert B to GB
280 |
281 | with open('/sys/fs/cgroup/memory.current', 'r') as f:
282 | mem_info['used'] = int(f.read().strip()) / (1024 * 1024 * 1024) # Convert B to GB
283 |
284 | except FileNotFoundError:
285 | # Fall back to cgroups v1 paths (older Docker)
286 | try:
287 | with open('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'r') as f:
288 | mem_limit = int(f.read().strip())
289 | # If the value is very large (close to 2^64), it's effectively unlimited
290 | if mem_limit < 2**63:
291 | mem_info['limit'] = mem_limit / (1024 * 1024 * 1024) # Convert B to GB
292 |
293 | with open('/sys/fs/cgroup/memory/memory.usage_in_bytes', 'r') as f:
294 | mem_info['used'] = int(f.read().strip()) / (1024 * 1024 * 1024) # Convert B to GB
295 |
296 | except FileNotFoundError:
297 | # Try the third possible location for cgroups
298 | try:
299 | with open('/sys/fs/cgroup/memory.limit_in_bytes', 'r') as f:
300 | mem_limit = int(f.read().strip())
301 | if mem_limit < 2**63:
302 | mem_info['limit'] = mem_limit / (1024 * 1024 * 1024) # Convert B to GB
303 |
304 | with open('/sys/fs/cgroup/memory.usage_in_bytes', 'r') as f:
305 | mem_info['used'] = int(f.read().strip()) / (1024 * 1024 * 1024) # Convert B to GB
306 |
307 | except FileNotFoundError:
308 | logging.warning('Could not find cgroup memory information', job_id)
309 |
310 | # Calculate available memory if we have both limit and used
311 | if 'limit' in mem_info and 'used' in mem_info:
312 | mem_info['available'] = mem_info['limit'] - mem_info['used']
313 |
314 | # Log memory information
315 | mem_log_parts = []
316 | if 'total' in mem_info:
317 | mem_log_parts.append(f"Total={mem_info['total']:.2f}")
318 | if 'limit' in mem_info:
319 | mem_log_parts.append(f"Limit={mem_info['limit']:.2f}")
320 | if 'used' in mem_info:
321 | mem_log_parts.append(f"Used={mem_info['used']:.2f}")
322 | if 'available' in mem_info:
323 | mem_log_parts.append(f"Available={mem_info['available']:.2f}")
324 | if 'free' in mem_info:
325 | mem_log_parts.append(f"Free={mem_info['free']:.2f}")
326 |
327 | if mem_log_parts:
328 | logging.info(f"Container Memory (GB): {', '.join(mem_log_parts)}", job_id)
329 | else:
330 | logging.info('Container memory information not available', job_id)
331 |
332 | return mem_info
333 | except Exception as e:
334 | logging.error(f'Error getting container memory info: {str(e)}', job_id)
335 | return {}
336 |
337 |
338 | def get_container_cpu_info(job_id=None):
339 | """
340 | Get CPU information that's actually allocated to the container using cgroups.
341 | Returns a dictionary with CPU stats.
342 | Also logs the CPU information directly.
343 | """
344 | try:
345 | cpu_info = {}
346 |
347 | # First get the number of CPUs visible to the container
348 | try:
349 | # Count available CPUs by checking /proc/cpuinfo
350 | available_cpus = 0
351 | with open('/proc/cpuinfo', 'r') as f:
352 | for line in f:
353 | if line.startswith('processor'):
354 | available_cpus += 1
355 | if available_cpus > 0:
356 | cpu_info['available_cpus'] = available_cpus
357 | except Exception as e:
358 | logging.warning(f'Failed to get available CPUs: {str(e)}', job_id)
359 |
360 | # Try getting CPU quota and period from cgroups v2
361 | try:
362 | with open('/sys/fs/cgroup/cpu.max', 'r') as f:
363 | cpu_data = f.read().strip().split()
364 | if cpu_data[0] != 'max':
365 | cpu_quota = int(cpu_data[0])
366 | cpu_period = int(cpu_data[1])
367 | # Calculate the number of CPUs as quota/period
368 | cpu_info['allocated_cpus'] = cpu_quota / cpu_period
369 | except FileNotFoundError:
370 | # Try cgroups v1 paths
371 | try:
372 | with open('/sys/fs/cgroup/cpu/cpu.cfs_quota_us', 'r') as f:
373 | cpu_quota = int(f.read().strip())
374 | with open('/sys/fs/cgroup/cpu/cpu.cfs_period_us', 'r') as f:
375 | cpu_period = int(f.read().strip())
376 | if cpu_quota > 0: # -1 means no limit
377 | cpu_info['allocated_cpus'] = cpu_quota / cpu_period
378 | except FileNotFoundError:
379 | # Try another possible location
380 | try:
381 | with open('/sys/fs/cgroup/cpu.cfs_quota_us', 'r') as f:
382 | cpu_quota = int(f.read().strip())
383 | with open('/sys/fs/cgroup/cpu.cfs_period_us', 'r') as f:
384 | cpu_period = int(f.read().strip())
385 | if cpu_quota > 0:
386 | cpu_info['allocated_cpus'] = cpu_quota / cpu_period
387 | except FileNotFoundError:
388 | logging.warning('Could not find cgroup CPU quota information', job_id)
389 |
390 | # Get container CPU usage stats
391 | try:
392 | # Try cgroups v2 path
393 | with open('/sys/fs/cgroup/cpu.stat', 'r') as f:
394 | for line in f:
395 | if line.startswith('usage_usec'):
396 | cpu_info['usage_usec'] = int(line.split()[1])
397 | break
398 | except FileNotFoundError:
399 | # Try cgroups v1 path
400 | try:
401 | with open('/sys/fs/cgroup/cpu/cpuacct.usage', 'r') as f:
402 | cpu_info['usage_usec'] = int(f.read().strip()) / 1000 # Convert ns to μs
403 | except FileNotFoundError:
404 | try:
405 | with open('/sys/fs/cgroup/cpuacct.usage', 'r') as f:
406 | cpu_info['usage_usec'] = int(f.read().strip()) / 1000
407 | except FileNotFoundError:
408 | pass
409 |
410 | # Log CPU information
411 | cpu_log_parts = []
412 | if 'allocated_cpus' in cpu_info:
413 | cpu_log_parts.append(f"Allocated CPUs={cpu_info['allocated_cpus']:.2f}")
414 | if 'available_cpus' in cpu_info:
415 | cpu_log_parts.append(f"Available CPUs={cpu_info['available_cpus']}")
416 | if 'usage_usec' in cpu_info:
417 | cpu_log_parts.append(f"Usage={cpu_info['usage_usec']/1000000:.2f}s")
418 |
419 | if cpu_log_parts:
420 | logging.info(f"Container CPU: {', '.join(cpu_log_parts)}", job_id)
421 | else:
422 | logging.info('Container CPU allocation information not available', job_id)
423 |
424 | return cpu_info
425 | except Exception as e:
426 | logging.error(f'Error getting container CPU info: {str(e)}', job_id)
427 | return {}
428 |
429 |
430 | def get_container_disk_info(job_id=None):
431 | """
432 | Get disk space information available to the container.
433 | Returns a dictionary with disk space stats.
434 | Also logs the disk space information directly.
435 | """
436 | try:
437 | disk_info = {}
438 |
439 | # Get disk usage statistics for the root (/) mount
440 | try:
441 | total, used, free = shutil.disk_usage('/')
442 | disk_info['total_bytes'] = total
443 | disk_info['used_bytes'] = used
444 | disk_info['free_bytes'] = free
445 | disk_info['usage_percent'] = (used / total) * 100
446 | except Exception as e:
447 | if job_id:
448 | logging.warning(f'Failed to get disk usage stats: {str(e)}', job_id)
449 | else:
450 | logging.warning(f'Failed to get disk usage stats: {str(e)}', job_id)
451 |
452 | # Try to get disk quota information from cgroups v2
453 | try:
454 | with open('/sys/fs/cgroup/io.stat', 'r') as f:
455 | content = f.read().strip()
456 | if content:
457 | disk_info['io_stats_raw'] = content
458 | except FileNotFoundError:
459 | # Try cgroups v1
460 | try:
461 | with open('/sys/fs/cgroup/blkio/blkio.throttle.io_service_bytes', 'r') as f:
462 | for line in f:
463 | parts = line.strip().split()
464 | if len(parts) >= 3 and 'Total' in line:
465 | disk_info['io_bytes'] = int(parts[2])
466 | break
467 | except FileNotFoundError:
468 | try:
469 | with open('/sys/fs/cgroup/blkio.throttle.io_service_bytes', 'r') as f:
470 | for line in f:
471 | parts = line.strip().split()
472 | if len(parts) >= 3 and 'Total' in line:
473 | disk_info['io_bytes'] = int(parts[2])
474 | break
475 | except FileNotFoundError:
476 | if job_id:
477 | logging.warning('Could not find cgroup disk I/O information', job_id)
478 | else:
479 | logging.warning('Could not find cgroup disk I/O information', job_id)
480 |
481 | # Get disk inodes information (important for container environments)
482 | try:
483 | import os
484 | stat = os.statvfs('/')
485 | disk_info['total_inodes'] = stat.f_files
486 | disk_info['free_inodes'] = stat.f_ffree
487 | disk_info['used_inodes'] = stat.f_files - stat.f_ffree
488 | if stat.f_files > 0:
489 | disk_info['inodes_usage_percent'] = ((stat.f_files - stat.f_ffree) / stat.f_files) * 100
490 | except Exception as e:
491 | if job_id:
492 | logging.warning(f'Failed to get inode information: {str(e)}', job_id)
493 | else:
494 | logging.warning(f'Failed to get inode information: {str(e)}', job_id)
495 |
496 | # Log disk information
497 | disk_log_parts = []
498 | if 'total_bytes' in disk_info:
499 | disk_log_parts.append(f"Total={disk_info['total_bytes']/(1024**3):.2f}GB")
500 | if 'used_bytes' in disk_info:
501 | disk_log_parts.append(f"Used={disk_info['used_bytes']/(1024**3):.2f}GB")
502 | if 'free_bytes' in disk_info:
503 | disk_log_parts.append(f"Free={disk_info['free_bytes']/(1024**3):.2f}GB")
504 | if 'usage_percent' in disk_info:
505 | disk_log_parts.append(f"Usage={disk_info['usage_percent']:.2f}%")
506 | if 'inodes_usage_percent' in disk_info:
507 | disk_log_parts.append(f"Inodes={disk_info['inodes_usage_percent']:.2f}%")
508 | if 'io_bytes' in disk_info:
509 | disk_log_parts.append(f"I/O={disk_info['io_bytes']/(1024**2):.2f}MB")
510 |
511 | if disk_log_parts:
512 | if job_id:
513 | logging.info(f"Container Disk: {', '.join(disk_log_parts)}", job_id)
514 | else:
515 | logging.info(f"Container Disk: {', '.join(disk_log_parts)}", job_id)
516 | else:
517 | if job_id:
518 | logging.info('Container disk space information not available', job_id)
519 | else:
520 | logging.info('Container disk space information not available', job_id)
521 |
522 | return disk_info
523 | except Exception as e:
524 | if job_id:
525 | logging.error(f'Error getting container disk info: {str(e)}', job_id)
526 | else:
527 | logging.error(f'Error getting container disk info: {str(e)}', job_id)
528 | return {}
529 |
530 |
531 | # ---------------------------------------------------------------------------- #
532 | # Runpod Handler #
533 | # ---------------------------------------------------------------------------- #
534 | def handler(event):
535 | job_id = event['id']
536 | os.environ['RUNPOD_JOB_ID'] = job_id
537 |
538 | try:
539 | memory_info = get_container_memory_info(job_id)
540 | cpu_info = get_container_cpu_info(job_id)
541 | disk_info = get_container_disk_info(job_id)
542 |
543 | memory_available_gb = memory_info.get('available')
544 | disk_free_bytes = disk_info.get('free_bytes')
545 |
546 | if memory_available_gb is not None and memory_available_gb < 0.5:
547 | raise Exception(f'Insufficient available container memory: {memory_available_gb:.2f} GB available (minimum 0.5 GB required)')
548 |
549 | if disk_free_bytes is not None and disk_free_bytes < DISK_MIN_FREE_BYTES:
550 | free_gb = disk_free_bytes / (1024**3)
551 | raise Exception(f'Insufficient free container disk space: {free_gb:.2f} GB available (minimum 0.5 GB required)')
552 |
553 | validated_input = validate(event['input'], INPUT_SCHEMA)
554 |
555 | if 'errors' in validated_input:
556 | return {
557 | 'error': '\n'.join(validated_input['errors'])
558 | }
559 |
560 | payload = validated_input['validated_input']
561 | workflow_name = payload['workflow']
562 | payload = payload['payload']
563 |
564 | if workflow_name == 'default':
565 | workflow_name = 'txt2img'
566 |
567 | logging.info(f'Workflow: {workflow_name}', job_id)
568 |
569 | if workflow_name != 'custom':
570 | try:
571 | payload = get_workflow_payload(workflow_name, payload)
572 | except Exception as e:
573 | logging.error(f'Unable to load workflow payload for: {workflow_name}', job_id)
574 | raise
575 |
576 | create_unique_filename_prefix(payload)
577 | logging.debug('Queuing prompt', job_id)
578 |
579 | queue_response = send_post_request(
580 | 'prompt',
581 | {
582 | 'prompt': payload
583 | }
584 | )
585 |
586 | if queue_response.status_code == 200:
587 | resp_json = queue_response.json()
588 | prompt_id = resp_json['prompt_id']
589 | logging.info(f'Prompt queued successfully: {prompt_id}', job_id)
590 | retries = 0
591 |
592 | while True:
593 | # Only log every 15 retries so the logs don't get spammed
594 | if retries == 0 or retries % 15 == 0:
595 | logging.info(f'Getting status of prompt: {prompt_id}', job_id)
596 |
597 | r = send_get_request(f'history/{prompt_id}')
598 | resp_json = r.json()
599 |
600 | if r.status_code == 200 and len(resp_json):
601 | break
602 |
603 | time.sleep(0.2)
604 | retries += 1
605 |
606 | status = resp_json[prompt_id]['status']
607 |
608 | if status['status_str'] == 'success' and status['completed']:
609 | # Job was processed successfully
610 | outputs = resp_json[prompt_id]['outputs']
611 |
612 | if len(outputs):
613 | logging.info(f'Images generated successfully for prompt: {prompt_id}', job_id)
614 | output_images = get_output_images(outputs)
615 | images = []
616 |
617 | for output_image in output_images:
618 | filename = output_image.get('filename')
619 |
620 | if output_image['type'] == 'output':
621 | image_path = f'{VOLUME_MOUNT_PATH}/ComfyUI/output/{filename}'
622 |
623 | if os.path.exists(image_path):
624 | with open(image_path, 'rb') as image_file:
625 | image_data = base64.b64encode(image_file.read()).decode('utf-8')
626 | images.append(image_data)
627 | logging.info(f'Deleting output file: {image_path}', job_id)
628 | os.remove(image_path)
629 | elif output_image['type'] == 'temp':
630 | # First check if the temp image exists in the mounted volume
631 | image_path = f'{VOLUME_MOUNT_PATH}/ComfyUI/temp/{filename}'
632 |
633 | if os.path.exists(image_path):
634 | logging.info(f'Deleting temp file: {image_path}', job_id)
635 |
636 | try:
637 | os.remove(image_path)
638 | except Exception as e:
639 | logging.error(f'Error deleting temp file {image_path}: {e}')
640 | else:
641 | # Then check if the temp image exists in the /tmp directory
642 | # This should be where they are located as a result of the
643 | # --temp-directory /tmp command line argument in the start.sh script
644 | image_path = f'/tmp/temp/{filename}'
645 |
646 | if os.path.exists(image_path):
647 | logging.info(f'Deleting temp file: {image_path}', job_id)
648 |
649 | try:
650 | os.remove(image_path)
651 | except Exception as e:
652 | logging.error(f'Error deleting temp file {image_path}: {e}')
653 |
654 | response = {
655 | 'images': images
656 | }
657 |
658 | # Unload models and free memory after each request to prevent
659 | # "Allocation on device" errors from lazy model loading
660 | try:
661 | requests.post(
662 | f'{BASE_URI}/free',
663 | json={'unload_models': True, 'free_memory': True},
664 | timeout=30
665 | )
666 | logging.info('Models unloaded and memory freed', job_id)
667 | except Exception as e:
668 | logging.warning(f'Failed to free memory: {e}', job_id)
669 |
670 | # Refresh worker if memory is low
671 | memory_info = get_container_memory_info(job_id)
672 | memory_available_gb = memory_info.get('available')
673 |
674 | if memory_available_gb is not None and memory_available_gb < 1.0:
675 | logging.info(f'Low memory detected: {memory_available_gb:.2f} GB available, refreshing worker', job_id)
676 | response['refresh_worker'] = True
677 |
678 | return response
679 | else:
680 | raise RuntimeError(f'No output found for prompt id: {prompt_id}')
681 | else:
682 | # Job did not process successfully
683 | for message in status['messages']:
684 | key, value = message
685 |
686 | if key == 'execution_error':
687 | if 'node_type' in value and 'exception_message' in value:
688 | node_type = value['node_type']
689 | exception_message = value['exception_message']
690 | raise RuntimeError(f'{node_type}: {exception_message}')
691 | else:
692 | # Log to file instead of Runpod because the output tends to be too verbose
693 | # and gets dropped by Runpod logging
694 | error_msg = f'Job did not process successfully for prompt_id: {prompt_id}'
695 | logging.error(error_msg, job_id)
696 | logging.info(f'{job_id}: Response JSON: {resp_json}', job_id)
697 | raise RuntimeError(error_msg)
698 |
699 | else:
700 | try:
701 | queue_response_content = queue_response.json()
702 | except Exception as e:
703 | queue_response_content = str(queue_response.content)
704 |
705 | logging.error(f'HTTP Status code: {queue_response.status_code}', job_id)
706 | logging.error(queue_response_content, job_id)
707 |
708 | return {
709 | 'error': f'HTTP status code: {queue_response.status_code}',
710 | 'output': queue_response_content
711 | }
712 | except Exception as e:
713 | logging.error(f'An exception was raised: {e}', job_id)
714 |
715 | return {
716 | 'error': str(e),
717 | 'output': traceback.format_exc(),
718 | 'refresh_worker': True
719 | }
720 |
721 |
722 | def setup_logging():
723 | root_logger = logging.getLogger()
724 | root_logger.setLevel(LOG_LEVEL)
725 |
726 | # Remove all existing handlers from the root logger
727 | for handler in root_logger.handlers[:]:
728 | root_logger.removeHandler(handler)
729 |
730 | formatter = logging.Formatter('%(asctime)s : %(levelname)s : %(message)s')
731 | log_handler = SnapLogHandler(APP_NAME)
732 | log_handler.setFormatter(formatter)
733 | root_logger.addHandler(log_handler)
734 |
735 |
736 | if __name__ == '__main__':
737 | session = requests.Session()
738 | retries = Retry(total=10, backoff_factor=0.1, status_forcelist=[502, 503, 504])
739 | session.mount('http://', HTTPAdapter(max_retries=retries))
740 | setup_logging()
741 | wait_for_service(url=f'{BASE_URI}/system_stats')
742 | logging.info('ComfyUI API is ready')
743 | logging.info('Starting Runpod Serverless...')
744 | runpod.serverless.start(
745 | {
746 | 'handler': handler
747 | }
748 | )
749 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/tests/test_handler.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | import json
3 | import base64
4 | import os
5 | import logging
6 | import requests
7 | from unittest.mock import MagicMock, patch, mock_open, call
8 |
9 |
10 | class TestGetOutputImages:
11 | """Tests for get_output_images function."""
12 |
13 | def test_single_image_output(self):
14 | from handler import get_output_images
15 |
16 | output = {
17 | '9': {
18 | 'images': [
19 | {'filename': 'test_00001_.png', 'type': 'output'}
20 | ]
21 | }
22 | }
23 | result = get_output_images(output)
24 | assert len(result) == 1
25 | assert result[0]['filename'] == 'test_00001_.png'
26 |
27 | def test_multiple_image_outputs(self):
28 | from handler import get_output_images
29 |
30 | output = {
31 | '9': {
32 | 'images': [
33 | {'filename': 'test_00001_.png', 'type': 'output'}
34 | ]
35 | },
36 | '10': {
37 | 'images': [
38 | {'filename': 'test_00002_.png', 'type': 'output'}
39 | ]
40 | }
41 | }
42 | result = get_output_images(output)
43 | assert len(result) == 2
44 |
45 | def test_empty_output(self):
46 | from handler import get_output_images
47 |
48 | output = {}
49 | result = get_output_images(output)
50 | assert len(result) == 0
51 |
52 | def test_output_without_images_key(self):
53 | from handler import get_output_images
54 |
55 | output = {
56 | '9': {
57 | 'other_data': 'value'
58 | }
59 | }
60 | result = get_output_images(output)
61 | assert len(result) == 0
62 |
63 |
64 | class TestCreateUniqueFilenamePrefix:
65 | """Tests for create_unique_filename_prefix function."""
66 |
67 | def test_adds_uuid_to_save_image_node(self):
68 | from handler import create_unique_filename_prefix
69 |
70 | payload = {
71 | '9': {
72 | 'class_type': 'SaveImage',
73 | 'inputs': {
74 | 'filename_prefix': 'original'
75 | }
76 | }
77 | }
78 | create_unique_filename_prefix(payload)
79 |
80 | new_prefix = payload['9']['inputs']['filename_prefix']
81 | assert new_prefix != 'original'
82 | assert len(new_prefix) == 36
83 | assert new_prefix.count('-') == 4
84 |
85 | def test_ignores_non_save_image_nodes(self):
86 | from handler import create_unique_filename_prefix
87 |
88 | payload = {
89 | '3': {
90 | 'class_type': 'KSampler',
91 | 'inputs': {
92 | 'seed': 12345
93 | }
94 | }
95 | }
96 | original_payload = json.loads(json.dumps(payload))
97 | create_unique_filename_prefix(payload)
98 |
99 | assert payload == original_payload
100 |
101 | def test_handles_multiple_save_image_nodes(self):
102 | from handler import create_unique_filename_prefix
103 |
104 | payload = {
105 | '9': {
106 | 'class_type': 'SaveImage',
107 | 'inputs': {'filename_prefix': 'first'}
108 | },
109 | '10': {
110 | 'class_type': 'SaveImage',
111 | 'inputs': {'filename_prefix': 'second'}
112 | }
113 | }
114 | create_unique_filename_prefix(payload)
115 |
116 | prefix_9 = payload['9']['inputs']['filename_prefix']
117 | prefix_10 = payload['10']['inputs']['filename_prefix']
118 |
119 | assert len(prefix_9) == 36
120 | assert len(prefix_10) == 36
121 | assert prefix_9 != prefix_10
122 |
123 |
124 | class TestGetTxt2ImgPayload:
125 | """Tests for get_txt2img_payload function."""
126 |
127 | def test_sets_all_expected_fields(self):
128 | from handler import get_txt2img_payload
129 |
130 | workflow = {
131 | '3': {'inputs': {}},
132 | '4': {'inputs': {}},
133 | '5': {'inputs': {}},
134 | '6': {'inputs': {}},
135 | '7': {'inputs': {}}
136 | }
137 | payload = {
138 | 'seed': 12345,
139 | 'steps': 20,
140 | 'cfg_scale': 7.5,
141 | 'sampler_name': 'euler',
142 | 'ckpt_name': 'model.safetensors',
143 | 'batch_size': 1,
144 | 'width': 512,
145 | 'height': 512,
146 | 'prompt': 'test prompt',
147 | 'negative_prompt': 'ugly'
148 | }
149 |
150 | result = get_txt2img_payload(workflow, payload)
151 |
152 | assert result['3']['inputs']['seed'] == 12345
153 | assert result['3']['inputs']['steps'] == 20
154 | assert result['3']['inputs']['cfg'] == 7.5
155 | assert result['3']['inputs']['sampler_name'] == 'euler'
156 | assert result['4']['inputs']['ckpt_name'] == 'model.safetensors'
157 | assert result['5']['inputs']['batch_size'] == 1
158 | assert result['5']['inputs']['width'] == 512
159 | assert result['5']['inputs']['height'] == 512
160 | assert result['6']['inputs']['text'] == 'test prompt'
161 | assert result['7']['inputs']['text'] == 'ugly'
162 |
163 |
164 | class TestGetImg2ImgPayload:
165 | """Tests for get_img2img_payload function."""
166 |
167 | def test_sets_all_expected_fields(self):
168 | from handler import get_img2img_payload
169 |
170 | workflow = {
171 | '1': {'inputs': {}},
172 | '2': {'inputs': {}},
173 | '4': {'inputs': {}},
174 | '6': {'inputs': {}},
175 | '7': {'inputs': {}},
176 | '13': {'inputs': {}}
177 | }
178 | payload = {
179 | 'seed': 12345,
180 | 'steps': 20,
181 | 'cfg_scale': 7.5,
182 | 'sampler_name': 'euler',
183 | 'scheduler': 'normal',
184 | 'denoise': 0.75,
185 | 'ckpt_name': 'model.safetensors',
186 | 'width': 512,
187 | 'height': 512,
188 | 'prompt': 'test prompt',
189 | 'negative_prompt': 'ugly'
190 | }
191 |
192 | result = get_img2img_payload(workflow, payload)
193 |
194 | assert result['13']['inputs']['seed'] == 12345
195 | assert result['13']['inputs']['steps'] == 20
196 | assert result['13']['inputs']['cfg'] == 7.5
197 | assert result['13']['inputs']['sampler_name'] == 'euler'
198 | assert result['13']['inputs']['scheduler'] == 'normal'
199 | assert result['13']['inputs']['denoise'] == 0.75
200 | assert result['1']['inputs']['ckpt_name'] == 'model.safetensors'
201 | assert result['2']['inputs']['width'] == 512
202 | assert result['2']['inputs']['height'] == 512
203 | assert result['6']['inputs']['text'] == 'test prompt'
204 | assert result['7']['inputs']['text'] == 'ugly'
205 |
206 |
207 | class TestGetWorkflowPayload:
208 | """Tests for get_workflow_payload function."""
209 |
210 | def test_loads_txt2img_workflow(self):
211 | from handler import get_workflow_payload
212 |
213 | mock_workflow = {
214 | '3': {'inputs': {}},
215 | '4': {'inputs': {}},
216 | '5': {'inputs': {}},
217 | '6': {'inputs': {}},
218 | '7': {'inputs': {}}
219 | }
220 | payload = {
221 | 'seed': 12345,
222 | 'steps': 20,
223 | 'cfg_scale': 7.5,
224 | 'sampler_name': 'euler',
225 | 'ckpt_name': 'model.safetensors',
226 | 'batch_size': 1,
227 | 'width': 512,
228 | 'height': 512,
229 | 'prompt': 'test',
230 | 'negative_prompt': 'ugly'
231 | }
232 |
233 | with patch('builtins.open', mock_open(read_data=json.dumps(mock_workflow))):
234 | result = get_workflow_payload('txt2img', payload)
235 |
236 | assert result['3']['inputs']['seed'] == 12345
237 | assert result['6']['inputs']['text'] == 'test'
238 |
239 | def test_loads_custom_workflow_without_modification(self):
240 | from handler import get_workflow_payload
241 |
242 | mock_workflow = {'custom': 'workflow'}
243 |
244 | with patch('builtins.open', mock_open(read_data=json.dumps(mock_workflow))):
245 | result = get_workflow_payload('other', {})
246 |
247 | assert result == mock_workflow
248 |
249 |
250 | class TestInputValidation:
251 | """Tests for input schema validation."""
252 |
253 | def test_valid_custom_workflow_input(self, sample_event):
254 | from runpod.serverless.utils.rp_validator import validate
255 | from schemas.input import INPUT_SCHEMA
256 |
257 | result = validate(sample_event['input'], INPUT_SCHEMA)
258 | assert 'errors' not in result
259 | assert result['validated_input']['workflow'] == 'custom'
260 |
261 | def test_valid_txt2img_workflow_input(self):
262 | from runpod.serverless.utils.rp_validator import validate
263 | from schemas.input import INPUT_SCHEMA
264 |
265 | input_data = {
266 | 'workflow': 'txt2img',
267 | 'payload': {'prompt': 'test'}
268 | }
269 | result = validate(input_data, INPUT_SCHEMA)
270 | assert 'errors' not in result
271 |
272 | def test_missing_payload(self):
273 | from runpod.serverless.utils.rp_validator import validate
274 | from schemas.input import INPUT_SCHEMA
275 |
276 | input_data = {
277 | 'workflow': 'custom'
278 | }
279 | result = validate(input_data, INPUT_SCHEMA)
280 | assert 'errors' in result
281 |
282 | def test_default_workflow_is_txt2img(self):
283 | from runpod.serverless.utils.rp_validator import validate
284 | from schemas.input import INPUT_SCHEMA
285 |
286 | input_data = {
287 | 'payload': {'prompt': 'test'}
288 | }
289 | result = validate(input_data, INPUT_SCHEMA)
290 | assert 'errors' not in result
291 | assert result['validated_input']['workflow'] == 'txt2img'
292 |
293 |
294 | class TestSendRequests:
295 | """Tests for HTTP request functions."""
296 |
297 | def test_send_get_request_uses_correct_url(self):
298 | import handler
299 | from handler import BASE_URI, TIMEOUT
300 |
301 | mock_session = MagicMock()
302 | mock_session.get.return_value = MagicMock(status_code=200)
303 | handler.session = mock_session
304 |
305 | handler.send_get_request('test/endpoint')
306 |
307 | mock_session.get.assert_called_once_with(
308 | url=f'{BASE_URI}/test/endpoint',
309 | timeout=TIMEOUT
310 | )
311 |
312 | def test_send_post_request_uses_correct_url_and_payload(self):
313 | import handler
314 | from handler import BASE_URI, TIMEOUT
315 |
316 | mock_session = MagicMock()
317 | mock_session.post.return_value = MagicMock(status_code=200)
318 | handler.session = mock_session
319 | test_payload = {'key': 'value'}
320 |
321 | handler.send_post_request('test/endpoint', test_payload)
322 |
323 | mock_session.post.assert_called_once_with(
324 | url=f'{BASE_URI}/test/endpoint',
325 | json=test_payload,
326 | timeout=TIMEOUT
327 | )
328 |
329 |
330 | class TestWaitForService:
331 | """Tests for wait_for_service function."""
332 |
333 | @patch('handler.time.sleep')
334 | @patch('handler.requests.get')
335 | def test_returns_immediately_on_success(self, mock_get, mock_sleep):
336 | from handler import wait_for_service
337 |
338 | mock_get.return_value = MagicMock(status_code=200)
339 |
340 | wait_for_service('http://localhost:3000')
341 |
342 | mock_get.assert_called_once_with('http://localhost:3000')
343 | mock_sleep.assert_not_called()
344 |
345 | @patch('handler.logging')
346 | @patch('handler.time.sleep')
347 | @patch('handler.requests.get')
348 | def test_retries_on_request_exception(self, mock_get, mock_sleep, mock_logging):
349 | from handler import wait_for_service
350 |
351 | mock_get.side_effect = [
352 | requests.exceptions.ConnectionError(),
353 | MagicMock(status_code=200)
354 | ]
355 |
356 | wait_for_service('http://localhost:3000')
357 |
358 | assert mock_get.call_count == 2
359 | assert mock_sleep.call_count == 1
360 |
361 | @patch('handler.logging')
362 | @patch('handler.time.sleep')
363 | @patch('handler.requests.get')
364 | def test_logs_every_15_retries(self, mock_get, mock_sleep, mock_logging):
365 | from handler import wait_for_service
366 |
367 | # Fail 15 times then succeed
368 | side_effects = [requests.exceptions.ConnectionError()] * 15 + [MagicMock(status_code=200)]
369 | mock_get.side_effect = side_effects
370 |
371 | wait_for_service('http://localhost:3000')
372 |
373 | assert mock_get.call_count == 16
374 | mock_logging.info.assert_called()
375 |
376 | @patch('handler.logging')
377 | @patch('handler.time.sleep')
378 | @patch('handler.requests.get')
379 | def test_logs_on_general_exception(self, mock_get, mock_sleep, mock_logging):
380 | from handler import wait_for_service
381 |
382 | mock_get.side_effect = [
383 | Exception('Some error'),
384 | MagicMock(status_code=200)
385 | ]
386 |
387 | wait_for_service('http://localhost:3000')
388 |
389 | mock_logging.error.assert_called()
390 |
391 |
392 | class TestContainerMemoryInfo:
393 | """Tests for get_container_memory_info function."""
394 |
395 | @patch('handler.logging')
396 | def test_reads_proc_meminfo(self, mock_logging):
397 | from handler import get_container_memory_info
398 |
399 | meminfo_content = """MemTotal: 16384000 kB
400 | MemFree: 8192000 kB
401 | MemAvailable: 12288000 kB
402 | """
403 | def mock_open_func(path, *args, **kwargs):
404 | if path == '/proc/meminfo':
405 | m = mock_open(read_data=meminfo_content)()
406 | m.readlines.return_value = meminfo_content.strip().split('\n')
407 | return m
408 | raise FileNotFoundError()
409 |
410 | with patch('builtins.open', side_effect=mock_open_func):
411 | result = get_container_memory_info()
412 |
413 | assert 'total' in result
414 | assert 'free' in result
415 | assert 'available' in result
416 | assert 'used' in result
417 |
418 | @patch('handler.logging')
419 | def test_reads_cgroups_v2(self, mock_logging):
420 | from handler import get_container_memory_info
421 |
422 | def mock_open_func(path, *args, **kwargs):
423 | if path == '/proc/meminfo':
424 | raise FileNotFoundError()
425 | elif path == '/sys/fs/cgroup/memory.max':
426 | return mock_open(read_data='8589934592')()
427 | elif path == '/sys/fs/cgroup/memory.current':
428 | return mock_open(read_data='4294967296')()
429 | raise FileNotFoundError()
430 |
431 | with patch('builtins.open', side_effect=mock_open_func):
432 | result = get_container_memory_info()
433 |
434 | assert 'limit' in result
435 | assert 'used' in result
436 | assert 'available' in result
437 |
438 | @patch('handler.logging')
439 | def test_reads_cgroups_v1(self, mock_logging):
440 | from handler import get_container_memory_info
441 |
442 | def mock_open_func(path, *args, **kwargs):
443 | if path == '/proc/meminfo':
444 | raise FileNotFoundError()
445 | elif path == '/sys/fs/cgroup/memory.max':
446 | raise FileNotFoundError()
447 | elif path == '/sys/fs/cgroup/memory/memory.limit_in_bytes':
448 | return mock_open(read_data='8589934592')()
449 | elif path == '/sys/fs/cgroup/memory/memory.usage_in_bytes':
450 | return mock_open(read_data='4294967296')()
451 | raise FileNotFoundError()
452 |
453 | with patch('builtins.open', side_effect=mock_open_func):
454 | result = get_container_memory_info()
455 |
456 | assert 'limit' in result
457 | assert 'used' in result
458 |
459 | @patch('handler.logging')
460 | def test_reads_cgroups_v1_alt_path(self, mock_logging):
461 | from handler import get_container_memory_info
462 |
463 | def mock_open_func(path, *args, **kwargs):
464 | if path == '/proc/meminfo':
465 | raise FileNotFoundError()
466 | elif path == '/sys/fs/cgroup/memory.max':
467 | raise FileNotFoundError()
468 | elif path == '/sys/fs/cgroup/memory/memory.limit_in_bytes':
469 | raise FileNotFoundError()
470 | elif path == '/sys/fs/cgroup/memory.limit_in_bytes':
471 | return mock_open(read_data='8589934592')()
472 | elif path == '/sys/fs/cgroup/memory.usage_in_bytes':
473 | return mock_open(read_data='4294967296')()
474 | raise FileNotFoundError()
475 |
476 | with patch('builtins.open', side_effect=mock_open_func):
477 | result = get_container_memory_info()
478 |
479 | assert 'limit' in result
480 | assert 'used' in result
481 |
482 | @patch('handler.logging')
483 | def test_handles_unlimited_memory(self, mock_logging):
484 | from handler import get_container_memory_info
485 |
486 | def mock_open_func(path, *args, **kwargs):
487 | if path == '/proc/meminfo':
488 | raise FileNotFoundError()
489 | elif path == '/sys/fs/cgroup/memory.max':
490 | return mock_open(read_data='max')()
491 | elif path == '/sys/fs/cgroup/memory.current':
492 | return mock_open(read_data='4294967296')()
493 | raise FileNotFoundError()
494 |
495 | with patch('builtins.open', side_effect=mock_open_func):
496 | result = get_container_memory_info()
497 |
498 | assert 'limit' not in result
499 |
500 | @patch('handler.logging')
501 | def test_handles_large_limit_cgroups_v1(self, mock_logging):
502 | from handler import get_container_memory_info
503 |
504 | def mock_open_func(path, *args, **kwargs):
505 | if path == '/proc/meminfo':
506 | raise FileNotFoundError()
507 | elif path == '/sys/fs/cgroup/memory.max':
508 | raise FileNotFoundError()
509 | elif path == '/sys/fs/cgroup/memory/memory.limit_in_bytes':
510 | return mock_open(read_data=str(2**63 + 1000))()
511 | elif path == '/sys/fs/cgroup/memory/memory.usage_in_bytes':
512 | return mock_open(read_data='4294967296')()
513 | raise FileNotFoundError()
514 |
515 | with patch('builtins.open', side_effect=mock_open_func):
516 | result = get_container_memory_info()
517 |
518 | assert 'limit' not in result
519 |
520 | @patch('handler.logging')
521 | def test_logs_no_memory_info(self, mock_logging):
522 | from handler import get_container_memory_info
523 |
524 | with patch('builtins.open', side_effect=FileNotFoundError()):
525 | result = get_container_memory_info()
526 |
527 | assert result == {}
528 |
529 | @patch('handler.logging')
530 | def test_handles_exception(self, mock_logging):
531 | from handler import get_container_memory_info
532 |
533 | with patch('builtins.open', side_effect=Exception('Test error')):
534 | result = get_container_memory_info()
535 |
536 | assert result == {}
537 | mock_logging.error.assert_called()
538 |
539 |
540 | class TestContainerCPUInfo:
541 | """Tests for get_container_cpu_info function."""
542 |
543 | @patch('handler.logging')
544 | def test_reads_proc_cpuinfo(self, mock_logging):
545 | from handler import get_container_cpu_info
546 |
547 | cpuinfo_lines = [
548 | "processor\t: 0\n",
549 | "model name\t: Intel\n",
550 | "processor\t: 1\n",
551 | "model name\t: Intel\n"
552 | ]
553 |
554 | def mock_open_func(path, *args, **kwargs):
555 | if path == '/proc/cpuinfo':
556 | m = MagicMock()
557 | m.__enter__ = MagicMock(return_value=iter(cpuinfo_lines))
558 | m.__exit__ = MagicMock(return_value=False)
559 | return m
560 | raise FileNotFoundError()
561 |
562 | with patch('builtins.open', side_effect=mock_open_func):
563 | result = get_container_cpu_info()
564 |
565 | assert result.get('available_cpus') == 2
566 |
567 | @patch('handler.logging')
568 | def test_reads_cgroups_v2_cpu(self, mock_logging):
569 | from handler import get_container_cpu_info
570 |
571 | def mock_open_func(path, *args, **kwargs):
572 | if path == '/proc/cpuinfo':
573 | raise FileNotFoundError()
574 | elif path == '/sys/fs/cgroup/cpu.max':
575 | return mock_open(read_data='200000 100000')()
576 | elif path == '/sys/fs/cgroup/cpu.stat':
577 | return mock_open(read_data='usage_usec 123456789')()
578 | raise FileNotFoundError()
579 |
580 | with patch('builtins.open', side_effect=mock_open_func):
581 | result = get_container_cpu_info()
582 |
583 | assert result.get('allocated_cpus') == 2.0
584 | assert 'usage_usec' in result
585 |
586 | @patch('handler.logging')
587 | def test_reads_cgroups_v1_cpu(self, mock_logging):
588 | from handler import get_container_cpu_info
589 |
590 | def mock_open_func(path, *args, **kwargs):
591 | if path == '/proc/cpuinfo':
592 | raise FileNotFoundError()
593 | elif path == '/sys/fs/cgroup/cpu.max':
594 | raise FileNotFoundError()
595 | elif path == '/sys/fs/cgroup/cpu/cpu.cfs_quota_us':
596 | return mock_open(read_data='200000')()
597 | elif path == '/sys/fs/cgroup/cpu/cpu.cfs_period_us':
598 | return mock_open(read_data='100000')()
599 | elif path == '/sys/fs/cgroup/cpu.stat':
600 | raise FileNotFoundError()
601 | elif path == '/sys/fs/cgroup/cpu/cpuacct.usage':
602 | return mock_open(read_data='123456789000')()
603 | raise FileNotFoundError()
604 |
605 | with patch('builtins.open', side_effect=mock_open_func):
606 | result = get_container_cpu_info()
607 |
608 | assert result.get('allocated_cpus') == 2.0
609 |
610 | @patch('handler.logging')
611 | def test_reads_cgroups_v1_alt_path(self, mock_logging):
612 | from handler import get_container_cpu_info
613 |
614 | def mock_open_func(path, *args, **kwargs):
615 | if path == '/proc/cpuinfo':
616 | raise FileNotFoundError()
617 | elif path == '/sys/fs/cgroup/cpu.max':
618 | raise FileNotFoundError()
619 | elif path == '/sys/fs/cgroup/cpu/cpu.cfs_quota_us':
620 | raise FileNotFoundError()
621 | elif path == '/sys/fs/cgroup/cpu.cfs_quota_us':
622 | return mock_open(read_data='200000')()
623 | elif path == '/sys/fs/cgroup/cpu.cfs_period_us':
624 | return mock_open(read_data='100000')()
625 | elif path == '/sys/fs/cgroup/cpu.stat':
626 | raise FileNotFoundError()
627 | elif path == '/sys/fs/cgroup/cpu/cpuacct.usage':
628 | raise FileNotFoundError()
629 | elif path == '/sys/fs/cgroup/cpuacct.usage':
630 | return mock_open(read_data='123456789000')()
631 | raise FileNotFoundError()
632 |
633 | with patch('builtins.open', side_effect=mock_open_func):
634 | result = get_container_cpu_info()
635 |
636 | assert result.get('allocated_cpus') == 2.0
637 |
638 | @patch('handler.logging')
639 | def test_handles_max_cpu(self, mock_logging):
640 | from handler import get_container_cpu_info
641 |
642 | def mock_open_func(path, *args, **kwargs):
643 | if path == '/proc/cpuinfo':
644 | raise FileNotFoundError()
645 | elif path == '/sys/fs/cgroup/cpu.max':
646 | return mock_open(read_data='max 100000')()
647 | elif path == '/sys/fs/cgroup/cpu.stat':
648 | raise FileNotFoundError()
649 | raise FileNotFoundError()
650 |
651 | with patch('builtins.open', side_effect=mock_open_func):
652 | result = get_container_cpu_info()
653 |
654 | assert 'allocated_cpus' not in result
655 |
656 | @patch('handler.logging')
657 | def test_handles_negative_quota(self, mock_logging):
658 | from handler import get_container_cpu_info
659 |
660 | def mock_open_func(path, *args, **kwargs):
661 | if path == '/proc/cpuinfo':
662 | raise FileNotFoundError()
663 | elif path == '/sys/fs/cgroup/cpu.max':
664 | raise FileNotFoundError()
665 | elif path == '/sys/fs/cgroup/cpu/cpu.cfs_quota_us':
666 | return mock_open(read_data='-1')()
667 | elif path == '/sys/fs/cgroup/cpu/cpu.cfs_period_us':
668 | return mock_open(read_data='100000')()
669 | elif path == '/sys/fs/cgroup/cpu.stat':
670 | raise FileNotFoundError()
671 | raise FileNotFoundError()
672 |
673 | with patch('builtins.open', side_effect=mock_open_func):
674 | result = get_container_cpu_info()
675 |
676 | assert 'allocated_cpus' not in result
677 |
678 | @patch('handler.logging')
679 | def test_handles_exception(self, mock_logging):
680 | from handler import get_container_cpu_info
681 |
682 | with patch('builtins.open', side_effect=Exception('Test error')):
683 | result = get_container_cpu_info()
684 |
685 | assert result == {}
686 | mock_logging.error.assert_called()
687 |
688 |
689 | class TestContainerDiskInfo:
690 | """Tests for get_container_disk_info function."""
691 |
692 | @patch('handler.logging')
693 | @patch('handler.shutil.disk_usage')
694 | def test_gets_disk_usage_with_job_id(self, mock_disk_usage, mock_logging):
695 | from handler import get_container_disk_info
696 |
697 | mock_disk_usage.return_value = (100 * 1024**3, 50 * 1024**3, 50 * 1024**3)
698 |
699 | with patch('builtins.open', side_effect=FileNotFoundError()):
700 | with patch('os.statvfs') as mock_statvfs:
701 | mock_statvfs.return_value = MagicMock(
702 | f_files=1000000,
703 | f_ffree=500000
704 | )
705 | result = get_container_disk_info(job_id='test-job-123')
706 |
707 | assert result['total_bytes'] == 100 * 1024**3
708 | mock_logging.info.assert_called()
709 |
710 | @patch('handler.logging')
711 | @patch('handler.shutil.disk_usage')
712 | def test_no_disk_info_with_job_id(self, mock_disk_usage, mock_logging):
713 | from handler import get_container_disk_info
714 |
715 | mock_disk_usage.side_effect = Exception('Disk error')
716 |
717 | with patch('builtins.open', side_effect=FileNotFoundError()):
718 | with patch('os.statvfs', side_effect=Exception('statvfs error')):
719 | result = get_container_disk_info(job_id='test-job-123')
720 |
721 | # Empty result but with job_id path coverage
722 | assert result == {} or 'total_bytes' not in result
723 |
724 | @patch('handler.logging')
725 | @patch('handler.shutil.disk_usage')
726 | def test_disk_info_logs_io_bytes(self, mock_disk_usage, mock_logging):
727 | from handler import get_container_disk_info
728 |
729 | mock_disk_usage.return_value = (100 * 1024**3, 50 * 1024**3, 50 * 1024**3)
730 |
731 | io_content = "8:0 Read 1234567\n8:0 Write 7654321\n8:0 Total 123456789\n"
732 |
733 | def mock_open_func(path, *args, **kwargs):
734 | if path == '/sys/fs/cgroup/io.stat':
735 | raise FileNotFoundError()
736 | elif path == '/sys/fs/cgroup/blkio/blkio.throttle.io_service_bytes':
737 | m = MagicMock()
738 | m.__enter__ = MagicMock(return_value=iter(io_content.split('\n')))
739 | m.__exit__ = MagicMock(return_value=False)
740 | return m
741 | raise FileNotFoundError()
742 |
743 | with patch('builtins.open', side_effect=mock_open_func):
744 | with patch('os.statvfs') as mock_statvfs:
745 | mock_statvfs.return_value = MagicMock(f_files=1000000, f_ffree=500000)
746 | result = get_container_disk_info(job_id='test-job')
747 |
748 | assert result.get('io_bytes') == 123456789
749 |
750 | @patch('handler.logging')
751 | @patch('handler.shutil.disk_usage')
752 | def test_gets_disk_usage(self, mock_disk_usage, mock_logging):
753 | from handler import get_container_disk_info
754 |
755 | mock_disk_usage.return_value = (100 * 1024**3, 50 * 1024**3, 50 * 1024**3)
756 |
757 | with patch('builtins.open', side_effect=FileNotFoundError()):
758 | with patch('os.statvfs') as mock_statvfs:
759 | mock_statvfs.return_value = MagicMock(
760 | f_files=1000000,
761 | f_ffree=500000
762 | )
763 | result = get_container_disk_info()
764 |
765 | assert result['total_bytes'] == 100 * 1024**3
766 | assert result['used_bytes'] == 50 * 1024**3
767 | assert result['free_bytes'] == 50 * 1024**3
768 |
769 | @patch('handler.logging')
770 | @patch('handler.shutil.disk_usage')
771 | def test_reads_io_stats_v2(self, mock_disk_usage, mock_logging):
772 | from handler import get_container_disk_info
773 |
774 | mock_disk_usage.return_value = (100 * 1024**3, 50 * 1024**3, 50 * 1024**3)
775 |
776 | def mock_open_func(path, *args, **kwargs):
777 | if path == '/sys/fs/cgroup/io.stat':
778 | return mock_open(read_data='253:0 rbytes=123456 wbytes=654321')()
779 | raise FileNotFoundError()
780 |
781 | with patch('builtins.open', side_effect=mock_open_func):
782 | with patch('os.statvfs') as mock_statvfs:
783 | mock_statvfs.return_value = MagicMock(f_files=1000000, f_ffree=500000)
784 | result = get_container_disk_info()
785 |
786 | assert 'io_stats_raw' in result
787 |
788 | @patch('handler.logging')
789 | @patch('handler.shutil.disk_usage')
790 | def test_reads_io_stats_v1(self, mock_disk_usage, mock_logging):
791 | from handler import get_container_disk_info
792 |
793 | mock_disk_usage.return_value = (100 * 1024**3, 50 * 1024**3, 50 * 1024**3)
794 |
795 | # Format: "device operation bytes" where we look for 'Total' and parts[2]
796 | io_content = "8:0 Read 1234567\n8:0 Write 7654321\n8:0 Total 123456789\n"
797 |
798 | def mock_open_func(path, *args, **kwargs):
799 | if path == '/sys/fs/cgroup/io.stat':
800 | raise FileNotFoundError()
801 | elif path == '/sys/fs/cgroup/blkio/blkio.throttle.io_service_bytes':
802 | m = MagicMock()
803 | m.__enter__ = MagicMock(return_value=iter(io_content.split('\n')))
804 | m.__exit__ = MagicMock(return_value=False)
805 | return m
806 | raise FileNotFoundError()
807 |
808 | with patch('builtins.open', side_effect=mock_open_func):
809 | with patch('os.statvfs') as mock_statvfs:
810 | mock_statvfs.return_value = MagicMock(f_files=1000000, f_ffree=500000)
811 | result = get_container_disk_info()
812 |
813 | assert result.get('io_bytes') == 123456789
814 |
815 | @patch('handler.logging')
816 | @patch('handler.shutil.disk_usage')
817 | def test_reads_io_stats_v1_alt(self, mock_disk_usage, mock_logging):
818 | from handler import get_container_disk_info
819 |
820 | mock_disk_usage.return_value = (100 * 1024**3, 50 * 1024**3, 50 * 1024**3)
821 |
822 | # Format: "device operation bytes" where we look for 'Total' and parts[2]
823 | io_content = "8:0 Read 1234567\n8:0 Write 7654321\n8:0 Total 123456789\n"
824 |
825 | def mock_open_func(path, *args, **kwargs):
826 | if path == '/sys/fs/cgroup/io.stat':
827 | raise FileNotFoundError()
828 | elif path == '/sys/fs/cgroup/blkio/blkio.throttle.io_service_bytes':
829 | raise FileNotFoundError()
830 | elif path == '/sys/fs/cgroup/blkio.throttle.io_service_bytes':
831 | m = MagicMock()
832 | m.__enter__ = MagicMock(return_value=iter(io_content.split('\n')))
833 | m.__exit__ = MagicMock(return_value=False)
834 | return m
835 | raise FileNotFoundError()
836 |
837 | with patch('builtins.open', side_effect=mock_open_func):
838 | with patch('os.statvfs') as mock_statvfs:
839 | mock_statvfs.return_value = MagicMock(f_files=1000000, f_ffree=500000)
840 | result = get_container_disk_info()
841 |
842 | assert result.get('io_bytes') == 123456789
843 |
844 | @patch('handler.logging')
845 | @patch('handler.shutil.disk_usage')
846 | def test_handles_disk_usage_exception(self, mock_disk_usage, mock_logging):
847 | from handler import get_container_disk_info
848 |
849 | mock_disk_usage.side_effect = Exception('Disk error')
850 |
851 | with patch('builtins.open', side_effect=FileNotFoundError()):
852 | with patch('os.statvfs') as mock_statvfs:
853 | mock_statvfs.return_value = MagicMock(f_files=1000000, f_ffree=500000)
854 | result = get_container_disk_info()
855 |
856 | assert 'total_bytes' not in result
857 |
858 | @patch('handler.logging')
859 | @patch('handler.shutil.disk_usage')
860 | def test_handles_statvfs_exception(self, mock_disk_usage, mock_logging):
861 | from handler import get_container_disk_info
862 |
863 | mock_disk_usage.return_value = (100 * 1024**3, 50 * 1024**3, 50 * 1024**3)
864 |
865 | with patch('builtins.open', side_effect=FileNotFoundError()):
866 | with patch('os.statvfs', side_effect=Exception('statvfs error')):
867 | result = get_container_disk_info()
868 |
869 | assert 'total_inodes' not in result
870 |
871 | @patch('handler.logging')
872 | @patch('handler.shutil.disk_usage')
873 | def test_handles_overall_exception(self, mock_disk_usage, mock_logging):
874 | from handler import get_container_disk_info
875 |
876 | mock_disk_usage.side_effect = Exception('Fatal error')
877 |
878 | with patch('builtins.open', side_effect=Exception('Fatal error')):
879 | with patch('os.statvfs', side_effect=Exception('Fatal error')):
880 | result = get_container_disk_info()
881 |
882 | assert result == {}
883 |
884 | @patch('handler.logging')
885 | @patch('handler.shutil.disk_usage')
886 | def test_handles_overall_exception_with_job_id(self, mock_disk_usage, mock_logging):
887 | from handler import get_container_disk_info
888 |
889 | mock_disk_usage.side_effect = Exception('Fatal error')
890 |
891 | with patch('builtins.open', side_effect=Exception('Fatal error')):
892 | with patch('os.statvfs', side_effect=Exception('Fatal error')):
893 | result = get_container_disk_info(job_id='test-job')
894 |
895 | assert result == {}
896 | mock_logging.error.assert_called()
897 |
898 | @patch('handler.logging')
899 | @patch('handler.shutil.disk_usage')
900 | def test_no_disk_log_parts_without_job_id(self, mock_disk_usage, mock_logging):
901 | from handler import get_container_disk_info
902 |
903 | mock_disk_usage.side_effect = Exception('Disk error')
904 |
905 | with patch('builtins.open', side_effect=FileNotFoundError()):
906 | with patch('os.statvfs', side_effect=Exception('statvfs error')):
907 | result = get_container_disk_info() # No job_id
908 |
909 | # Should hit the else branch for logging without job_id
910 | mock_logging.info.assert_called()
911 |
912 |
913 | class TestHandlerErrorHandling:
914 | """Tests for handler error handling."""
915 |
916 | @patch('handler.logging')
917 | @patch('handler.get_container_memory_info')
918 | @patch('handler.get_container_cpu_info')
919 | @patch('handler.get_container_disk_info')
920 | @patch('handler.validate')
921 | def test_handler_returns_error_on_validation_failure(
922 | self, mock_validate, mock_disk, mock_cpu, mock_memory, mock_logging
923 | ):
924 | from handler import handler
925 |
926 | mock_memory.return_value = {'available': 10.0}
927 | mock_cpu.return_value = {}
928 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
929 | mock_validate.return_value = {'errors': ['Invalid input']}
930 |
931 | event = {'id': 'test-123', 'input': {}}
932 | result = handler(event)
933 |
934 | assert 'error' in result
935 | assert 'Invalid input' in result['error']
936 |
937 | @patch('handler.logging')
938 | @patch('handler.get_container_memory_info')
939 | @patch('handler.get_container_cpu_info')
940 | @patch('handler.get_container_disk_info')
941 | def test_handler_returns_error_on_low_memory(
942 | self, mock_disk, mock_cpu, mock_memory, mock_logging
943 | ):
944 | from handler import handler
945 |
946 | mock_memory.return_value = {'available': 0.1}
947 | mock_cpu.return_value = {}
948 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
949 |
950 | event = {'id': 'test-123', 'input': {'workflow': 'custom', 'payload': {}}}
951 | result = handler(event)
952 |
953 | assert 'error' in result
954 | assert 'memory' in result['error'].lower()
955 |
956 | @patch('handler.logging')
957 | @patch('handler.get_container_memory_info')
958 | @patch('handler.get_container_cpu_info')
959 | @patch('handler.get_container_disk_info')
960 | def test_handler_returns_error_on_low_disk_space(
961 | self, mock_disk, mock_cpu, mock_memory, mock_logging
962 | ):
963 | from handler import handler
964 |
965 | mock_memory.return_value = {'available': 10.0}
966 | mock_cpu.return_value = {}
967 | mock_disk.return_value = {'free_bytes': 100 * 1024}
968 |
969 | event = {'id': 'test-123', 'input': {'workflow': 'custom', 'payload': {}}}
970 | result = handler(event)
971 |
972 | assert 'error' in result
973 | assert 'disk' in result['error'].lower()
974 |
975 |
976 | class TestHandlerSuccessPath:
977 | """Tests for handler success path."""
978 |
979 | @patch('handler.logging')
980 | @patch('handler.get_container_memory_info')
981 | @patch('handler.get_container_cpu_info')
982 | @patch('handler.get_container_disk_info')
983 | @patch('handler.send_post_request')
984 | @patch('handler.send_get_request')
985 | @patch('handler.os.path.exists')
986 | @patch('handler.os.remove')
987 | @patch('builtins.open', new_callable=mock_open, read_data=b'fake image data')
988 | def test_handler_success_with_output_image(
989 | self, mock_file, mock_remove, mock_exists, mock_get, mock_post,
990 | mock_disk, mock_cpu, mock_memory, mock_logging
991 | ):
992 | import handler
993 |
994 | mock_memory.return_value = {'available': 10.0}
995 | mock_cpu.return_value = {}
996 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
997 |
998 | mock_post.return_value = MagicMock(
999 | status_code=200,
1000 | json=lambda: {'prompt_id': 'test-prompt-123'}
1001 | )
1002 |
1003 | mock_get.return_value = MagicMock(
1004 | status_code=200,
1005 | json=lambda: {
1006 | 'test-prompt-123': {
1007 | 'status': {'status_str': 'success', 'completed': True, 'messages': []},
1008 | 'outputs': {
1009 | '9': {'images': [{'filename': 'test.png', 'type': 'output'}]}
1010 | }
1011 | }
1012 | }
1013 | )
1014 |
1015 | mock_exists.return_value = True
1016 |
1017 | event = {
1018 | 'id': 'test-123',
1019 | 'input': {
1020 | 'workflow': 'custom',
1021 | 'payload': {
1022 | '9': {'class_type': 'SaveImage', 'inputs': {'filename_prefix': 'test'}}
1023 | }
1024 | }
1025 | }
1026 |
1027 | result = handler.handler(event)
1028 |
1029 | assert 'images' in result
1030 | assert len(result['images']) == 1
1031 |
1032 | @patch('handler.logging')
1033 | @patch('handler.get_container_memory_info')
1034 | @patch('handler.get_container_cpu_info')
1035 | @patch('handler.get_container_disk_info')
1036 | @patch('handler.send_post_request')
1037 | @patch('handler.send_get_request')
1038 | @patch('handler.os.path.exists')
1039 | @patch('handler.os.remove')
1040 | def test_handler_success_with_temp_image_volume(
1041 | self, mock_remove, mock_exists, mock_get, mock_post,
1042 | mock_disk, mock_cpu, mock_memory, mock_logging
1043 | ):
1044 | import handler
1045 |
1046 | mock_memory.return_value = {'available': 10.0}
1047 | mock_cpu.return_value = {}
1048 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1049 |
1050 | mock_post.return_value = MagicMock(
1051 | status_code=200,
1052 | json=lambda: {'prompt_id': 'test-prompt-123'}
1053 | )
1054 |
1055 | mock_get.return_value = MagicMock(
1056 | status_code=200,
1057 | json=lambda: {
1058 | 'test-prompt-123': {
1059 | 'status': {'status_str': 'success', 'completed': True, 'messages': []},
1060 | 'outputs': {
1061 | '9': {'images': [{'filename': 'test.png', 'type': 'temp'}]}
1062 | }
1063 | }
1064 | }
1065 | )
1066 |
1067 | mock_exists.return_value = True
1068 |
1069 | event = {
1070 | 'id': 'test-123',
1071 | 'input': {
1072 | 'workflow': 'custom',
1073 | 'payload': {
1074 | '9': {'class_type': 'SaveImage', 'inputs': {'filename_prefix': 'test'}}
1075 | }
1076 | }
1077 | }
1078 |
1079 | result = handler.handler(event)
1080 |
1081 | assert 'images' in result
1082 | mock_remove.assert_called()
1083 |
1084 | @patch('handler.logging')
1085 | @patch('handler.get_container_memory_info')
1086 | @patch('handler.get_container_cpu_info')
1087 | @patch('handler.get_container_disk_info')
1088 | @patch('handler.send_post_request')
1089 | @patch('handler.send_get_request')
1090 | @patch('handler.os.path.exists')
1091 | @patch('handler.os.remove')
1092 | def test_handler_success_with_temp_image_tmp_dir(
1093 | self, mock_remove, mock_exists, mock_get, mock_post,
1094 | mock_disk, mock_cpu, mock_memory, mock_logging
1095 | ):
1096 | import handler
1097 |
1098 | mock_memory.return_value = {'available': 10.0}
1099 | mock_cpu.return_value = {}
1100 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1101 |
1102 | mock_post.return_value = MagicMock(
1103 | status_code=200,
1104 | json=lambda: {'prompt_id': 'test-prompt-123'}
1105 | )
1106 |
1107 | mock_get.return_value = MagicMock(
1108 | status_code=200,
1109 | json=lambda: {
1110 | 'test-prompt-123': {
1111 | 'status': {'status_str': 'success', 'completed': True, 'messages': []},
1112 | 'outputs': {
1113 | '9': {'images': [{'filename': 'test.png', 'type': 'temp'}]}
1114 | }
1115 | }
1116 | }
1117 | )
1118 |
1119 | # First call (volume path) returns False, second call (/tmp) returns True
1120 | mock_exists.side_effect = [False, True]
1121 |
1122 | event = {
1123 | 'id': 'test-123',
1124 | 'input': {
1125 | 'workflow': 'custom',
1126 | 'payload': {
1127 | '9': {'class_type': 'SaveImage', 'inputs': {'filename_prefix': 'test'}}
1128 | }
1129 | }
1130 | }
1131 |
1132 | result = handler.handler(event)
1133 |
1134 | assert 'images' in result
1135 | mock_remove.assert_called()
1136 |
1137 | @patch('handler.logging')
1138 | @patch('handler.get_container_memory_info')
1139 | @patch('handler.get_container_cpu_info')
1140 | @patch('handler.get_container_disk_info')
1141 | @patch('handler.send_post_request')
1142 | @patch('handler.send_get_request')
1143 | @patch('handler.os.path.exists')
1144 | @patch('handler.os.remove')
1145 | def test_handler_temp_delete_error(
1146 | self, mock_remove, mock_exists, mock_get, mock_post,
1147 | mock_disk, mock_cpu, mock_memory, mock_logging
1148 | ):
1149 | import handler
1150 |
1151 | mock_memory.return_value = {'available': 10.0}
1152 | mock_cpu.return_value = {}
1153 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1154 |
1155 | mock_post.return_value = MagicMock(
1156 | status_code=200,
1157 | json=lambda: {'prompt_id': 'test-prompt-123'}
1158 | )
1159 |
1160 | mock_get.return_value = MagicMock(
1161 | status_code=200,
1162 | json=lambda: {
1163 | 'test-prompt-123': {
1164 | 'status': {'status_str': 'success', 'completed': True, 'messages': []},
1165 | 'outputs': {
1166 | '9': {'images': [{'filename': 'test.png', 'type': 'temp'}]}
1167 | }
1168 | }
1169 | }
1170 | )
1171 |
1172 | mock_exists.return_value = True
1173 | mock_remove.side_effect = Exception('Permission denied')
1174 |
1175 | event = {
1176 | 'id': 'test-123',
1177 | 'input': {
1178 | 'workflow': 'custom',
1179 | 'payload': {
1180 | '9': {'class_type': 'SaveImage', 'inputs': {'filename_prefix': 'test'}}
1181 | }
1182 | }
1183 | }
1184 |
1185 | result = handler.handler(event)
1186 |
1187 | assert 'images' in result
1188 | mock_logging.error.assert_called()
1189 |
1190 | @patch('handler.logging')
1191 | @patch('handler.get_container_memory_info')
1192 | @patch('handler.get_container_cpu_info')
1193 | @patch('handler.get_container_disk_info')
1194 | @patch('handler.send_post_request')
1195 | @patch('handler.send_get_request')
1196 | @patch('handler.os.path.exists')
1197 | @patch('handler.os.remove')
1198 | def test_handler_temp_delete_error_tmp_path(
1199 | self, mock_remove, mock_exists, mock_get, mock_post,
1200 | mock_disk, mock_cpu, mock_memory, mock_logging
1201 | ):
1202 | import handler
1203 |
1204 | mock_memory.return_value = {'available': 10.0}
1205 | mock_cpu.return_value = {}
1206 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1207 |
1208 | mock_post.return_value = MagicMock(
1209 | status_code=200,
1210 | json=lambda: {'prompt_id': 'test-prompt-123'}
1211 | )
1212 |
1213 | mock_get.return_value = MagicMock(
1214 | status_code=200,
1215 | json=lambda: {
1216 | 'test-prompt-123': {
1217 | 'status': {'status_str': 'success', 'completed': True, 'messages': []},
1218 | 'outputs': {
1219 | '9': {'images': [{'filename': 'test.png', 'type': 'temp'}]}
1220 | }
1221 | }
1222 | }
1223 | )
1224 |
1225 | mock_exists.side_effect = [False, True]
1226 | mock_remove.side_effect = Exception('Permission denied')
1227 |
1228 | event = {
1229 | 'id': 'test-123',
1230 | 'input': {
1231 | 'workflow': 'custom',
1232 | 'payload': {
1233 | '9': {'class_type': 'SaveImage', 'inputs': {'filename_prefix': 'test'}}
1234 | }
1235 | }
1236 | }
1237 |
1238 | result = handler.handler(event)
1239 |
1240 | assert 'images' in result
1241 |
1242 | @patch('handler.logging')
1243 | @patch('handler.get_container_memory_info')
1244 | @patch('handler.get_container_cpu_info')
1245 | @patch('handler.get_container_disk_info')
1246 | @patch('handler.send_post_request')
1247 | @patch('handler.send_get_request')
1248 | @patch('handler.os.path.exists')
1249 | def test_handler_low_memory_refresh(
1250 | self, mock_exists, mock_get, mock_post,
1251 | mock_disk, mock_cpu, mock_memory, mock_logging
1252 | ):
1253 | import handler
1254 |
1255 | # First call returns normal, second call (after processing) returns low memory
1256 | mock_memory.side_effect = [
1257 | {'available': 10.0},
1258 | {'available': 0.5}
1259 | ]
1260 | mock_cpu.return_value = {}
1261 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1262 |
1263 | mock_post.return_value = MagicMock(
1264 | status_code=200,
1265 | json=lambda: {'prompt_id': 'test-prompt-123'}
1266 | )
1267 |
1268 | mock_get.return_value = MagicMock(
1269 | status_code=200,
1270 | json=lambda: {
1271 | 'test-prompt-123': {
1272 | 'status': {'status_str': 'success', 'completed': True, 'messages': []},
1273 | 'outputs': {
1274 | '9': {'images': [{'filename': 'test.png', 'type': 'output'}]}
1275 | }
1276 | }
1277 | }
1278 | )
1279 |
1280 | mock_exists.return_value = False
1281 |
1282 | event = {
1283 | 'id': 'test-123',
1284 | 'input': {
1285 | 'workflow': 'custom',
1286 | 'payload': {
1287 | '9': {'class_type': 'SaveImage', 'inputs': {'filename_prefix': 'test'}}
1288 | }
1289 | }
1290 | }
1291 |
1292 | result = handler.handler(event)
1293 |
1294 | assert result.get('refresh_worker') is True
1295 |
1296 | @patch('handler.logging')
1297 | @patch('handler.get_container_memory_info')
1298 | @patch('handler.get_container_cpu_info')
1299 | @patch('handler.get_container_disk_info')
1300 | @patch('handler.send_post_request')
1301 | @patch('handler.send_get_request')
1302 | def test_handler_no_outputs(
1303 | self, mock_get, mock_post,
1304 | mock_disk, mock_cpu, mock_memory, mock_logging
1305 | ):
1306 | import handler
1307 |
1308 | mock_memory.return_value = {'available': 10.0}
1309 | mock_cpu.return_value = {}
1310 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1311 |
1312 | mock_post.return_value = MagicMock(
1313 | status_code=200,
1314 | json=lambda: {'prompt_id': 'test-prompt-123'}
1315 | )
1316 |
1317 | mock_get.return_value = MagicMock(
1318 | status_code=200,
1319 | json=lambda: {
1320 | 'test-prompt-123': {
1321 | 'status': {'status_str': 'success', 'completed': True, 'messages': []},
1322 | 'outputs': {}
1323 | }
1324 | }
1325 | )
1326 |
1327 | event = {
1328 | 'id': 'test-123',
1329 | 'input': {
1330 | 'workflow': 'custom',
1331 | 'payload': {}
1332 | }
1333 | }
1334 |
1335 | result = handler.handler(event)
1336 |
1337 | assert 'error' in result
1338 | assert 'No output found' in result['error']
1339 |
1340 | @patch('handler.logging')
1341 | @patch('handler.get_container_memory_info')
1342 | @patch('handler.get_container_cpu_info')
1343 | @patch('handler.get_container_disk_info')
1344 | @patch('handler.send_post_request')
1345 | @patch('handler.send_get_request')
1346 | def test_handler_execution_error(
1347 | self, mock_get, mock_post,
1348 | mock_disk, mock_cpu, mock_memory, mock_logging
1349 | ):
1350 | import handler
1351 |
1352 | mock_memory.return_value = {'available': 10.0}
1353 | mock_cpu.return_value = {}
1354 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1355 |
1356 | mock_post.return_value = MagicMock(
1357 | status_code=200,
1358 | json=lambda: {'prompt_id': 'test-prompt-123'}
1359 | )
1360 |
1361 | mock_get.return_value = MagicMock(
1362 | status_code=200,
1363 | json=lambda: {
1364 | 'test-prompt-123': {
1365 | 'status': {
1366 | 'status_str': 'error',
1367 | 'completed': False,
1368 | 'messages': [
1369 | ['execution_error', {
1370 | 'node_type': 'TestNode',
1371 | 'exception_message': 'Test error'
1372 | }]
1373 | ]
1374 | },
1375 | 'outputs': {}
1376 | }
1377 | }
1378 | )
1379 |
1380 | event = {
1381 | 'id': 'test-123',
1382 | 'input': {
1383 | 'workflow': 'custom',
1384 | 'payload': {}
1385 | }
1386 | }
1387 |
1388 | result = handler.handler(event)
1389 |
1390 | assert 'error' in result
1391 | assert 'TestNode' in result['error']
1392 |
1393 | @patch('handler.logging')
1394 | @patch('handler.get_container_memory_info')
1395 | @patch('handler.get_container_cpu_info')
1396 | @patch('handler.get_container_disk_info')
1397 | @patch('handler.send_post_request')
1398 | @patch('handler.send_get_request')
1399 | def test_handler_execution_error_without_details(
1400 | self, mock_get, mock_post,
1401 | mock_disk, mock_cpu, mock_memory, mock_logging
1402 | ):
1403 | import handler
1404 |
1405 | mock_memory.return_value = {'available': 10.0}
1406 | mock_cpu.return_value = {}
1407 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1408 |
1409 | mock_post.return_value = MagicMock(
1410 | status_code=200,
1411 | json=lambda: {'prompt_id': 'test-prompt-123'}
1412 | )
1413 |
1414 | mock_get.return_value = MagicMock(
1415 | status_code=200,
1416 | json=lambda: {
1417 | 'test-prompt-123': {
1418 | 'status': {
1419 | 'status_str': 'error',
1420 | 'completed': False,
1421 | 'messages': [
1422 | ['execution_error', {'other_field': 'value'}]
1423 | ]
1424 | },
1425 | 'outputs': {}
1426 | }
1427 | }
1428 | )
1429 |
1430 | event = {
1431 | 'id': 'test-123',
1432 | 'input': {
1433 | 'workflow': 'custom',
1434 | 'payload': {}
1435 | }
1436 | }
1437 |
1438 | result = handler.handler(event)
1439 |
1440 | assert 'error' in result
1441 |
1442 | @patch('handler.logging')
1443 | @patch('handler.get_container_memory_info')
1444 | @patch('handler.get_container_cpu_info')
1445 | @patch('handler.get_container_disk_info')
1446 | @patch('handler.send_post_request')
1447 | def test_handler_queue_error_with_json(
1448 | self, mock_post,
1449 | mock_disk, mock_cpu, mock_memory, mock_logging
1450 | ):
1451 | import handler
1452 |
1453 | mock_memory.return_value = {'available': 10.0}
1454 | mock_cpu.return_value = {}
1455 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1456 |
1457 | mock_post.return_value = MagicMock(
1458 | status_code=500,
1459 | json=lambda: {'error': 'Server error'},
1460 | content=b'Server error'
1461 | )
1462 |
1463 | event = {
1464 | 'id': 'test-123',
1465 | 'input': {
1466 | 'workflow': 'custom',
1467 | 'payload': {}
1468 | }
1469 | }
1470 |
1471 | result = handler.handler(event)
1472 |
1473 | assert 'error' in result
1474 | assert '500' in result['error']
1475 |
1476 | @patch('handler.logging')
1477 | @patch('handler.get_container_memory_info')
1478 | @patch('handler.get_container_cpu_info')
1479 | @patch('handler.get_container_disk_info')
1480 | @patch('handler.send_post_request')
1481 | def test_handler_queue_error_without_json(
1482 | self, mock_post,
1483 | mock_disk, mock_cpu, mock_memory, mock_logging
1484 | ):
1485 | import handler
1486 |
1487 | mock_memory.return_value = {'available': 10.0}
1488 | mock_cpu.return_value = {}
1489 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1490 |
1491 | mock_response = MagicMock(status_code=500, content=b'Server error')
1492 | mock_response.json.side_effect = Exception('Not JSON')
1493 | mock_post.return_value = mock_response
1494 |
1495 | event = {
1496 | 'id': 'test-123',
1497 | 'input': {
1498 | 'workflow': 'custom',
1499 | 'payload': {}
1500 | }
1501 | }
1502 |
1503 | result = handler.handler(event)
1504 |
1505 | assert 'error' in result
1506 | assert '500' in result['error']
1507 |
1508 | @patch('handler.logging')
1509 | @patch('handler.get_container_memory_info')
1510 | @patch('handler.get_container_cpu_info')
1511 | @patch('handler.get_container_disk_info')
1512 | @patch('handler.send_post_request')
1513 | @patch('handler.send_get_request')
1514 | @patch('handler.time.sleep')
1515 | def test_handler_retries_history(
1516 | self, mock_sleep, mock_get, mock_post,
1517 | mock_disk, mock_cpu, mock_memory, mock_logging
1518 | ):
1519 | import handler
1520 |
1521 | mock_memory.return_value = {'available': 10.0}
1522 | mock_cpu.return_value = {}
1523 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1524 |
1525 | mock_post.return_value = MagicMock(
1526 | status_code=200,
1527 | json=lambda: {'prompt_id': 'test-prompt-123'}
1528 | )
1529 |
1530 | # First call returns empty, second returns result
1531 | call_count = [0]
1532 | def mock_json():
1533 | call_count[0] += 1
1534 | if call_count[0] == 1:
1535 | return {}
1536 | return {
1537 | 'test-prompt-123': {
1538 | 'status': {'status_str': 'success', 'completed': True, 'messages': []},
1539 | 'outputs': {}
1540 | }
1541 | }
1542 |
1543 | mock_get.return_value = MagicMock(status_code=200, json=mock_json)
1544 |
1545 | event = {
1546 | 'id': 'test-123',
1547 | 'input': {
1548 | 'workflow': 'custom',
1549 | 'payload': {}
1550 | }
1551 | }
1552 |
1553 | result = handler.handler(event)
1554 |
1555 | mock_sleep.assert_called()
1556 |
1557 | @patch('handler.logging')
1558 | @patch('handler.get_container_memory_info')
1559 | @patch('handler.get_container_cpu_info')
1560 | @patch('handler.get_container_disk_info')
1561 | @patch('handler.get_workflow_payload')
1562 | @patch('handler.send_post_request')
1563 | @patch('handler.send_get_request')
1564 | def test_handler_default_workflow(
1565 | self, mock_get, mock_post, mock_workflow,
1566 | mock_disk, mock_cpu, mock_memory, mock_logging
1567 | ):
1568 | import handler
1569 |
1570 | mock_memory.return_value = {'available': 10.0}
1571 | mock_cpu.return_value = {}
1572 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1573 | mock_workflow.return_value = {}
1574 |
1575 | mock_post.return_value = MagicMock(
1576 | status_code=200,
1577 | json=lambda: {'prompt_id': 'test-prompt-123'}
1578 | )
1579 |
1580 | mock_get.return_value = MagicMock(
1581 | status_code=200,
1582 | json=lambda: {
1583 | 'test-prompt-123': {
1584 | 'status': {'status_str': 'success', 'completed': True, 'messages': []},
1585 | 'outputs': {}
1586 | }
1587 | }
1588 | )
1589 |
1590 | event = {
1591 | 'id': 'test-123',
1592 | 'input': {
1593 | 'workflow': 'default',
1594 | 'payload': {}
1595 | }
1596 | }
1597 |
1598 | result = handler.handler(event)
1599 |
1600 | mock_workflow.assert_called_with('txt2img', {})
1601 |
1602 | @patch('handler.logging')
1603 | @patch('handler.get_container_memory_info')
1604 | @patch('handler.get_container_cpu_info')
1605 | @patch('handler.get_container_disk_info')
1606 | @patch('handler.get_workflow_payload')
1607 | def test_handler_workflow_load_error(
1608 | self, mock_workflow,
1609 | mock_disk, mock_cpu, mock_memory, mock_logging
1610 | ):
1611 | import handler
1612 |
1613 | mock_memory.return_value = {'available': 10.0}
1614 | mock_cpu.return_value = {}
1615 | mock_disk.return_value = {'free_bytes': 10 * 1024 * 1024 * 1024}
1616 | mock_workflow.side_effect = Exception('File not found')
1617 |
1618 | event = {
1619 | 'id': 'test-123',
1620 | 'input': {
1621 | 'workflow': 'txt2img',
1622 | 'payload': {}
1623 | }
1624 | }
1625 |
1626 | result = handler.handler(event)
1627 |
1628 | assert 'error' in result
1629 | assert 'refresh_worker' in result
1630 |
1631 |
1632 | class TestSnapLogHandler:
1633 | """Tests for custom log handler."""
1634 |
1635 | def test_log_handler_formats_message_correctly(self, mock_runpod_logger):
1636 | from handler import SnapLogHandler
1637 |
1638 | with patch.dict(os.environ, {'RUNPOD_JOB_ID': 'test-job'}):
1639 | handler = SnapLogHandler('test-app')
1640 | handler.setFormatter(logging.Formatter('%(message)s'))
1641 |
1642 | record = logging.LogRecord(
1643 | name='test',
1644 | level=logging.INFO,
1645 | pathname='',
1646 | lineno=0,
1647 | msg='Test message',
1648 | args=(),
1649 | exc_info=None
1650 | )
1651 |
1652 | handler.emit(record)
1653 |
1654 | def test_log_handler_handles_format_args(self, mock_runpod_logger):
1655 | from handler import SnapLogHandler
1656 |
1657 | handler = SnapLogHandler('test-app')
1658 | handler.setFormatter(logging.Formatter('%(message)s'))
1659 |
1660 | record = logging.LogRecord(
1661 | name='test',
1662 | level=logging.INFO,
1663 | pathname='',
1664 | lineno=0,
1665 | msg='Test %s message',
1666 | args=('formatted',),
1667 | exc_info=None
1668 | )
1669 |
1670 | handler.emit(record)
1671 |
1672 | def test_log_handler_handles_dict_args(self, mock_runpod_logger):
1673 | from handler import SnapLogHandler
1674 |
1675 | handler = SnapLogHandler('test-app')
1676 | handler.setFormatter(logging.Formatter('%(message)s'))
1677 |
1678 | # Use MagicMock because LogRecord doesn't accept dict as args directly
1679 | record = MagicMock()
1680 | record.msg = 'Test %(key)s message'
1681 | record.args = {'key': 'value'}
1682 | record.levelno = logging.INFO
1683 | record.levelname = 'INFO'
1684 |
1685 | handler.emit(record)
1686 |
1687 | def test_log_handler_handles_dict_args_no_format(self, mock_runpod_logger):
1688 | from handler import SnapLogHandler
1689 |
1690 | handler = SnapLogHandler('test-app')
1691 | handler.setFormatter(logging.Formatter('%(message)s'))
1692 |
1693 | # Use MagicMock because LogRecord doesn't accept dict as args directly
1694 | record = MagicMock()
1695 | record.msg = 'Test message no format'
1696 | record.args = {'key': 'value'}
1697 | record.levelno = logging.INFO
1698 | record.levelname = 'INFO'
1699 |
1700 | handler.emit(record)
1701 |
1702 | def test_log_handler_handles_format_error(self, mock_runpod_logger):
1703 | from handler import SnapLogHandler
1704 |
1705 | handler = SnapLogHandler('test-app')
1706 | handler.setFormatter(logging.Formatter('%(message)s'))
1707 |
1708 | record = logging.LogRecord(
1709 | name='test',
1710 | level=logging.INFO,
1711 | pathname='',
1712 | lineno=0,
1713 | msg='Test %s %s message',
1714 | args=('only_one',),
1715 | exc_info=None
1716 | )
1717 |
1718 | handler.emit(record)
1719 |
1720 | def test_log_handler_handles_no_msg_attr(self, mock_runpod_logger):
1721 | from handler import SnapLogHandler
1722 |
1723 | handler = SnapLogHandler('test-app')
1724 | handler.setFormatter(logging.Formatter('%(message)s'))
1725 |
1726 | record = MagicMock()
1727 | del record.msg
1728 | del record.args
1729 | record.levelno = logging.INFO
1730 | record.levelname = 'INFO'
1731 |
1732 | handler.emit(record)
1733 |
1734 | def test_log_handler_long_message_skips_runpod(self, mock_runpod_logger):
1735 | from handler import SnapLogHandler
1736 |
1737 | handler = SnapLogHandler('test-app')
1738 | handler.setFormatter(logging.Formatter('%(message)s'))
1739 |
1740 | record = logging.LogRecord(
1741 | name='test',
1742 | level=logging.INFO,
1743 | pathname='',
1744 | lineno=0,
1745 | msg='x' * 1001,
1746 | args=(),
1747 | exc_info=None
1748 | )
1749 |
1750 | handler.emit(record)
1751 |
1752 | def test_log_handler_without_job_id(self, mock_runpod_logger):
1753 | from handler import SnapLogHandler
1754 |
1755 | with patch.dict(os.environ, {}, clear=True):
1756 | if 'RUNPOD_JOB_ID' in os.environ:
1757 | del os.environ['RUNPOD_JOB_ID']
1758 |
1759 | handler = SnapLogHandler('test-app')
1760 | handler.setFormatter(logging.Formatter('%(message)s'))
1761 |
1762 | record = logging.LogRecord(
1763 | name='test',
1764 | level=logging.INFO,
1765 | pathname='',
1766 | lineno=0,
1767 | msg='Test message',
1768 | args=(),
1769 | exc_info=None
1770 | )
1771 |
1772 | handler.emit(record)
1773 |
1774 | @patch('handler.requests.post')
1775 | def test_log_handler_posts_to_api(self, mock_post, mock_runpod_logger):
1776 | from handler import SnapLogHandler
1777 |
1778 | mock_post.return_value = MagicMock(status_code=200)
1779 |
1780 | with patch.dict(os.environ, {'LOG_API_ENDPOINT': 'http://test.com/log', 'LOG_API_TOKEN': 'token'}):
1781 | handler = SnapLogHandler('test-app')
1782 | handler.setFormatter(logging.Formatter('%(message)s'))
1783 |
1784 | record = logging.LogRecord(
1785 | name='test',
1786 | level=logging.INFO,
1787 | pathname='',
1788 | lineno=0,
1789 | msg='Test message',
1790 | args=(),
1791 | exc_info=None
1792 | )
1793 |
1794 | handler.emit(record)
1795 |
1796 | mock_post.assert_called_once()
1797 |
1798 | @patch('handler.requests.post')
1799 | def test_log_handler_handles_api_error(self, mock_post, mock_runpod_logger):
1800 | from handler import SnapLogHandler
1801 |
1802 | mock_post.return_value = MagicMock(status_code=500)
1803 |
1804 | with patch.dict(os.environ, {'LOG_API_ENDPOINT': 'http://test.com/log', 'LOG_API_TOKEN': 'token'}):
1805 | handler = SnapLogHandler('test-app')
1806 | handler.setFormatter(logging.Formatter('%(message)s'))
1807 |
1808 | record = logging.LogRecord(
1809 | name='test',
1810 | level=logging.INFO,
1811 | pathname='',
1812 | lineno=0,
1813 | msg='Test message',
1814 | args=(),
1815 | exc_info=None
1816 | )
1817 |
1818 | handler.emit(record)
1819 |
1820 | @patch('handler.requests.post')
1821 | def test_log_handler_handles_api_timeout(self, mock_post, mock_runpod_logger):
1822 | from handler import SnapLogHandler
1823 |
1824 | mock_post.side_effect = requests.Timeout()
1825 |
1826 | with patch.dict(os.environ, {'LOG_API_ENDPOINT': 'http://test.com/log', 'LOG_API_TOKEN': 'token'}):
1827 | handler = SnapLogHandler('test-app')
1828 | handler.setFormatter(logging.Formatter('%(message)s'))
1829 |
1830 | record = logging.LogRecord(
1831 | name='test',
1832 | level=logging.INFO,
1833 | pathname='',
1834 | lineno=0,
1835 | msg='Test message',
1836 | args=(),
1837 | exc_info=None
1838 | )
1839 |
1840 | handler.emit(record)
1841 |
1842 | @patch('handler.requests.post')
1843 | def test_log_handler_handles_api_exception(self, mock_post, mock_runpod_logger):
1844 | from handler import SnapLogHandler
1845 |
1846 | mock_post.side_effect = Exception('Network error')
1847 |
1848 | with patch.dict(os.environ, {'LOG_API_ENDPOINT': 'http://test.com/log', 'LOG_API_TOKEN': 'token'}):
1849 | handler = SnapLogHandler('test-app')
1850 | handler.setFormatter(logging.Formatter('%(message)s'))
1851 |
1852 | record = logging.LogRecord(
1853 | name='test',
1854 | level=logging.INFO,
1855 | pathname='',
1856 | lineno=0,
1857 | msg='Test message',
1858 | args=(),
1859 | exc_info=None
1860 | )
1861 |
1862 | handler.emit(record)
1863 |
1864 | def test_log_handler_emit_exception(self, mock_runpod_logger):
1865 | from handler import SnapLogHandler
1866 |
1867 | handler = SnapLogHandler('test-app')
1868 | handler.setFormatter(MagicMock(formatTime=MagicMock(side_effect=Exception('Format error'))))
1869 |
1870 | with patch.dict(os.environ, {'LOG_API_ENDPOINT': 'http://test.com/log'}):
1871 | record = logging.LogRecord(
1872 | name='test',
1873 | level=logging.INFO,
1874 | pathname='',
1875 | lineno=0,
1876 | msg='Test message',
1877 | args=(),
1878 | exc_info=None
1879 | )
1880 |
1881 | handler.emit(record)
1882 |
1883 | def test_log_handler_outer_exception(self, mock_runpod_logger):
1884 | from handler import SnapLogHandler
1885 |
1886 | # Create handler but make rp_logger.info raise an exception
1887 | # to trigger the outer exception handler
1888 | handler = SnapLogHandler('test-app')
1889 | handler.setFormatter(logging.Formatter('%(message)s'))
1890 | handler.rp_logger.info = MagicMock(side_effect=RuntimeError('Outer exception'))
1891 |
1892 | record = logging.LogRecord(
1893 | name='test',
1894 | level=logging.INFO,
1895 | pathname='',
1896 | lineno=0,
1897 | msg='Test message',
1898 | args=(),
1899 | exc_info=None
1900 | )
1901 |
1902 | # This should trigger the outer exception handler (lines 129-131)
1903 | handler.emit(record)
1904 |
1905 | # The outer exception handler should have called rp_logger.error
1906 | handler.rp_logger.error.assert_called()
1907 |
1908 | def test_log_handler_different_levels(self, mock_runpod_logger):
1909 | from handler import SnapLogHandler
1910 |
1911 | handler = SnapLogHandler('test-app')
1912 | handler.setFormatter(logging.Formatter('%(message)s'))
1913 |
1914 | for level in [logging.DEBUG, logging.WARNING, logging.ERROR, logging.CRITICAL]:
1915 | record = logging.LogRecord(
1916 | name='test',
1917 | level=level,
1918 | pathname='',
1919 | lineno=0,
1920 | msg='Test message',
1921 | args=(),
1922 | exc_info=None
1923 | )
1924 | handler.emit(record)
1925 |
1926 |
1927 | class TestSetupLogging:
1928 | """Tests for setup_logging function."""
1929 |
1930 | @patch('handler.SnapLogHandler')
1931 | def test_setup_logging_configures_root_logger(self, mock_handler_class):
1932 | from handler import setup_logging
1933 |
1934 | mock_handler = MagicMock()
1935 | mock_handler_class.return_value = mock_handler
1936 |
1937 | setup_logging()
1938 |
1939 | mock_handler.setFormatter.assert_called_once()
1940 |
--------------------------------------------------------------------------------