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
3 changes: 2 additions & 1 deletion examples/exec_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
print(f"\rConnected: {stats.total_daemons} | Platforms: {stats.by_platform}", end="", flush=True)

if daemons and len(daemons) > 0:
for daemon_id in daemons:
for daemon in daemons:
daemon_id = daemon.id
try:
# Test 1: Python script
result = server.exec(
Expand Down
5 changes: 3 additions & 2 deletions examples/install_htop.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ def main():
time.sleep(1)
daemons = server.list_daemons()

daemon_id = daemons[0]
print(f"✓ Found daemon: {daemon_id}\n")
daemon = daemons[0]
daemon_id = daemon.id
print(f"✓ Found daemon: {daemon_id} (version={daemon.version})\n")

# Check if htop is available
print("Checking if htop is available...")
Expand Down
6 changes: 3 additions & 3 deletions examples/interactive_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ def main():
time.sleep(1)
daemons = server.list_daemons()

daemon_id = daemons[0]
print(f"✓ Found daemon: {daemon_id}\n")
daemon = daemons[0]
print(f"✓ Found daemon: {daemon.id} (version={daemon.version})\n")

print("Starting interactive terminal...")
print()

# Start session in interactive mode - this blocks until user exits
server.new_session(daemon_id, interactive=True)
server.new_session(daemon.id, interactive=True)


if __name__ == "__main__":
Expand Down
3 changes: 2 additions & 1 deletion python/sandd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
... )
"""

from .models import CommandResult, ServerStats
from .models import CommandResult, ServerStats, DaemonInfo
from .server import Server
from .async_server import AsyncServer

Expand All @@ -57,4 +57,5 @@
"Session",
"CommandResult",
"ServerStats",
"DaemonInfo",
]
29 changes: 29 additions & 0 deletions python/sandd/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,35 @@ def __repr__(self) -> str:
)


class DaemonInfo:
"""Information about a connected daemon

Attributes:
id: Daemon identifier
version: Daemon version string
labels: Key-value labels for filtering
is_busy: Whether daemon has pending commands
"""

def __init__(
self,
id: str,
version: str,
labels: Dict[str, str],
is_busy: bool,
):
self.id = id
self.version = version
self.labels = labels
self.is_busy = is_busy

def __repr__(self) -> str:
return (
f"DaemonInfo(id={self.id!r}, version={self.version!r}, "
f"labels={self.labels}, is_busy={self.is_busy})"
)


class ServerStats:
"""Server statistics

Expand Down
34 changes: 24 additions & 10 deletions python/sandd/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import sys
import select

from .models import CommandResult, ServerStats
from .models import CommandResult, ServerStats, DaemonInfo

try:
from ._core import Server as _RustServer, Session
Expand Down Expand Up @@ -185,30 +185,41 @@ def download_file(
def list_daemons(
self,
labels: Optional[Dict[str, str]] = None,
) -> List[str]:
"""List all connected daemon IDs, optionally filtered by labels
) -> List[DaemonInfo]:
"""List all connected daemons with their information, optionally filtered by labels
Comment thread
kerthcet marked this conversation as resolved.

Args:
labels: Dictionary of label key-value pairs to filter by (AND logic)
All specified labels must match for a daemon to be included

Returns:
List of daemon IDs
List of DaemonInfo objects

Example:
>>> # List all daemons
>>> daemons = server.list_daemons()
>>> print(f"Connected: {len(daemons)} daemons")
>>> for daemon in daemons:
... print(f" {daemon.id}: {daemon.version}, busy={daemon.is_busy}")
>>>
>>> # List daemons with single label
>>> prod_daemons = server.list_daemons(labels={"env": "prod"})
>>>
>>> # List daemons with multiple labels (AND logic)
>>> west_prod = server.list_daemons(labels={"env": "prod", "region": "us-west"})
>>> for daemon_id in west_prod:
... print(f" - {daemon_id}")
>>> for daemon in west_prod:
... print(f" - {daemon.id} ({daemon.labels})")
"""
return self._server.list_daemons(labels)
py_infos = self._server.list_daemons(labels)
return [
DaemonInfo(
id=info.id,
version=info.version,
labels=info.labels,
is_busy=info.is_busy,
)
for info in py_infos
]

def daemon_count(self) -> int:
"""Get number of connected daemons
Expand Down Expand Up @@ -339,10 +350,12 @@ def broadcast(
import concurrent.futures

# Get matching daemons
daemon_ids = self.list_daemons(labels=labels)
if not daemon_ids:
daemons = self.list_daemons(labels=labels)
if not daemons:
return {}

daemon_ids = [d.id for d in daemons]

# Execute command on all daemons concurrently
def run_command(daemon_id):
try:
Expand Down Expand Up @@ -390,7 +403,8 @@ def wait_for_daemon(
"""
start = time.time()
while time.time() - start < timeout:
if daemon_id in self.list_daemons():
daemons = self.list_daemons()
if any(d.id == daemon_id for d in daemons):
return True
time.sleep(poll_interval)
return False
Expand Down
32 changes: 19 additions & 13 deletions python/tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,14 @@ class TestE2EBasicOperations:
def test_all_daemons_connected(self, server):
"""Verify all 6 daemons connected (2 debian + 2 alpine + 2 rocky)"""
daemons = server.list_daemons()
daemon_ids = [d.id for d in daemons]
expected = [
"daemon-debian-1", "daemon-debian-2",
"daemon-alpine-1", "daemon-alpine-2",
"daemon-rocky-1", "daemon-rocky-2"
]
for daemon_id in expected:
assert daemon_id in daemons
assert daemon_id in daemon_ids
assert server.daemon_count() >= 6

def test_execute_on_each_daemon(self, server):
Expand Down Expand Up @@ -224,29 +225,34 @@ class TestE2ELabels:
def test_filter_by_env_label(self, server):
"""Filter daemons by env label"""
test_daemons = server.list_daemons(labels={"env": "test"})
assert "daemon-debian-1" in test_daemons
assert "daemon-debian-2" in test_daemons
assert "daemon-alpine-1" in test_daemons
assert "daemon-rocky-2" in test_daemons
test_ids = [d.id for d in test_daemons]
assert "daemon-debian-1" in test_ids
assert "daemon-debian-2" in test_ids
assert "daemon-alpine-1" in test_ids
assert "daemon-rocky-2" in test_ids

prod_daemons = server.list_daemons(labels={"env": "prod"})
assert "daemon-alpine-2" in prod_daemons
assert "daemon-rocky-1" in prod_daemons
prod_ids = [d.id for d in prod_daemons]
assert "daemon-alpine-2" in prod_ids
assert "daemon-rocky-1" in prod_ids

def test_filter_by_distro_label(self, server):
"""Filter daemons by distribution"""
debian_daemons = server.list_daemons(labels={"distro": "debian"})
assert "daemon-debian-1" in debian_daemons
assert "daemon-debian-2" in debian_daemons
debian_ids = [d.id for d in debian_daemons]
assert "daemon-debian-1" in debian_ids
assert "daemon-debian-2" in debian_ids
assert len(debian_daemons) >= 2

alpine_daemons = server.list_daemons(labels={"distro": "alpine"})
assert "daemon-alpine-1" in alpine_daemons
assert "daemon-alpine-2" in alpine_daemons
alpine_ids = [d.id for d in alpine_daemons]
assert "daemon-alpine-1" in alpine_ids
assert "daemon-alpine-2" in alpine_ids

rocky_daemons = server.list_daemons(labels={"distro": "rocky"})
assert "daemon-rocky-1" in rocky_daemons
assert "daemon-rocky-2" in rocky_daemons
rocky_ids = [d.id for d in rocky_daemons]
assert "daemon-rocky-1" in rocky_ids
assert "daemon-rocky-2" in rocky_ids


class TestE2EResilience:
Expand Down
31 changes: 19 additions & 12 deletions python/tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ def test_daemon_connects(self, server, daemon_process):

# Verify daemon is in the list
daemons = server.list_daemons()
assert daemon_id in daemons
daemon_ids = [d.id for d in daemons]
assert daemon_id in daemon_ids

# Verify daemon count
assert server.daemon_count() == 1
Expand Down Expand Up @@ -109,9 +110,10 @@ def test_multiple_daemons_connect(self, server, sandd_binary):

# Verify all connected
daemons = server.list_daemons()
daemon_id_list = [d.id for d in daemons]
assert server.daemon_count() == 3
for daemon_id in daemon_ids:
assert daemon_id in daemons
assert daemon_id in daemon_id_list

finally:
# Cleanup all daemons
Expand Down Expand Up @@ -160,29 +162,34 @@ def test_daemon_with_labels(self, server, sandd_binary):

# Test: list all daemons (no filter)
all_daemons = server.list_daemons()
assert daemon_id_prod in all_daemons
assert daemon_id_dev in all_daemons
all_ids = [d.id for d in all_daemons]
assert daemon_id_prod in all_ids
assert daemon_id_dev in all_ids
assert len(all_daemons) >= 2

# Test: filter by env=prod
prod_daemons = server.list_daemons(labels={"env": "prod"})
assert daemon_id_prod in prod_daemons
assert daemon_id_dev not in prod_daemons
prod_ids = [d.id for d in prod_daemons]
assert daemon_id_prod in prod_ids
assert daemon_id_dev not in prod_ids

# Test: filter by env=dev
dev_daemons = server.list_daemons(labels={"env": "dev"})
assert daemon_id_dev in dev_daemons
assert daemon_id_prod not in dev_daemons
dev_ids = [d.id for d in dev_daemons]
assert daemon_id_dev in dev_ids
assert daemon_id_prod not in dev_ids

# Test: filter by region=us-west
region_daemons = server.list_daemons(labels={"region": "us-west"})
assert daemon_id_prod in region_daemons
assert daemon_id_dev not in region_daemons
region_ids = [d.id for d in region_daemons]
assert daemon_id_prod in region_ids
assert daemon_id_dev not in region_ids

# Test: filter by multiple labels (AND logic)
west_prod = server.list_daemons(labels={"env": "prod", "region": "us-west"})
assert daemon_id_prod in west_prod
assert daemon_id_dev not in west_prod
west_prod_ids = [d.id for d in west_prod]
assert daemon_id_prod in west_prod_ids
assert daemon_id_dev not in west_prod_ids

# Test: filter by non-existent label
none_daemons = server.list_daemons(labels={"env": "staging"})
Expand Down
33 changes: 31 additions & 2 deletions server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,22 @@ impl Server {

/// List all connected daemons, optionally filtered by labels
#[pyo3(signature = (labels=None))]
fn list_daemons(&self, labels: Option<HashMap<String, String>>) -> PyResult<Vec<String>> {
Ok(self.registry.list_all(labels.as_ref()))
fn list_daemons(&self, labels: Option<HashMap<String, String>>) -> PyResult<Vec<PyDaemonInfo>> {
let daemon_ids = self.registry.list_all(labels.as_ref());
let mut result = Vec::with_capacity(daemon_ids.len());

for daemon_id in daemon_ids {
if let Some(conn) = self.registry.get(&daemon_id) {
result.push(PyDaemonInfo {
Comment thread
kerthcet marked this conversation as resolved.
id: conn.id.clone(),
version: conn.metadata.version.clone(),
labels: conn.metadata.labels.clone(),
is_busy: conn.is_busy(),
});
}
}

Ok(result)
}

/// Get daemon count
Expand Down Expand Up @@ -325,6 +339,20 @@ impl Session {
}
}

/// Daemon information
#[pyclass]
#[derive(Clone)]
pub struct PyDaemonInfo {
#[pyo3(get)]
pub id: String,
#[pyo3(get)]
pub version: String,
#[pyo3(get)]
pub labels: HashMap<String, String>,
#[pyo3(get)]
pub is_busy: bool,
}

/// Command execution result
#[pyclass]
#[derive(Clone)]
Expand Down Expand Up @@ -370,6 +398,7 @@ fn _core(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_class::<Server>()?;
m.add_class::<Session>()?;
m.add_class::<PyCommandResult>()?;
m.add_class::<PyDaemonInfo>()?;
m.add_class::<PyStats>()?;
Ok(())
}
Loading