Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies = [
"grpcio-tools",
"redis",
"sqlalchemy",
"psycopg[binary]",
"pyyaml",
"flask",
]
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ flask
ipdb
ipython
sqlalchemy
psycopg[binary]
43 changes: 28 additions & 15 deletions tests/test_instance_manager_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,16 +249,23 @@ def test_manager_keeps_ec2_runtime_boundary_behavior(self):
controller.redis_containers = {"10.0.0.30": "redis-box"}
manager.remove_instance("EC2:Remote:0")

runtime.provision_instance.assert_called_once_with(
{
"name": "Remote",
"provider": "EC2",
"instance_type": "t3.small",
"redis_port": 6390,
},
0,
manager._next_host_port,
runtime.provision_instance.assert_called_once()
provision_args = runtime.provision_instance.call_args.args
self.assertEqual(
provision_args[:2],
(
{
"name": "Remote",
"provider": "EC2",
"instance_type": "t3.small",
"redis_port": 6390,
},
0,
),
)
# EC2 jobs never get a pre-reserved port; the callback stands in for
# one but should always resolve to None for this provider.
self.assertIsNone(provision_args[2]("10.0.0.30"))
runtime.bootstrap_instance.assert_called_once_with(
provisioned,
{
Expand Down Expand Up @@ -324,17 +331,23 @@ def runtime_for(provider):
)

local_runtime.validate_config.assert_called_once_with()
local_runtime.provision_instance.assert_called_once_with(
{"name": "Local", "provider": "local"}, 0, manager._next_host_port
local_runtime.provision_instance.assert_called_once()
local_provision_args = local_runtime.provision_instance.call_args.args
self.assertEqual(
local_provision_args[:2], ({"name": "Local", "provider": "local"}, 0)
)
# Local jobs get a pre-reserved port (8000 is the first free port).
self.assertEqual(local_provision_args[2]("localhost"), 8000)
local_runtime.bootstrap_instance.assert_called_once_with(
{}, {"name": "Local", "provider": "local"}, 0
)
ec2_runtime.provision_instance.assert_called_once_with(
{"name": "Remote", "provider": "EC2", "instance_type": "t3.small"},
0,
manager._next_host_port,
ec2_runtime.provision_instance.assert_called_once()
ec2_provision_args = ec2_runtime.provision_instance.call_args.args
self.assertEqual(
ec2_provision_args[:2],
({"name": "Remote", "provider": "EC2", "instance_type": "t3.small"}, 0),
)
self.assertIsNone(ec2_provision_args[2]("10.0.0.30"))
ec2_runtime.bootstrap_instance.assert_called_once_with(
{}, {"name": "Remote", "provider": "EC2", "instance_type": "t3.small"}, 0
)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_runtime_sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ def get(self, name):
execution_time REAL,
cpu_resource REAL,
gpu_resource REAL,
created_at TEXT,
updated_at TEXT
created_at REAL,
updated_at REAL
)
"""

Expand Down Expand Up @@ -90,7 +90,7 @@ def test_pull_and_upsert(self):
text("SELECT * FROM runtime_information WHERE future_id='abc'")
).fetchone()
self.assertEqual(row[4], 8.0)
self.assertEqual(row[8], "9.0")
self.assertEqual(row[8], 9.0)
for value in row:
self.assertNotIn(value, (None, ""))

Expand Down
10 changes: 10 additions & 0 deletions ventis/controller/cloud_provider_logic/EC2/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
they can read config and reuse the controller's Docker/Redis logic.
"""

import logging
import os
import shlex
import socket
Expand All @@ -24,6 +25,8 @@

from ventis.utils.redis_client import RedisClient

logger = logging.getLogger(__name__)

CONTAINER_PORT = 50051
DEFAULT_SSH_KEY_PATH = os.path.expanduser("~/.ssh/ventis_ec2")
_controller: Any = None
Expand Down Expand Up @@ -225,13 +228,20 @@ def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port):
if spec.get("type") == "workflow":
port_args += ["-p", f"{spec.get('api_port', 8080)}:8080"]

logger.info("Transferring image %s to %s", image, host)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Log a redacted transfer command if command-level diagnostics are required.

This message records only the image and host, not the actual SSH destination/options. Add structured, redacted command details while omitting the private-key path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ventis/controller/cloud_provider_logic/EC2/_runtime.py` at line 231, Update
the logging around the image transfer in the relevant EC2 runtime flow to
include structured, redacted SSH command details, including the destination and
options, while explicitly omitting the private-key path. Preserve the existing
image and host context in the log message and ensure sensitive command arguments
are not emitted.

result = subprocess.run(
"set -o pipefail; "
f"docker save {shlex.quote(image)} | ssh -o StrictHostKeyChecking=no "
f"-o IdentitiesOnly=yes -i {shlex.quote(key)} "
f"{shlex.quote(f'{ssh_user}@{host}')} 'sudo docker load'",
Comment on lines 234 to 236

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Restore SSH host-key verification.

StrictHostKeyChecking=no accepts any server host key, so the image can be transferred to an unintended endpoint without cryptographic host authentication. Configure a managed known_hosts entry and use strict verification for this provisioning path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ventis/controller/cloud_provider_logic/EC2/_runtime.py` around lines 234 -
236, Update the SSH command in the image transfer flow to remove
StrictHostKeyChecking=no and enable strict host-key verification. Configure and
reference a managed known_hosts file or entry for the target host, preserving
the existing key-based authentication and docker save/load pipeline.

shell=True,
capture_output=True,
text=True,
executable="/bin/bash",
)
Comment on lines 232 to +241

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add a timeout to the image-transfer subprocess.

A stalled SSH handshake, sudo docker load, or pipeline can block a ThreadPoolExecutor worker indefinitely and prevent provisioning from completing. Pass a configured timeout and convert subprocess.TimeoutExpired into a failure so the existing instance-cleanup path runs.

🧰 Tools
🪛 OpenGrep (1.25.0)

[ERROR] 232-241: Dynamic command passed to subprocess with shell=True. Use a command list without shell=True, or use shlex.quote() to sanitize input.

(coderabbit.command-injection.python-shell-true)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ventis/controller/cloud_provider_logic/EC2/_runtime.py` around lines 232 -
241, Add a configured timeout to the image-transfer subprocess.run call in the
image-transfer flow, and catch subprocess.TimeoutExpired to convert the timeout
into the same failure path used for other transfer errors so instance cleanup
still executes. Preserve the existing command and successful transfer behavior.

logger.info(
"docker save|load returncode=%s stdout=%s stderr=%s",
result.returncode, result.stdout, result.stderr,
Comment on lines +242 to +244

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Bound and sanitize transfer output before logging.

Remote stdout and stderr are logged verbatim at INFO without a size limit, allowing large or multiline output to flood or obscure logs. Truncate and escape the captured output, while retaining full details only at an explicitly enabled diagnostic level.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ventis/controller/cloud_provider_logic/EC2/_runtime.py` around lines 242 -
244, Update the logging around the docker save/load result to sanitize and
size-limit result.stdout and result.stderr before INFO logging, escaping
multiline content and truncating oversized output. Preserve complete transfer
output only when the explicitly enabled diagnostic logging level is active.

)
if result.returncode != 0:
raise RuntimeError(f"Failed to transfer image to {host}: {result.stderr}")
Expand Down
78 changes: 68 additions & 10 deletions ventis/controller/instance_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"""

import json
import os
from concurrent.futures import ThreadPoolExecutor, as_completed

from ventis.controller.cloud_provider_logic.Local import _runtime as local_runtime

Expand All @@ -29,6 +31,8 @@ def redis(self):
def ensure_instances(self, agent_specs):
self._agent_specs = list(agent_specs)
instances = []
existing = []
jobs = []

for agent_spec in self._agent_specs:
agent_name = agent_spec["name"]
Expand All @@ -45,22 +49,65 @@ def ensure_instances(self, agent_specs):
key = self._instance_key(provider, agent_name, replica_index)
instance = self.redis.hgetall(key)

if not instance:
provisioned = runtime.provision_instance(
agent_spec, replica_index, self._next_host_port
if instance and instance.get("runtime_id"):
existing.append((agent_name, instance_id, instance))
continue

reserved_port = None
if provider == "local":
host = agent_spec.get("host", local_runtime.DEFAULT_HOST)
reserved_port = self._next_host_port(
host, key, agent_name, provider, replica_index
)
instance = runtime.bootstrap_instance(
provisioned, agent_spec, replica_index

jobs.append(
{
"agent_name": agent_name,
"agent_spec": agent_spec,
"runtime": runtime,
"replica_index": replica_index,
"instance_id": instance_id,
"reserved_port": reserved_port,
}
)

max_workers = int(os.environ.get("VENTIS_MAX_AGENT_INSTANCES", 8))
provisioned = []
if jobs:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_job = {
executor.submit(self._provision_one, job): job for job in jobs
}
for future in as_completed(future_to_job):
job = future_to_job[future]
instance = future.result()
provisioned.append(
(job["agent_name"], job["instance_id"], instance)
)
self._write_instance(instance)

self._add_instance_to_agent(agent_name, instance_id)
self._track_runtime(agent_name, instance["runtime_id"])
instances.append(instance)
for agent_name, instance_id, instance in existing + provisioned:
self._add_instance_to_agent(agent_name, instance_id)
self._track_runtime(agent_name, instance["runtime_id"])
instances.append(instance)
Comment on lines +74 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unhandled/timeout-less future.result() aborts the whole batch on a single failure.

future.result() is called with no timeout and no per-future exception handling. If any one job (a slow EC2 boot, SSH hang, or docker failure) raises or blocks, it either hangs ensure_instances indefinitely or propagates immediately and skips _add_instance_to_agent/_track_runtime/publish_routing_snapshot for every other job that already completed successfully in this call (their Redis records are written via _write_instance inside _provision_one, but the aggregation/registration step at Line 88 is never reached). Callers get an unhandled exception instead of a partial result, and the routing snapshot is never republished even though other replicas came up fine.

🔧 Proposed fix: isolate failures per job
                 for future in as_completed(future_to_job):
                     job = future_to_job[future]
-                    instance = future.result()
-                    provisioned.append(
-                        (job["agent_name"], job["instance_id"], instance)
-                    )
+                    try:
+                        instance = future.result()
+                    except Exception:
+                        logger.exception(
+                            "Provisioning failed for %s replica %s",
+                            job["agent_name"], job["replica_index"],
+                        )
+                        continue
+                    provisioned.append(
+                        (job["agent_name"], job["instance_id"], instance)
+                    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
max_workers = int(os.environ.get("VENTIS_MAX_AGENT_INSTANCES", 8))
provisioned = []
if jobs:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_job = {
executor.submit(self._provision_one, job): job for job in jobs
}
for future in as_completed(future_to_job):
job = future_to_job[future]
instance = future.result()
provisioned.append(
(job["agent_name"], job["instance_id"], instance)
)
self._write_instance(instance)
self._add_instance_to_agent(agent_name, instance_id)
self._track_runtime(agent_name, instance["runtime_id"])
instances.append(instance)
for agent_name, instance_id, instance in existing + provisioned:
self._add_instance_to_agent(agent_name, instance_id)
self._track_runtime(agent_name, instance["runtime_id"])
instances.append(instance)
max_workers = int(os.environ.get("VENTIS_MAX_AGENT_INSTANCES", 8))
provisioned = []
if jobs:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_job = {
executor.submit(self._provision_one, job): job for job in jobs
}
for future in as_completed(future_to_job):
job = future_to_job[future]
try:
instance = future.result()
except Exception:
logger.exception(
"Provisioning failed for %s replica %s",
job["agent_name"], job["replica_index"],
)
continue
provisioned.append(
(job["agent_name"], job["instance_id"], instance)
)
for agent_name, instance_id, instance in existing + provisioned:
self._add_instance_to_agent(agent_name, instance_id)
self._track_runtime(agent_name, instance["runtime_id"])
instances.append(instance)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ventis/controller/instance_manager.py` around lines 74 - 91, Update the
concurrent provisioning flow around _provision_one and future.result() to
isolate failures per job: apply the existing provisioning timeout policy, catch
and record/log each future’s exception without aborting ensure_instances, and
continue aggregating successful instances so _add_instance_to_agent,
_track_runtime, and the later routing snapshot still run for completed jobs.


self.publish_routing_snapshot(self._agent_specs)
return instances

def _provision_one(self, job):
runtime = job["runtime"]
agent_spec = job["agent_spec"]
replica_index = job["replica_index"]
reserved_port = job["reserved_port"]

next_host_port = lambda host: reserved_port

provisioned = runtime.provision_instance(
agent_spec, replica_index, next_host_port
)
instance = runtime.bootstrap_instance(provisioned, agent_spec, replica_index)
self._write_instance(instance)
return instance

def _write_instance(self, instance):
key = self._instance_key(
instance["provider"], instance["agent_name"], int(instance["replica_index"])
Expand Down Expand Up @@ -128,7 +175,7 @@ def _track_runtime(self, agent_name, runtime_id):
if runtime_id not in containers:
containers.append(runtime_id)

def _next_host_port(self, host):
def _next_host_port(self, host, key, agent_name, provider, replica_index):
used = {
int(instance["host_port"])
for instance in self.list_instances()
Expand All @@ -137,6 +184,17 @@ def _next_host_port(self, host):
port = DEFAULT_HOST_PORT_START
while port in used:
port += 1

self.redis.hset_multiple(
key,
{
"agent_name": agent_name,
"provider": provider,
"replica_index": str(replica_index),
"host": host,
"host_port": str(port),
},
)
return port

@staticmethod
Expand Down
12 changes: 8 additions & 4 deletions ventis/controller/utils/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@

_engine = None

TABLE_NAME = "runtime_information"

_UPSERT = text(
"""
INSERT INTO runtime_information (
f"""
INSERT INTO {TABLE_NAME} (
future_id, session_id, workflow, agent, execution_time,
cpu_resource, gpu_resource, created_at, updated_at
) VALUES (
Expand Down Expand Up @@ -70,6 +72,8 @@ def send_data(
if not fid:
continue
session_id = raw.get("request_id")
if not session_id:
continue
workflow = (
redis_client.get(f"request:{session_id}:workflow")
if redis_client is not None
Expand All @@ -90,7 +94,7 @@ def send_data(
"execution_time": end - start,
"cpu_resource": cpu_resource,
"gpu_resource": gpu_resource,
"created_at": str(start),
"updated_at": str(end),
"created_at": start,
"updated_at": end,
},
)
Loading