diff --git a/docs/api_reference.rst b/docs/api_reference.rst index e3bd991a..bf0e8ecd 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -14,6 +14,7 @@ API Reference api_reference/dataframe api_reference/spec api_reference/file + api_reference/system Indices and tables ------------------ diff --git a/docs/api_reference/system.rst b/docs/api_reference/system.rst new file mode 100644 index 00000000..ed6440ec --- /dev/null +++ b/docs/api_reference/system.rst @@ -0,0 +1,18 @@ +.. _api_tag_page: + +nisystemlink.clients.system +====================== + +.. autoclass:: nisystemlink.clients.system.SystemClient + :exclude-members: __init__ + + .. automethod:: __init__ + .. automethod:: list_jobs + .. automethod:: create_job + .. automethod:: get_job_summary + .. automethod:: query_jobs + .. automethod:: cancel_jobs + +.. automodule:: nisystemlink.clients.system.models + :members: + :imported-members: diff --git a/docs/getting_started.rst b/docs/getting_started.rst index ffdb564a..7d46fb9b 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -210,5 +210,33 @@ Examples Get the metadata of a File using its Id and download it. .. literalinclude:: ../examples/file/download_file.py + :language: python + :linenos: + + +System API +------- + +Overview +~~~~~~~~ + +The :class:`.SystemClient` class is the primary entry point of the System API. + +When constructing a :class:`.SystemClient`, you can pass an +:class:`.HttpConfiguration` (like one retrieved from the +:class:`.HttpConfigurationManager`), or let :class:`.SystemClient` use the +default connection. The default connection depends on your environment. + +With a :class:`.SystemClient` object, you can: + +* Create, get, query, and cancel jobs +* Get the summary of a jobs + +Examples +~~~~~~~~ + +Create, get, query, and cancel jobs + +.. literalinclude:: ../examples/system/system.py :language: python :linenos: \ No newline at end of file diff --git a/examples/system/job.py b/examples/system/job.py new file mode 100644 index 00000000..dd2464d8 --- /dev/null +++ b/examples/system/job.py @@ -0,0 +1,63 @@ +from nisystemlink.clients.core import HttpConfiguration +from nisystemlink.clients.system import SystemClient +from nisystemlink.clients.system.models import ( + CancelJobRequest, + CreateJobRequest, + JobField, + JobState, + QueryJobsRequest, +) + +# Setup the server configuration to point to your instance of SystemLink Enterprise +server_configuration = HttpConfiguration( + server_uri="https://yourserver.yourcompany.com", + api_key="YourAPIKeyGeneratedFromSystemLink", +) +client = SystemClient(configuration=server_configuration) + +# Get all jobs that have succeeded +jobs = client.list_jobs( + system_id="system_id", + job_id="jid", + state=JobState.SUCCEEDED, + function="function", + skip=0, + take=10, +) + +# Create a job +arguments = [["A description"]] +target_systems = [ + "HVM_domU--SN-ec200972-eeca-062e-5bf5-017a25451b39--MAC-0A-E1-20-D6-96-2B" +] +functions = ["system.set_computer_desc"] +metadata = {"queued": True, "refresh_minion_cache": {"grains": True}} +job = CreateJobRequest( + arguments=arguments, + target_systems=target_systems, + functions=functions, + metadata=metadata, +) + +create_job_response = client.create_job(job) + +# Get job summary +job_summary = client.get_job_summary() + +# Query jobs +query_job = QueryJobsRequest( + skip=0, + take=1000, + filter="result.success.Contains(false)", + projection=[JobField.ID, JobField.SYSTEM_ID, "metadata.queued"], + orderBy=JobField.CREATED_TIMESTAMP, + descending=True, +) +query_jobs_response = client.query_jobs(query_job) + + +# Cancel a job +cancel_job_request = CancelJobRequest( + id=create_job_response.id, system_id=target_systems[0] +) +cancel_job_response = client.cancel_jobs([cancel_job_request]) diff --git a/nisystemlink/clients/core/_uplink/_methods.py b/nisystemlink/clients/core/_uplink/_methods.py index bed04703..646f62ab 100644 --- a/nisystemlink/clients/core/_uplink/_methods.py +++ b/nisystemlink/clients/core/_uplink/_methods.py @@ -17,6 +17,7 @@ def get(path: str, args: Optional[Sequence[Any]] = None) -> Callable[[F], F]: """Annotation for a GET request.""" def decorator(func: F) -> F: + print("get decorator", func) return commands.get(path, args=args)(func) # type: ignore return decorator diff --git a/nisystemlink/clients/system/__init__.py b/nisystemlink/clients/system/__init__.py new file mode 100644 index 00000000..830608c6 --- /dev/null +++ b/nisystemlink/clients/system/__init__.py @@ -0,0 +1,3 @@ +from ._system_client import SystemClient + +# flake8: noqa diff --git a/nisystemlink/clients/system/_system_client.py b/nisystemlink/clients/system/_system_client.py new file mode 100644 index 00000000..1bd30785 --- /dev/null +++ b/nisystemlink/clients/system/_system_client.py @@ -0,0 +1,194 @@ +import json +from typing import List, Optional, Union + +from nisystemlink.clients import core +from nisystemlink.clients.core import ApiError +from nisystemlink.clients.core._uplink._base_client import BaseClient +from nisystemlink.clients.core._uplink._methods import get, post, response_handler +from requests.models import Response +from uplink import Query + +from . import models + + +def _cancel_job_response_handler(response: Response) -> Union[ApiError, None]: + """Response handler for Cancel Job response.""" + if response is None: + return None + + try: + cancel_response = response.json() + except json.JSONDecodeError: + return None + + return cancel_response.get("error") + + +def _list_jobs_response_handler(response: Response) -> List[models.Job]: + """Response handler for List Jobs response.""" + if response is None: + return [] + + jobs = response.json() + + return jobs + + +class SystemClient(BaseClient): + def __init__(self, configuration: Optional[core.HttpConfiguration] = None): + """Initialize an instance. + + Args: + configuration: Defines the web server to connect to and information about + how to connect. If not provided, the + :class:`HttpConfigurationManager ` + is used to obtain the configuration. + + Raises: + ApiException: if unable to communicate with the ``/nisysmgmt`` Service. + """ + if configuration is None: + configuration = core.HttpConfigurationManager.get_configuration() + + super().__init__(configuration, "/nisysmgmt/v1/") + + @response_handler(_list_jobs_response_handler) + @get( + "jobs", + args=[ + Query("systemId"), + Query("jid"), + Query("state"), + Query("function"), + Query("skip"), + Query("take"), + ], + ) + def list_jobs( + self, + system_id: Optional[str] = None, + job_id: Optional[str] = None, + state: Optional[models.JobState] = None, + function: Optional[str] = None, + skip: Optional[int] = None, + take: Optional[int] = None, + ) -> List[models.Job]: + """List the jobs that matched the criteria. + + Args: + system_id: The ID of the system to that the jobs belong. + jid: The ID of the job. + state: The state of the jobs. + function: The salt function to run on the client. + skip: The number of jobs to skip. + take: The number of jobs to return. + + Returns: + The list of jobs that matched the criteria. + + Raises: + ApiException: if unable to communicate with the ``/nisysmgmt`` Service + or provided an invalid argument. + """ + ... + + @post("jobs") + def create_job(self, job: models.CreateJobRequest) -> models.CreateJobResponse: + """Create a job. + + Args: + job: The request to create the job. + + Returns: + The job that was created. + + Raises: + ApiException: if unable to communicate with the ``/nisysmgmt`` Service + or provided an invalid argument. + """ + ... + + @get("get-jobs-summary") + def get_job_summary(self) -> models.JobSummaryResponse: + """Get a summary of the jobs. + + Returns: + An instance of a JobsSummaryResponse. + + Raises: + ApiException: if unable to communicate with the ``/nisysmgmt`` Service + or provided an invalid argument. + """ + ... + + @post("query-jobs") + def _query_jobs(self, query: models._QueryJobsRequest) -> models.QueryJobsResponse: + """Query the jobs. + + Args: + query: The request to query the jobs. + + Returns: + An instance of QueryJobsRequest. + + Raises: + ApiException: if unable to communicate with the ``/nisysmgmt`` Service + or provided an invalid argument. + """ + ... + + def query_jobs(self, query: models.QueryJobsRequest) -> models.QueryJobsResponse: + """Query the jobs. + + Args: + query: The request to query the jobs. + + Returns: + An instance of QueryJobsRequest. + + Raises: + ApiException: if unable to communicate with the ``/nisysmgmt`` Service + or provided an invalid argument. + """ + projection = f"new({','.join(query.projection)})" if query.projection else None + + order_by = ( + f"{query.order_by.value} {'descending' if query.descending else 'ascending'}" + if query.order_by + else None + ) + + query_params = { + "skip": query.skip, + "take": query.take, + "filter": query.filter, + "projection": projection, + "order_by": order_by, + } + + # Clean the query_params to remove any keys with None values + query_params = {k: v for k, v in query_params.items() if v is not None} + + # Create the query request with the cleaned parameters + query_request = models._QueryJobsRequest(**query_params) + + return self._query_jobs(query_request) + + @response_handler(_cancel_job_response_handler) + @post("cancel-jobs") + def cancel_jobs( + self, job_ids: List[models.CancelJobRequest] + ) -> Union[ApiError, None]: + """Cancel the jobs. + + Args: + job_ids: List of CancelJobRequest. + + Returns: + The errors that appear while attempting the operation. + + Raises: + ApiException: if unable to communicate with the ``/nisysmgmt`` Service + or provided an invalid argument. + """ + ... diff --git a/nisystemlink/clients/system/models/__init__.py b/nisystemlink/clients/system/models/__init__.py new file mode 100644 index 00000000..927eacaf --- /dev/null +++ b/nisystemlink/clients/system/models/__init__.py @@ -0,0 +1,9 @@ +from ._job import Job, JobState, JobConfig, JobResult +from ._create_job_request import CreateJobRequest +from ._create_job_response import CreateJobResponse +from ._job_summary_response import JobSummaryResponse +from ._query_jobs_request import QueryJobsRequest, _QueryJobsRequest, JobField +from ._query_jobs_response import QueryJobsResponse, QueryJob, QueryJobConfig +from ._cancel_job_request import CancelJobRequest + +# flake8: noqa diff --git a/nisystemlink/clients/system/models/_cancel_job_request.py b/nisystemlink/clients/system/models/_cancel_job_request.py new file mode 100644 index 00000000..e866c822 --- /dev/null +++ b/nisystemlink/clients/system/models/_cancel_job_request.py @@ -0,0 +1,12 @@ +from nisystemlink.clients.core._uplink._json_model import JsonModel +from pydantic import Field + + +class CancelJobRequest(JsonModel): + """Model for cancel job request.""" + + id: str = Field(alias="jid") + """The ID of the job to cancel.""" + + system_id: str + """The system ID that the job to cancel targets.""" diff --git a/nisystemlink/clients/system/models/_create_job_request.py b/nisystemlink/clients/system/models/_create_job_request.py new file mode 100644 index 00000000..aa410bef --- /dev/null +++ b/nisystemlink/clients/system/models/_create_job_request.py @@ -0,0 +1,20 @@ +from typing import Any, Dict, List, Optional + +from nisystemlink.clients.core._uplink._json_model import JsonModel +from pydantic import Field + + +class CreateJobRequest(JsonModel): + """Model for create job request.""" + + arguments: Optional[List[List[Any]]] = Field(None, alias="arg") + """List of arguments to the functions specified in the "fun" property.""" + + target_systems: List[str] = Field(alias="tgt") + """List of system IDs on which to run the job.""" + + functions: List[str] = Field(alias="fun") + """Functions contained in the job.""" + + metadata: Optional[Dict[str, Any]] = None + """Additional information of the job.""" diff --git a/nisystemlink/clients/system/models/_create_job_response.py b/nisystemlink/clients/system/models/_create_job_response.py new file mode 100644 index 00000000..008f6a12 --- /dev/null +++ b/nisystemlink/clients/system/models/_create_job_response.py @@ -0,0 +1,16 @@ +from typing import Optional + +from nisystemlink.clients.core import ApiError +from pydantic import Field + +from ._create_job_request import CreateJobRequest + + +class CreateJobResponse(CreateJobRequest): + """Model for response of create job request.""" + + error: Optional[ApiError] = None + """Represents the standard error structure.""" + + id: str = Field(alias="jid") + """The job ID.""" diff --git a/nisystemlink/clients/system/models/_job.py b/nisystemlink/clients/system/models/_job.py new file mode 100644 index 00000000..c0e0c6ae --- /dev/null +++ b/nisystemlink/clients/system/models/_job.py @@ -0,0 +1,86 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional + +from nisystemlink.clients.core._uplink._json_model import JsonModel +from pydantic import Field + + +class JobState(str, Enum): + """The state of the job.""" + + SUCCEEDED = "SUCCEEDED" + + OUTOFQUEUE = "OUTOFQUEUE" + + INQUEUE = "INQUEUE" + + INPROGRESS = "INPROGRESS" + + CANCELED = "CANCELED" + + FAILED = "FAILED" + + +class JobConfig(JsonModel): + """The configuration of the job.""" + + user: str + """The user who created the job.""" + + target_systems: List[str] = Field(alias="tgt") + """The target systems for the job.""" + + functions: List[str] = Field(alias="fun") + """Salt functions related to the job.""" + + arguments: Optional[List[List[Any]]] = Field(None, alias="arg") + """Arguments of the salt functions.""" + + +class JobResult(JsonModel): + id: Optional[str] = Field(None, alias="jid") + """The job ID.""" + + system_id: Optional[str] = Field(None, alias="id") + """The ID of the system that the job targets.""" + + return_code: Optional[List[int]] = Field(None, alias="retcode") + """Return code of the job.""" + + return_: Optional[List[Any]] = None + """Return value of the job.""" + + success: Optional[List[bool]] = None + """Whether the job was successful.""" + + +class Job(JsonModel): + """Job Model.""" + + id: str = Field(alias="jid") + """The job ID.""" + + system_id: str = Field(alias="id") + """The ID of the system that the job targets.""" + + created_timestamp: datetime + """The timestamp representing when the job was created.""" + + last_updated_timestamp: datetime + """The timestamp representing when the job was last updated.""" + + dispatched_timestamp: Optional[datetime] = None + """The timestamp representing when the job was dispatched.""" + + state: JobState + """The state of the job.""" + + metadata: Dict[str, Any] + """The metadata associated with the job.""" + + config: JobConfig + """The configuration of the job.""" + + result: Optional[JobResult] = None + """The result of the job.""" diff --git a/nisystemlink/clients/system/models/_job_summary_response.py b/nisystemlink/clients/system/models/_job_summary_response.py new file mode 100644 index 00000000..8c870776 --- /dev/null +++ b/nisystemlink/clients/system/models/_job_summary_response.py @@ -0,0 +1,20 @@ +from typing import Optional + +from nisystemlink.clients.core import ApiError +from nisystemlink.clients.core._uplink._json_model import JsonModel + + +class JobSummaryResponse(JsonModel): + """Model for request of jobs summary response.""" + + error: Optional[ApiError] = None + """Represents the standard error structure.""" + + active_count: int + """The number of active jobs.""" + + failed_count: int + """The number of failed jobs.""" + + succeeded_count: int + """The number of succeeded jobs.""" diff --git a/nisystemlink/clients/system/models/_query_jobs_request.py b/nisystemlink/clients/system/models/_query_jobs_request.py new file mode 100644 index 00000000..4c627033 --- /dev/null +++ b/nisystemlink/clients/system/models/_query_jobs_request.py @@ -0,0 +1,194 @@ +from enum import Enum +from typing import List, Optional, Union + +from nisystemlink.clients.core._uplink._json_model import JsonModel + + +class JobField(str, Enum): + """The fields of the job.""" + + ID = "jid" + + SYSTEM_ID = "id" + + CREATED_TIMESTAMP = "createdTimestamp" + + LAST_UPDATED_TIMESTAMP = "lastUpdatedTimestamp" + + DISPATCHED_TIMESTAMP = "dispatchedTimestamp" + + SCHEDULED_TIMESTAMP = "scheduledTimestamp" + + COMPLETING_TIMESTAMP = "completingTimestamp" + + STATE = "state" + + METADATA = "metadata" + + CONFIG = "config" + + CONFIG_USER = "config.user" + + CONFIG_TARGET_SYSTEMS = "config.tgt" + + CONFIG_FUNCTIONS = "config.fun" + + CONFIG_ARGUMENTS = "config.arg" + + RESULT = "result" + + RESULT_JOB_ID = "result.jid" + + RESULT_SYSTEM_ID = "result.id" + + RESULT_RETURN = "result.return" + + RESULT_RETURN_CODE = "result.retcode" + + RESULT_SUCCESS = "result.success" + + +class QueryJobsRequest(JsonModel): + """Model for query job request.""" + + skip: Optional[int] = None + """The number of jobs to skip.""" + + take: Optional[int] = None + """The number of jobs to return. The maximum value is 1000.""" + + filter: Optional[str] = None + """ + Gets or sets the filter criteria for jobs or systems. Consists of a string of queries composed using + AND/OR operators.String values and date strings need to be enclosed in double quotes. Parenthesis + can be used around filters to better define the order of operations. + Filter syntax: '[property name][operator][operand] and [property name][operator][operand]' + + Operators: + Equals operator '='. Example: 'x = y' + Not equal operator '!='. Example: 'x != y' + Greater than operator '>'. Example: 'x > y' + Greater than or equal operator '>='. Example: 'x >= y' + Less than operator '<'. Example: 'x < y' + Less than or equal operator '<='. Example: 'x <= y' + Logical AND operator 'and' or '&&'. Example: 'x and y' + Logical OR operator 'or' or '||'. Example: 'x or y' + Contains operator '.Contains()', used to check if a list contains an element. + Example: 'x.Contains(y)' + Not Contains operator '!.Contains()', used to check if a list does not contain an element. + Example: '!x.Contains(y)' + + Valid job properties that can be used in the filter: + jid : String representing the ID of the job. + id : String representing the ID of the system. + createdTimestamp: ISO-8601 formatted timestamp string specifying the date when the job + was created. + lastUpdatedTimestamp: ISO-8601 formatted timestamp string specifying the last date the + job was updated. + dispatchedTimestamp: ISO-8601 formatted timestamp string specifying the date when the + job was actually sent to the system. + state: String representing the state of the job. + metadata: Object containg the the metadata of job. Example: metadata.queued + config.user: String representing the user who created the job. + config.tgt: List of strings representing the targeted systems. Example: config.tgt.Contains("id") + config.fun: List of strings representing the functions to be executed within the job. + Example: config.fun.Contains("nisysmgmt.set_blackout") + config.arg: An array of arrays of variable type elements that are arguments to the function specified + by the "fun" property. Example: config.arg[0].Contains("test") + result.return: An array of objects representing return values for each executed function. + Example: result.return[0].Contains("Success") + result.retcode: An array of integers representing code values for each executed function. + Example: result.retcode + result.success: An array of booleans representing success values for each executed function. + Example: result.success.Contains(false) + """ + + projection: List[Union[JobField, str]] = [] + """ + Gets or sets specifies the projection for resources. Use this field to receive specific properties of the model. + + Examples: - [JobField.ID, JobField.SYSTEM_ID, metadata.queued] + """ + + order_by: Optional[JobField] = None + """ + The order in which the jobs return. + + Example: JobField.CREATED_TIMESTAMP + """ + + descending: bool = False + """ + The order in which the jobs return. Default is ascending. + """ + + +class _QueryJobsRequest(JsonModel): + """Model for query job request.""" + + skip: Optional[int] = None + """The number of jobs to skip.""" + + take: Optional[int] = None + """The number of jobs to return. The maximum value is 1000.""" + + filter: Optional[str] = None + """ + Gets or sets the filter criteria for jobs or systems. Consists of a string of queries composed using + AND/OR operators.String values and date strings need to be enclosed in double quotes. Parenthesis + can be used around filters to better define the order of operations. + Filter syntax: '[property name][operator][operand] and [property name][operator][operand]' + + Operators: + Equals operator '='. Example: 'x = y' + Not equal operator '!='. Example: 'x != y' + Greater than operator '>'. Example: 'x > y' + Greater than or equal operator '>='. Example: 'x >= y' + Less than operator '<'. Example: 'x < y' + Less than or equal operator '<='. Example: 'x <= y' + Logical AND operator 'and' or '&&'. Example: 'x and y' + Logical OR operator 'or' or '||'. Example: 'x or y' + Contains operator '.Contains()', used to check if a list contains an element. + Example: 'x.Contains(y)' + Not Contains operator '!.Contains()', used to check if a list does not contain an element. + Example: '!x.Contains(y)' + + Valid job properties that can be used in the filter: + jid : String representing the ID of the job. + id : String representing the ID of the system. + createdTimestamp: ISO-8601 formatted timestamp string specifying the date when the job + was created. + lastUpdatedTimestamp: ISO-8601 formatted timestamp string specifying the last date the + job was updated. + dispatchedTimestamp: ISO-8601 formatted timestamp string specifying the date when the + job was actually sent to the system. + state: String representing the state of the job. + metadata: Object containg the the metadata of job. Example: metadata.queued + config.user: String representing the user who created the job. + config.tgt: List of strings representing the targeted systems. Example: config.tgt.Contains("id") + config.fun: List of strings representing the functions to be executed within + the job. Example: config.fun.Contains("nisysmgmt.set_blackout") + config.arg: An array of arrays of variable type elements that are arguments to the function specified + by the "fun" property. Example: config.arg[0].Contains("test") + result.return: An array of objects representing return values for each executed function. + Example: result.return[0].Contains("Success") + result.retcode: An array of integers representing code values for each executed function. + Example: result.retcode + result.success: An array of booleans representing success values for each executed function. + Example: result.success.Contains(false) + """ + + projection: Optional[str] = None + """ + Gets or sets specifies the projection for resources. Use this field to receive specific properties of the model. + + Examples: - 'new(id,jid,state)' - 'new(id,jid,config.user as user)' - + 'new(id,jid,state,lastUpdatedTimestamp,metadata.queued as queued)' + """ + + order_by: Optional[str] = None + """ + The order in which the jobs return. + + Example: createdTimestamp descending + """ diff --git a/nisystemlink/clients/system/models/_query_jobs_response.py b/nisystemlink/clients/system/models/_query_jobs_response.py new file mode 100644 index 00000000..2b0c09ac --- /dev/null +++ b/nisystemlink/clients/system/models/_query_jobs_response.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional + +from nisystemlink.clients.core import ApiError +from nisystemlink.clients.core._uplink._json_model import JsonModel +from pydantic import Field + +from ._job import JobResult, JobState + + +class QueryJobConfig(JsonModel): + """The configuration of the job.""" + + user: Optional[str] = None + """The user who created the job.""" + + target_systems: Optional[List[str]] = Field(None, alias="tgt") + """The target systems for the job.""" + + functions: Optional[List[str]] = Field(None, alias="fun") + """Salt functions related to the job.""" + + arguments: Optional[List[List[Any]]] = Field(None, alias="arg") + """Arguments of the salt functions.""" + + +class QueryJob(JsonModel): + """Job Modal for query response.""" + + id: Optional[str] = Field(None, alias="jid") + """The job ID.""" + + system_id: Optional[str] = Field(None, alias="id") + """The ID of the system that the job targets.""" + + created_timestamp: Optional[datetime] = None + """The timestamp representing when the job was created.""" + + last_updated_timestamp: Optional[datetime] = None + """The timestamp representing when the job was last updated.""" + + dispatched_timestamp: Optional[datetime] = None + """The timestamp representing when the job was dispatched.""" + + scheduled_timestamp: Optional[datetime] = None + """The timestamp when the job was scheduled.""" + + completing_timestamp: Optional[datetime] = None + """The timestamp when the job was completed.""" + + state: Optional[JobState] = None + """The state of the job.""" + + metadata: Optional[Dict[str, Any]] = None + """The metadata associated with the job.""" + + config: Optional[QueryJobConfig] = None + """The configuration of the job.""" + + result: Optional[JobResult] = None + """The result of the job.""" + + +class QueryJobsResponse(JsonModel): + """Model for response of a query request.""" + + error: Optional[ApiError] = None + """Represents the standard error structure.""" + + data: Optional[List[QueryJob]] = None + """The data returned by the query.""" + + count: Optional[int] = None + """The total number of resources that matched the query.""" diff --git a/tests/integration/system/test_system_client.py b/tests/integration/system/test_system_client.py new file mode 100644 index 00000000..011c7357 --- /dev/null +++ b/tests/integration/system/test_system_client.py @@ -0,0 +1,496 @@ +import pytest +import responses +import responses.matchers +from nisystemlink.clients.core import ApiError, ApiException +from nisystemlink.clients.core._http_configuration import HttpConfiguration +from nisystemlink.clients.system import SystemClient +from nisystemlink.clients.system.models import ( + CancelJobRequest, + CreateJobRequest, + JobField, + QueryJobsRequest, +) + +TARGET_SYSTEM = "dh33jg-43erhqfb-3r3r3r" +METADATA = {"queued": True, "refresh_minion_cache": {"grains": True}} +SAMPLE_FUN_1 = "system.sample_function_one" +SAMPLE_FUN_2 = "system.sample_function_two" + + +@pytest.fixture(scope="class") +def client(enterprise_config: HttpConfiguration) -> SystemClient: + """Fixture to create an SystemClient instance.""" + return SystemClient(enterprise_config) + + +@pytest.mark.integration +@pytest.mark.enterprise +class TestSystemClient: + @responses.activate + def test__create_a_job__job_is_created_with_right_field_values( + self, + client: SystemClient, + ): + arg = [["sample argument"]] + tgt = [TARGET_SYSTEM] + fun = ["system.set_computer_desc_sample_function"] + + job = CreateJobRequest( + arguments=arg, + target_systems=tgt, + functions=fun, + metadata=METADATA, + ) + job_id = "sample_job_id" + + return_value = { + "jid": job_id, + "tgt": job.target_systems, + "fun": job.functions, + "arg": job.arguments, + "metadata": job.metadata, + "error": None, + } + + responses.add( + method=responses.POST, + url="https://dev-api.lifecyclesolutions.ni.com/nisysmgmt/v1/jobs", + json=return_value, + status=201, + ) + + response = client.create_job(job) + + assert response is not None + assert response.id == job_id + assert response.target_systems == tgt + assert response.error is None + + @responses.activate + def test__create_job_with_invalid_target_system__return_error_response( + self, + client: SystemClient, + ): + arg = [["sample argument"]] + tgt = ["Invalid_target_system"] + fun = ["system.set_computer_desc_sample_function"] + + job = CreateJobRequest( + arguments=arg, + target_systems=tgt, + functions=fun, + metadata=METADATA, + ) + + return_value = { + "jid": "", + "tgt": job.target_systems, + "fun": job.functions, + "arg": job.arguments, + "metadata": job.metadata, + "error": { + "name": "Skyline.OneOrMoreErrorsOccurred", + "code": -251041, + "message": "One or more errors occurred. See the contained list for details of each error.", + "args": [], + "innerErrors": [ + { + "name": "SystemsManagement.SystemNotFound", + "code": -254010, + "message": "System not found.", + "resourceType": "Minion", + "resourceId": "Invalid_target_system", + "args": ["Invalid_target_system"], + "innerErrors": [], + } + ], + }, + } + + responses.add( + method=responses.POST, + url="https://dev-api.lifecyclesolutions.ni.com/nisysmgmt/v1/jobs", + json=return_value, + status=201, + ) + + response = client.create_job(job) + + assert response is not None + assert response.id == "" + assert response.target_systems == tgt + assert response.error is not None + + @responses.activate + def test__get_job_using_target_and_job_id__returns_job_matches_target_and_job_id( + self, + client: SystemClient, + ): + job_id = "sample_job_id" + return_value = [ + { + "jid": job_id, + "id": TARGET_SYSTEM, + "createdTimestamp": "2024-11-12T06:00:02.212+00:00", + "lastUpdatedTimestamp": "2024-11-12T11:05:38.614+00:00", + "state": "CANCELED", + "config": { + "user": "admin", + "tgt": [TARGET_SYSTEM], + "fun": [SAMPLE_FUN_1], + }, + "metadata": METADATA, + }, + ] + + responses.add( + method=responses.GET, + url="https://dev-api.lifecyclesolutions.ni.com/nisysmgmt/v1/jobs", + json=return_value, + status=200, + ) + + list_response = client.list_jobs(job_id=job_id, system_id=TARGET_SYSTEM) + assert len(list_response) == 1 + assert list_response[0].id == job_id + assert list_response[0].system_id == TARGET_SYSTEM + + @responses.activate + def test__get_jobs_using_target_and_function__return_jobs_match_target_and_function( + self, client: SystemClient + ): + job_id_1 = "sample_job_id_1" + job_id_2 = "sample_job_id_2" + return_value = [ + { + "jid": job_id_1, + "id": TARGET_SYSTEM, + "createdTimestamp": "2024-11-12T06:00:02.212+00:00", + "lastUpdatedTimestamp": "2024-11-12T11:05:38.614+00:00", + "state": "CANCELED", + "config": { + "user": "admin", + "tgt": [TARGET_SYSTEM], + "fun": [SAMPLE_FUN_1], + }, + "metadata": METADATA, + }, + { + "jid": job_id_2, + "id": TARGET_SYSTEM, + "createdTimestamp": "2024-11-12T06:00:02.212+00:00", + "lastUpdatedTimestamp": "2024-11-12T11:05:38.614+00:00", + "state": "CANCELED", + "config": { + "user": "admin", + "tgt": [TARGET_SYSTEM], + "fun": [SAMPLE_FUN_1], + }, + "metadata": METADATA, + }, + ] + + responses.add( + method=responses.GET, + url="https://dev-api.lifecyclesolutions.ni.com/nisysmgmt/v1/jobs", + json=return_value, + status=200, + ) + + list_response = client.list_jobs(system_id=TARGET_SYSTEM, function=SAMPLE_FUN_1) + assert len(list_response) == 2 + assert list_response[0].id == job_id_1 + assert list_response[1].id == job_id_2 + + def test__get_job_by_taking_one__return_only_one_job(self, client: SystemClient): + response = client.list_jobs(take=1) + assert len(response) == 1 + + def test__get_jobs_using_invalid_system_id__returns_empty_list( + self, client: SystemClient + ): + response = client.list_jobs(system_id="Invalid_system_id") + assert len(response) == 0 + + def test__get_jobs_using_invalid_jid__returns_empty_list( + self, client: SystemClient + ): + response = client.list_jobs(job_id="Invalid_jid") + assert len(response) == 0 + + def test__get_job_summary__returns_job_summary(self, client: SystemClient): + response = client.get_job_summary() + + assert response is not None + assert response.active_count is not None + assert response.failed_count is not None + assert response.succeeded_count is not None + assert response.error is None + + def test__query_jobs_by_taking_one__returns_one_job(self, client: SystemClient): + query = QueryJobsRequest(take=1) + response = client.query_jobs(query=query) + + assert response is not None + assert response.data is not None + assert len(response.data) == response.count == 1 + + @responses.activate + def test__query_jobs_by_filtering_config__return_jobs_matches_filter( + self, client: SystemClient + ): + job_id = "sample_job_id" + return_value = { + "data": [ + { + "jid": job_id, + "id": TARGET_SYSTEM, + "createdTimestamp": "2024-11-12T06:00:02.212+00:00", + "lastUpdatedTimestamp": "2024-11-12T11:05:38.614+00:00", + "state": "CANCELED", + "config": { + "user": "admin", + "tgt": [TARGET_SYSTEM], + "fun": [SAMPLE_FUN_1], + }, + "metadata": METADATA, + }, + ], + "count": 1, + } + + responses.add( + method=responses.POST, + url="https://dev-api.lifecyclesolutions.ni.com/nisysmgmt/v1/query-jobs", + json=return_value, + status=200, + ) + + query = QueryJobsRequest(filter=f"config.fun.Contains({SAMPLE_FUN_1})") + response = client.query_jobs(query=query) + + assert response is not None + assert response.data is not None + assert response.count is not None + assert len(response.data) == response.count == 1 + assert response.data[0].id == job_id + assert response.data[0].config is not None + assert response.data[0].config.functions is not None + assert response.data[0].config.functions == [SAMPLE_FUN_1] + + def test__query_jobs_by_filtering_invalid_function__returns_empty_list( + self, client: SystemClient + ): + query = QueryJobsRequest(filter='config.fun.Contains("failed_function")') + response = client.query_jobs(query=query) + + assert response.error is None + assert response.data is not None + assert len(response.data) == 0 + assert response.count == 0 + + @responses.activate + def test__query_jobs_by_filtering_job_id__returns_job_matches_job_id( + self, client: SystemClient + ): + job_id = "sample_job_id" + return_value = { + "data": [ + { + "jid": job_id, + "id": TARGET_SYSTEM, + "createdTimestamp": "2024-11-12T06:00:02.212+00:00", + "lastUpdatedTimestamp": "2024-11-12T11:05:38.614+00:00", + "state": "CANCELED", + "config": { + "user": "admin", + "tgt": [TARGET_SYSTEM], + "fun": [SAMPLE_FUN_1], + }, + "metadata": METADATA, + }, + ], + "count": 1, + } + + responses.add( + method=responses.POST, + url="https://dev-api.lifecyclesolutions.ni.com/nisysmgmt/v1/query-jobs", + json=return_value, + status=200, + ) + query = QueryJobsRequest(filter=f"jid={job_id}") + response = client.query_jobs(query=query) + + assert response is not None + assert response.data is not None + assert len(response.data) == response.count == 1 + assert response.data[0].id == job_id + + def test__query_jobs_by_filtering_invalid_job_id__raises_ApiException_BadRequest( + self, client: SystemClient + ): + query = QueryJobsRequest(filter="jid=Invalid_jid") + with pytest.raises(ApiException, match="Bad Request"): + client.query_jobs(query=query) + + def test__query_jobs_by_filtering_invalid_system_id__raises_ApiException( + self, client: SystemClient + ): + query = QueryJobsRequest(filter="id=Invalid_system_id") + with pytest.raises(ApiException, match="Bad Request"): + client.query_jobs(query=query) + + def test__query_jobs_by_projecting_job_id_and_system_id__returns_jobs_with_only_job_id_and_system_id_properties( + self, + client: SystemClient, + ): + query = QueryJobsRequest(projection=[JobField.ID, JobField.SYSTEM_ID], take=3) + response = client.query_jobs(query=query) + + assert response is not None + assert response.data is not None + assert len(response.data) == response.count == 3 + + assert all( + job.id is not None + and job.system_id is not None + and job.created_timestamp is None + and job.last_updated_timestamp is None + and job.state is None + and job.config is None + and job.metadata is None + and job.result is None + for job in response.data + ) + + def test__query_jobs_with_invalid_projection__raises_ApiException_BadRequest( + self, client: SystemClient + ): + query = QueryJobsRequest(projection=["Invalid_projection"], take=3) + with pytest.raises(ApiException, match="Bad Request"): + client.query_jobs(query=query) + + def test__query_jobs_order_by_created_timestamp_in_asc__returns_jobs_sorted_by_created_timestamp_in_asc( + self, client: SystemClient + ): + query = QueryJobsRequest(order_by=JobField.CREATED_TIMESTAMP, take=3) + response = client.query_jobs(query=query) + + assert response is not None + assert response.data is not None + assert len(response.data) == response.count == 3 + + assert response.data[0].created_timestamp is not None + assert response.data[1].created_timestamp is not None + assert response.data[2].created_timestamp is not None + + assert ( + response.data[0].created_timestamp + <= response.data[1].created_timestamp + <= response.data[2].created_timestamp + ) + + def test__query_jobs_order_by_completing_timestamp_in_desc__returns_jobs_sorted_by_completing_timestamp_in_desc( + self, client: SystemClient + ): + query = QueryJobsRequest( + order_by=JobField.COMPLETING_TIMESTAMP, descending=True, take=3 + ) + response = client.query_jobs(query=query) + + assert response is not None + assert response.data is not None + assert len(response.data) == response.count == 3 + + assert response.data[0].completing_timestamp is not None + assert response.data[1].completing_timestamp is not None + assert response.data[2].completing_timestamp is not None + + assert ( + response.data[0].completing_timestamp + >= response.data[1].completing_timestamp + >= response.data[2].completing_timestamp + ) + + @responses.activate + def test__cancel_single_job__cancel_single_job_succeeds(self, client: SystemClient): + responses.add( + method=responses.POST, + url="https://dev-api.lifecyclesolutions.ni.com/nisysmgmt/v1/cancel-jobs", + status=200, + ) + + cancel_job_request = CancelJobRequest(id="Job.id", system_id=TARGET_SYSTEM) + cancel_response = client.cancel_jobs([cancel_job_request]) + + assert cancel_response is None + + @responses.activate + def test__cancel_multiple_jobs__cancel_multiple_job_succeeds( + self, client: SystemClient + ): + + responses.add( + method=responses.POST, + url="https://dev-api.lifecyclesolutions.ni.com/nisysmgmt/v1/cancel-jobs", + status=200, + ) + + cancel_job_request_1 = CancelJobRequest(id="Job_1.id", system_id=TARGET_SYSTEM) + cancel_job_request_2 = CancelJobRequest(id="Job_2.id", system_id=TARGET_SYSTEM) + cancel_response = client.cancel_jobs( + [cancel_job_request_1, cancel_job_request_2] + ) + + assert cancel_response is None + + def test__cancel_with_invalid_jid_system_id__cancel_job_returns_None( + self, client: SystemClient + ): + cancel_job_request = CancelJobRequest(id="Invalid_jid", system_id="Invalid_tgt") + cancel_response = client.cancel_jobs([cancel_job_request]) + + assert cancel_response is None + + @responses.activate + def test__cancel_with_invalid_jid_valid_system_id__cancel_job_returns_error( + self, client: SystemClient + ): + return_value = { + "error": { + "name": "Skyline.OneOrMoreErrorsOccurred", + "code": -251041, + "message": "One or more errors occurred. See the contained list for details of each error.", + "args": [], + "innerErrors": [ + { + "name": "SystemsManagement.SaltCancelJobFailed", + "code": -254003, + "message": "The job is not found or you are not authorized to cancel it.", + "resourceType": "Job", + "resourceId": "54afefqf4b95-ea89-48df-b21f-dddrwerf56083476", + "args": [ + "54af4b95-ea89-48df-b21f-dddrfrewf56083476", + "ferfgertgw--SN-eger--rf-AC-1A-3D-99-75-3F", + ], + "innerErrors": [], + } + ], + } + } + + responses.add( + method=responses.POST, + url="https://dev-api.lifecyclesolutions.ni.com/nisysmgmt/v1/cancel-jobs", + json=return_value, + status=200, + ) + + cancel_job_request = CancelJobRequest( + id="Invalid_jid", + system_id="ferfgertgw--SN-eger--rf-AC-1A-3D-99-75-3F", + ) + cancel_response = client.cancel_jobs([cancel_job_request]) + + assert isinstance(cancel_response, ApiError)