Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@
how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured
here.

## Unreleased

### Added
- Added several parameters to the `sdk.agents.v1.get_page` and `sdk.agents.v1.list` methods:
- `serial_number` - the serial number of the agents to match.
- `agent_os_types` - the list of operating systems ("LINUX", "MAC", "WIN") to match.
- `connected_in_last_days` - filter to agents that have connected in this number of days.
- `not_connected_in_last_days` - filter to agents that have not connected in this number of days.
- Added corresponding options to the `incydr agents list` command.
- `--serial-number`
- `--agent-os-types`
- `--connected-in-last-days`
- `--not-connected-in-last-days`

## 2.10.0 - 2026-01-27

### Added
Expand Down
35 changes: 35 additions & 0 deletions src/_incydr_cli/cmds/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ def agents():
default=None,
help="Filter agents that have had agent health modified in the last N days (starting from midnight this morning), where N is the value of the parameter.",
)
@click.option(
"--connected-in-last-days",
Copy link
Contributor

Choose a reason for hiding this comment

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

are --connected-in-last-days and --not-connected-in-last-days able to be used at the same time?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes!

type=int,
default=None,
help="When specified, agents are filtered to include only those that have connected in the last N days (starting from midnight this morning), where N is the value of the parameter.",
)
@click.option(
"--not-connected-in-last-days",
type=int,
default=None,
help="When specified, agents are filtered to include only those that have not connected in the last N days (starting from midnight this morning), where N is the value of the parameter.",
)
@click.option(
"--serial-number",
type=str,
default=None,
help="When specified, returns agents that have this serial number.",
Copy link
Contributor

Choose a reason for hiding this comment

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

minor; will more than one agent ever match a given serial number?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, you can for example have multiple agent installs sequentially on the same machine (different guids, so different entries on our side, but each will have the same serial number).

)
@click.option(
"--agent-os-types",
type=str,
default=None,
help="When specified, agents are filtered to include only those of the given OS types. Pass a comma-delimited list of the OS types you wish to search. OS types include the following: WINDOWS, MAC, LINUX.",
)
@table_format_option
@columns_option
@logging_options
Expand All @@ -74,6 +98,10 @@ def list_(
healthy: bool = None,
unhealthy: str = None,
agent_health_modified_within_days: int = None,
connected_in_last_days: int = None,
not_connected_in_last_days: int = None,
serial_number: int = None,
agent_os_types: str = None,
format_: TableFormat = None,
columns: str = None,
):
Expand All @@ -91,13 +119,20 @@ def list_(
): # If the unhealthy value is FLAG_VALUE then we know the option was passed with no values
health_issues = unhealthy.split(",")

if agent_os_types:
agent_os_types = agent_os_types.split(",")

client = Client()

agents = client.agents.v1.iter_all(
active=active,
agent_healthy=agent_healthy,
agent_health_issue_types=health_issues,
agent_health_modified_in_last_days=agent_health_modified_within_days,
connected_in_last_days=connected_in_last_days,
not_connected_in_last_days=not_connected_in_last_days,
serial_number=serial_number,
agent_os_types=agent_os_types,
)

if format_ == TableFormat.table:
Expand Down
22 changes: 22 additions & 0 deletions src/_incydr_sdk/agents/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ def get_page(
agent_health_issue_types: Union[List[str], str] = None,
agent_health_modified_in_last_days: Optional[int] = None,
user_id: str = None,
connected_in_last_days: int = None,
not_connected_in_last_days: int = None,
serial_number: str = None,
agent_os_types: Union[List[str], str] = None,
) -> AgentsPage:
"""
Get a page of agents.
Expand All @@ -59,6 +63,10 @@ def get_page(
* **agent_health_issue_types**: `List[str] | str` - Optionally retrieve agents that have (at least) any of the given issue type(s). Health issue types include the following: `NOT_CONNECTING`, `NOT_SENDING_SECURITY_EVENTS`, `SECURITY_INGEST_REJECTED`, `MISSING_MACOS_PERMISSION_FULL_DISK_ACCESS`, `MISSING_MACOS_PERMISSION_ACCESSIBILITY`.
* **agent_health_modified_in_last_days**: `int | None` - Optionally retrieve agents that have had their agent health modified in the last N days.
* **user_id**: `str` - Optionally retrieve only agents associated with this user ID.
* **connected_in_last_days**: `int` - When specified, agents are filtered to include only those that have connected in the last N days (starting from midnight this morning), where N is the value of the parameter.
* **not_connected_in_last_days**: `int` - When specified, agents are filtered to include only those that have not connected in the last N days (starting from midnight this morning), where N is the value of the parameter.
* **serial_number**: `str` - When specified, returns agents that have this serial number.
* **agent_os_types: `List[str] | str` - When specified, agents are filtered to include only those of the given OS types.

**Returns**: An [`AgentsPage`][agentspage-model] object.
"""
Expand All @@ -75,6 +83,12 @@ def get_page(
pageSize=page_size,
page=page_num,
userId=user_id,
connectedInLastDays=connected_in_last_days,
notConnectedInLastDays=not_connected_in_last_days,
serialNumber=serial_number,
anyOfAgentOsTypes=[agent_os_types]
if isinstance(agent_os_types, str)
else agent_os_types,
)
response = self._parent.session.get("/v1/agents", params=data.dict())
return AgentsPage.parse_response(response)
Expand All @@ -90,6 +104,10 @@ def iter_all(
agent_health_issue_types: List[str] = None,
agent_health_modified_in_last_days: Optional[int] = None,
user_id: str = None,
connected_in_last_days: int = None,
not_connected_in_last_days: int = None,
serial_number: str = None,
agent_os_types: Union[List[str], str] = None,
) -> Iterator[Agent]:
"""
Iterate over all agents.
Expand All @@ -110,6 +128,10 @@ def iter_all(
page_num=page_num,
page_size=page_size,
user_id=user_id,
connected_in_last_days=connected_in_last_days,
not_connected_in_last_days=not_connected_in_last_days,
serial_number=serial_number,
agent_os_types=agent_os_types,
)
yield from page.agents
if len(page.agents) < page_size:
Expand Down
8 changes: 6 additions & 2 deletions src/_incydr_sdk/agents/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,20 @@ class AgentUpdateRequest(BaseModel):


class QueryAgentsRequest(BaseModel):
active: Optional[bool] = None
userId: Optional[str] = None
agentType: Optional[Union[AgentType, str]] = None
active: Optional[bool] = None
connectedInLastDays: Optional[int] = None
notConnectedInLastDays: Optional[int] = None
agentHealthy: Optional[bool] = None
anyOfAgentHealthIssueTypes: Optional[List[str]] = None
agentHealthModifiedInLastDays: Optional[int] = None
serialNumber: Optional[str] = None
anyOfAgentOsTypes: Optional[List[str]] = None
srtKey: Optional[Union[SortKeys, str]] = None
srtDir: Optional[str] = None
pageSize: Optional[int] = None
page: Optional[int] = None
userId: Optional[str] = None

@field_validator("srtDir")
@classmethod
Expand Down
16 changes: 15 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,22 @@


@pytest.fixture(autouse=True)
def clean_environment(mocker):
def clean_environment(mocker, tmp_path, monkeypatch):
mocker.patch.dict(os.environ, clear=True)
# Change to temp directory to prevent reading project's .env file
monkeypatch.chdir(tmp_path)

# Patch IncydrSettings to disable reading .env files entirely
from _incydr_sdk.core.settings import IncydrSettings

_original_init = IncydrSettings.__init__

def _patched_init(self, **kwargs):
# Force _env_file="" to disable env file reading
kwargs["_env_file"] = ""
_original_init(self, **kwargs)

monkeypatch.setattr(IncydrSettings, "__init__", _patched_init)


@pytest.fixture(scope="session")
Expand Down
156 changes: 155 additions & 1 deletion tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,122 @@ def test_get_page_when_agent_health_modified_in_last_days_passed_makes_expected_
assert page.total_count == len(page.agents) == 2


def test_get_page_when_connected_in_last_days_passed_makes_expected_call(
httpserver_auth: HTTPServer,
):
query = {
"connectedInLastDays": 7,
"srtKey": "NAME",
"srtDir": "ASC",
"pageSize": 500,
"page": 1,
}

agents_data = {
"agents": [TEST_AGENT_1, TEST_AGENT_2],
"totalCount": 2,
"pageSize": 500,
"page": 1,
}
httpserver_auth.expect_request(
uri="/v1/agents", method="GET", query_string=urlencode(query)
).respond_with_json(agents_data)

client = Client()
page = client.agents.v1.get_page(connected_in_last_days=7)
assert isinstance(page, AgentsPage)
assert page.agents[0].json() == json.dumps(TEST_AGENT_1, separators=(",", ":"))
assert page.agents[1].json() == json.dumps(TEST_AGENT_2, separators=(",", ":"))
assert page.total_count == len(page.agents) == 2


def test_get_page_when_not_connected_in_last_days_passed_makes_expected_call(
httpserver_auth: HTTPServer,
):
query = {
"notConnectedInLastDays": 7,
"srtKey": "NAME",
"srtDir": "ASC",
"pageSize": 500,
"page": 1,
}

agents_data = {
"agents": [TEST_AGENT_1, TEST_AGENT_2],
"totalCount": 2,
"pageSize": 500,
"page": 1,
}
httpserver_auth.expect_request(
uri="/v1/agents", method="GET", query_string=urlencode(query)
).respond_with_json(agents_data)

client = Client()
page = client.agents.v1.get_page(not_connected_in_last_days=7)
assert isinstance(page, AgentsPage)
assert page.agents[0].json() == json.dumps(TEST_AGENT_1, separators=(",", ":"))
assert page.agents[1].json() == json.dumps(TEST_AGENT_2, separators=(",", ":"))
assert page.total_count == len(page.agents) == 2


def test_get_page_when_serial_number_passed_makes_expected_call(
httpserver_auth: HTTPServer,
):
query = {
"serialNumber": "example",
"srtKey": "NAME",
"srtDir": "ASC",
"pageSize": 500,
"page": 1,
}

agents_data = {
"agents": [TEST_AGENT_1, TEST_AGENT_2],
"totalCount": 2,
"pageSize": 500,
"page": 1,
}
httpserver_auth.expect_request(
uri="/v1/agents", method="GET", query_string=urlencode(query)
).respond_with_json(agents_data)

client = Client()
page = client.agents.v1.get_page(serial_number="example")
assert isinstance(page, AgentsPage)
assert page.agents[0].json() == json.dumps(TEST_AGENT_1, separators=(",", ":"))
assert page.agents[1].json() == json.dumps(TEST_AGENT_2, separators=(",", ":"))
assert page.total_count == len(page.agents) == 2


def test_get_page_when_agent_os_types_passed_makes_expected_call(
httpserver_auth: HTTPServer,
):
query = {
"anyOfAgentOsTypes": ["LINUX", "MAC"],
"srtKey": "NAME",
"srtDir": "ASC",
"pageSize": 500,
"page": 1,
}

agents_data = {
"agents": [TEST_AGENT_1, TEST_AGENT_2],
"totalCount": 2,
"pageSize": 500,
"page": 1,
}
httpserver_auth.expect_request(
uri="/v1/agents", method="GET", query_string=urlencode(query, doseq=True)
).respond_with_json(agents_data)

client = Client()
page = client.agents.v1.get_page(agent_os_types=["LINUX", "MAC"])
assert isinstance(page, AgentsPage)
assert page.agents[0].json() == json.dumps(TEST_AGENT_1, separators=(",", ":"))
assert page.agents[1].json() == json.dumps(TEST_AGENT_2, separators=(",", ":"))
assert page.total_count == len(page.agents) == 2


def test_iter_all_when_default_params_returns_expected_data(
httpserver_auth: HTTPServer,
):
Expand Down Expand Up @@ -303,6 +419,7 @@ def test_cli_list_when_custom_params_makes_expected_call(
):
query = {
"active": True,
"notConnectedInLastDays": 5,
"agentHealthy": True,
"srtKey": "NAME",
"srtDir": "ASC",
Expand All @@ -320,7 +437,17 @@ def test_cli_list_when_custom_params_makes_expected_call(
uri="/v1/agents", method="GET", query_string=urlencode(query)
).respond_with_json(agents_data)

result = runner.invoke(incydr, ["agents", "list", "--active", "--healthy"])
result = runner.invoke(
incydr,
[
"agents",
"list",
"--active",
"--healthy",
"--not-connected-in-last-days",
"5",
],
)
httpserver_auth.check()
assert result.exit_code == 0

Expand Down Expand Up @@ -381,6 +508,33 @@ def test_cli_list_when_unhealthy_option_passed_with_string_parses_issue_types_co
assert result.exit_code == 0


def test_cli_list_when_agent_os_types_passed_with_string_parses_os_types_correctly(
httpserver_auth: HTTPServer, runner
):
query = [
("anyOfAgentOsTypes", "LINUX"),
Copy link
Contributor

Choose a reason for hiding this comment

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

The query for the other one looked like this: "anyOfAgentOsTypes": ["LINUX", "MAC"],. Are both valid and expected?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, the way that we handle this is when a list is given, we pass the parameter twice, so the url ends up looking like /endpoint?anyOfAgentOsTypes=LINUX&anyOfAgentOsTypes=MAC and so on. The other test tests a query where we give it two values (because of how python dicts work, it's in the serialization logic where this gets converted, otherwise you can't have the same key twice), and here we're only passing one.

Both work successfully in test and in practice.

("anyOfAgentOsTypes", "MAC"),
("srtKey", "NAME"),
("srtDir", "ASC"),
("pageSize", 500),
("page", 1),
]

agents_data = {
"agents": [TEST_AGENT_1, TEST_AGENT_2],
"totalCount": 2,
"pageSize": 500,
"page": 1,
}
httpserver_auth.expect_request(
uri="/v1/agents", method="GET", query_string=urlencode(query)
).respond_with_json(agents_data)

result = runner.invoke(incydr, ["agents", "list", "--agent-os-types", "LINUX,MAC"])
httpserver_auth.check()
assert result.exit_code == 0


def test_cli_list_when_health_modified_days_option_passed_makes_expected_call(
httpserver_auth: HTTPServer, runner
):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,11 +278,11 @@ def mock_get_devices(httpserver_auth: HTTPServer):
@pytest.fixture
def mock_get_agents(httpserver_auth: HTTPServer):
query = {
"userId": TEST_USER_ID,
"srtKey": "NAME",
"srtDir": "ASC",
"pageSize": 500,
"page": 1,
"userId": TEST_USER_ID,
}
agents_data = {
"agents": [TEST_AGENT_1, TEST_AGENT_2],
Expand Down