diff --git a/FEATURES.md b/FEATURES.md index 95800e76..1285c867 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -25,6 +25,7 @@ Note that list is **not** in any specific order. - [ ] Worker sleep schedule - [ ] Web frontend for Worker management - [ ] Web frontend for Job & Task management +- [ ] API: only allow valid job & task status changes. Currently any change is allowed by the API. - [ ] Web frontend for configuration management - [ ] Frontend authentication - [x] Worker API authentication diff --git a/addon/flamenco/manager/__init__.py b/addon/flamenco/manager/__init__.py index 5d602986..c64e2a85 100644 --- a/addon/flamenco/manager/__init__.py +++ b/addon/flamenco/manager/__init__.py @@ -10,7 +10,7 @@ """ -__version__ = "d79fde17-dirty" +__version__ = "b699647e-dirty" # import ApiClient from flamenco.manager.api_client import ApiClient diff --git a/addon/flamenco/manager/api/jobs_api.py b/addon/flamenco/manager/api/jobs_api.py index b9c6fd9e..2d54d4a4 100644 --- a/addon/flamenco/manager/api/jobs_api.py +++ b/addon/flamenco/manager/api/jobs_api.py @@ -25,6 +25,7 @@ from flamenco.manager.model.available_job_type import AvailableJobType from flamenco.manager.model.available_job_types import AvailableJobTypes from flamenco.manager.model.error import Error from flamenco.manager.model.job import Job +from flamenco.manager.model.job_status_change import JobStatusChange from flamenco.manager.model.jobs_query import JobsQuery from flamenco.manager.model.jobs_query_result import JobsQueryResult from flamenco.manager.model.submitted_job import SubmittedJob @@ -231,6 +232,62 @@ class JobsApi(object): }, api_client=api_client ) + self.set_job_status_endpoint = _Endpoint( + settings={ + 'response_type': None, + 'auth': [], + 'endpoint_path': '/api/jobs/{job_id}/setstatus', + 'operation_id': 'set_job_status', + 'http_method': 'POST', + 'servers': None, + }, + params_map={ + 'all': [ + 'job_id', + 'job_status_change', + ], + 'required': [ + 'job_id', + 'job_status_change', + ], + 'nullable': [ + ], + 'enum': [ + ], + 'validation': [ + ] + }, + root_map={ + 'validations': { + }, + 'allowed_values': { + }, + 'openapi_types': { + 'job_id': + (str,), + 'job_status_change': + (JobStatusChange,), + }, + 'attribute_map': { + 'job_id': 'job_id', + }, + 'location_map': { + 'job_id': 'path', + 'job_status_change': 'body', + }, + 'collection_format_map': { + } + }, + headers_map={ + 'accept': [ + 'application/json' + ], + 'content_type': [ + 'application/json' + ] + }, + api_client=api_client + ) self.submit_job_endpoint = _Endpoint( settings={ 'response_type': (Job,), @@ -585,6 +642,87 @@ class JobsApi(object): jobs_query return self.query_jobs_endpoint.call_with_http_info(**kwargs) + def set_job_status( + self, + job_id, + job_status_change, + **kwargs + ): + """set_job_status # noqa: E501 + + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.set_job_status(job_id, job_status_change, async_req=True) + >>> result = thread.get() + + Args: + job_id (str): + job_status_change (JobStatusChange): The status change to request. + + Keyword Args: + _return_http_data_only (bool): response data without head status + code and headers. Default is True. + _preload_content (bool): if False, the urllib3.HTTPResponse object + will be returned without reading/decoding response data. + Default is True. + _request_timeout (int/float/tuple): timeout setting for this request. If + one number provided, it will be total request timeout. It can also + be a pair (tuple) of (connection, read) timeouts. + Default is None. + _check_input_type (bool): specifies if type checking + should be done one the data sent to the server. + Default is True. + _check_return_type (bool): specifies if type checking + should be done one the data received from the server. + Default is True. + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _content_type (str/None): force body content-type. + Default is None and content-type will be predicted by allowed + content-types and body. + _host_index (int/None): specifies the index of the server + that we want to use. + Default is read from the configuration. + async_req (bool): execute request asynchronously + + Returns: + None + If the method is called asynchronously, returns the request + thread. + """ + kwargs['async_req'] = kwargs.get( + 'async_req', False + ) + kwargs['_return_http_data_only'] = kwargs.get( + '_return_http_data_only', True + ) + kwargs['_preload_content'] = kwargs.get( + '_preload_content', True + ) + kwargs['_request_timeout'] = kwargs.get( + '_request_timeout', None + ) + kwargs['_check_input_type'] = kwargs.get( + '_check_input_type', True + ) + kwargs['_check_return_type'] = kwargs.get( + '_check_return_type', True + ) + kwargs['_spec_property_naming'] = kwargs.get( + '_spec_property_naming', False + ) + kwargs['_content_type'] = kwargs.get( + '_content_type') + kwargs['_host_index'] = kwargs.get('_host_index') + kwargs['job_id'] = \ + job_id + kwargs['job_status_change'] = \ + job_status_change + return self.set_job_status_endpoint.call_with_http_info(**kwargs) + def submit_job( self, submitted_job, diff --git a/addon/flamenco/manager/api_client.py b/addon/flamenco/manager/api_client.py index daea4294..aa785e5b 100644 --- a/addon/flamenco/manager/api_client.py +++ b/addon/flamenco/manager/api_client.py @@ -76,7 +76,7 @@ class ApiClient(object): self.default_headers[header_name] = header_value self.cookie = cookie # Set default User-Agent. - self.user_agent = 'Flamenco/d79fde17-dirty (Blender add-on)' + self.user_agent = 'Flamenco/b699647e-dirty (Blender add-on)' def __enter__(self): return self diff --git a/addon/flamenco/manager/configuration.py b/addon/flamenco/manager/configuration.py index d9479bae..fc712b51 100644 --- a/addon/flamenco/manager/configuration.py +++ b/addon/flamenco/manager/configuration.py @@ -404,7 +404,7 @@ conf = flamenco.manager.Configuration( "OS: {env}\n"\ "Python Version: {pyversion}\n"\ "Version of the API: 1.0.0\n"\ - "SDK Package Version: d79fde17-dirty".\ + "SDK Package Version: b699647e-dirty".\ format(env=sys.platform, pyversion=sys.version) def get_host_settings(self): diff --git a/addon/flamenco/manager/docs/JobStatusChange.md b/addon/flamenco/manager/docs/JobStatusChange.md new file mode 100644 index 00000000..bcef2e1c --- /dev/null +++ b/addon/flamenco/manager/docs/JobStatusChange.md @@ -0,0 +1,13 @@ +# JobStatusChange + + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**status** | [**JobStatus**](JobStatus.md) | | +**reason** | **str** | The reason for this status change. | +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/addon/flamenco/manager/docs/JobsApi.md b/addon/flamenco/manager/docs/JobsApi.md index ade0d60d..c9606119 100644 --- a/addon/flamenco/manager/docs/JobsApi.md +++ b/addon/flamenco/manager/docs/JobsApi.md @@ -8,6 +8,7 @@ Method | HTTP request | Description [**get_job_type**](JobsApi.md#get_job_type) | **GET** /api/jobs/type/{typeName} | Get single job type and its parameters. [**get_job_types**](JobsApi.md#get_job_types) | **GET** /api/jobs/types | Get list of job types and their parameters. [**query_jobs**](JobsApi.md#query_jobs) | **POST** /api/jobs/query | Fetch list of jobs. +[**set_job_status**](JobsApi.md#set_job_status) | **POST** /api/jobs/{job_id}/setstatus | [**submit_job**](JobsApi.md#submit_job) | **POST** /api/jobs | Submit a new job for Flamenco Manager to execute. @@ -283,6 +284,77 @@ No authorization required [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **set_job_status** +> set_job_status(job_id, job_status_change) + + + +### Example + + +```python +import time +import flamenco.manager +from flamenco.manager.api import jobs_api +from flamenco.manager.model.error import Error +from flamenco.manager.model.job_status_change import JobStatusChange +from pprint import pprint +# Defining the host is optional and defaults to http://localhost +# See configuration.py for a list of all supported configuration parameters. +configuration = flamenco.manager.Configuration( + host = "http://localhost" +) + + +# Enter a context with an instance of the API client +with flamenco.manager.ApiClient() as api_client: + # Create an instance of the API class + api_instance = jobs_api.JobsApi(api_client) + job_id = "job_id_example" # str | + job_status_change = JobStatusChange( + status=JobStatus("active"), + reason="reason_example", + ) # JobStatusChange | The status change to request. + + # example passing only required values which don't have defaults set + try: + api_instance.set_job_status(job_id, job_status_change) + except flamenco.manager.ApiException as e: + print("Exception when calling JobsApi->set_job_status: %s\n" % e) +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **job_id** | **str**| | + **job_status_change** | [**JobStatusChange**](JobStatusChange.md)| The status change to request. | + +### Return type + +void (empty response body) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**204** | Status change was accepted. | - | +**422** | The requested status change is not valid for the current status of the job. | - | +**0** | Unexpected error. | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **submit_job** > Job submit_job(submitted_job) diff --git a/addon/flamenco/manager/model/job_status_change.py b/addon/flamenco/manager/model/job_status_change.py new file mode 100644 index 00000000..a4870450 --- /dev/null +++ b/addon/flamenco/manager/model/job_status_change.py @@ -0,0 +1,273 @@ +""" + Flamenco manager + + Render Farm manager API # noqa: E501 + + The version of the OpenAPI document: 1.0.0 + Generated by: https://openapi-generator.tech +""" + + +import re # noqa: F401 +import sys # noqa: F401 + +from flamenco.manager.model_utils import ( # noqa: F401 + ApiTypeError, + ModelComposed, + ModelNormal, + ModelSimple, + cached_property, + change_keys_js_to_python, + convert_js_args_to_python_args, + date, + datetime, + file_type, + none_type, + validate_get_composed_info, + OpenApiModel +) +from flamenco.manager.exceptions import ApiAttributeError + + +def lazy_import(): + from flamenco.manager.model.job_status import JobStatus + globals()['JobStatus'] = JobStatus + + +class JobStatusChange(ModelNormal): + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + + Attributes: + allowed_values (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + with a capitalized key describing the allowed value and an allowed + value. These dicts store the allowed enum values. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + discriminator_value_class_map (dict): A dict to go from the discriminator + variable value to the discriminator class name. + validations (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + that stores validations for max_length, min_length, max_items, + min_items, exclusive_maximum, inclusive_maximum, exclusive_minimum, + inclusive_minimum, and regex. + additional_properties_type (tuple): A tuple of classes accepted + as additional properties values. + """ + + allowed_values = { + } + + validations = { + } + + @cached_property + def additional_properties_type(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + """ + lazy_import() + return (bool, date, datetime, dict, float, int, list, str, none_type,) # noqa: E501 + + _nullable = False + + @cached_property + def openapi_types(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + + Returns + openapi_types (dict): The key is attribute name + and the value is attribute type. + """ + lazy_import() + return { + 'status': (JobStatus,), # noqa: E501 + 'reason': (str,), # noqa: E501 + } + + @cached_property + def discriminator(): + return None + + + attribute_map = { + 'status': 'status', # noqa: E501 + 'reason': 'reason', # noqa: E501 + } + + read_only_vars = { + } + + _composed_schemas = {} + + @classmethod + @convert_js_args_to_python_args + def _from_openapi_data(cls, status, reason, *args, **kwargs): # noqa: E501 + """JobStatusChange - a model defined in OpenAPI + + Args: + status (JobStatus): + reason (str): The reason for this status change. + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + """ + + _check_type = kwargs.pop('_check_type', True) + _spec_property_naming = kwargs.pop('_spec_property_naming', False) + _path_to_item = kwargs.pop('_path_to_item', ()) + _configuration = kwargs.pop('_configuration', None) + _visited_composed_classes = kwargs.pop('_visited_composed_classes', ()) + + self = super(OpenApiModel, cls).__new__(cls) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.status = status + self.reason = reason + for var_name, var_value in kwargs.items(): + if var_name not in self.attribute_map and \ + self._configuration is not None and \ + self._configuration.discard_unknown_keys and \ + self.additional_properties_type is None: + # discard variable. + continue + setattr(self, var_name, var_value) + return self + + required_properties = set([ + '_data_store', + '_check_type', + '_spec_property_naming', + '_path_to_item', + '_configuration', + '_visited_composed_classes', + ]) + + @convert_js_args_to_python_args + def __init__(self, status, reason, *args, **kwargs): # noqa: E501 + """JobStatusChange - a model defined in OpenAPI + + Args: + status (JobStatus): + reason (str): The reason for this status change. + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + """ + + _check_type = kwargs.pop('_check_type', True) + _spec_property_naming = kwargs.pop('_spec_property_naming', False) + _path_to_item = kwargs.pop('_path_to_item', ()) + _configuration = kwargs.pop('_configuration', None) + _visited_composed_classes = kwargs.pop('_visited_composed_classes', ()) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.status = status + self.reason = reason + for var_name, var_value in kwargs.items(): + if var_name not in self.attribute_map and \ + self._configuration is not None and \ + self._configuration.discard_unknown_keys and \ + self.additional_properties_type is None: + # discard variable. + continue + setattr(self, var_name, var_value) + if var_name in self.read_only_vars: + raise ApiAttributeError(f"`{var_name}` is a read-only attribute. Use `from_openapi_data` to instantiate " + f"class with read only attributes.") diff --git a/addon/flamenco/manager/models/__init__.py b/addon/flamenco/manager/models/__init__.py index a77f7a09..2557c62d 100644 --- a/addon/flamenco/manager/models/__init__.py +++ b/addon/flamenco/manager/models/__init__.py @@ -23,6 +23,7 @@ from flamenco.manager.model.job_all_of import JobAllOf from flamenco.manager.model.job_metadata import JobMetadata from flamenco.manager.model.job_settings import JobSettings from flamenco.manager.model.job_status import JobStatus +from flamenco.manager.model.job_status_change import JobStatusChange from flamenco.manager.model.job_update import JobUpdate from flamenco.manager.model.jobs_query import JobsQuery from flamenco.manager.model.jobs_query_result import JobsQueryResult diff --git a/addon/flamenco/manager_README.md b/addon/flamenco/manager_README.md index 8f4c94f3..f8e66d5c 100644 --- a/addon/flamenco/manager_README.md +++ b/addon/flamenco/manager_README.md @@ -4,7 +4,7 @@ Render Farm manager API The `flamenco.manager` package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: - API version: 1.0.0 -- Package version: d79fde17-dirty +- Package version: b699647e-dirty - Build package: org.openapitools.codegen.languages.PythonClientCodegen For more information, please visit [https://flamenco.io/](https://flamenco.io/) @@ -36,6 +36,7 @@ from flamenco.manager.model.available_job_type import AvailableJobType from flamenco.manager.model.available_job_types import AvailableJobTypes from flamenco.manager.model.error import Error from flamenco.manager.model.job import Job +from flamenco.manager.model.job_status_change import JobStatusChange from flamenco.manager.model.jobs_query import JobsQuery from flamenco.manager.model.jobs_query_result import JobsQueryResult from flamenco.manager.model.submitted_job import SubmittedJob @@ -71,6 +72,7 @@ Class | Method | HTTP request | Description *JobsApi* | [**get_job_type**](flamenco/manager/docs/JobsApi.md#get_job_type) | **GET** /api/jobs/type/{typeName} | Get single job type and its parameters. *JobsApi* | [**get_job_types**](flamenco/manager/docs/JobsApi.md#get_job_types) | **GET** /api/jobs/types | Get list of job types and their parameters. *JobsApi* | [**query_jobs**](flamenco/manager/docs/JobsApi.md#query_jobs) | **POST** /api/jobs/query | Fetch list of jobs. +*JobsApi* | [**set_job_status**](flamenco/manager/docs/JobsApi.md#set_job_status) | **POST** /api/jobs/{job_id}/setstatus | *JobsApi* | [**submit_job**](flamenco/manager/docs/JobsApi.md#submit_job) | **POST** /api/jobs | Submit a new job for Flamenco Manager to execute. *MetaApi* | [**get_configuration**](flamenco/manager/docs/MetaApi.md#get_configuration) | **GET** /api/configuration | Get the configuration of this Manager. *MetaApi* | [**get_version**](flamenco/manager/docs/MetaApi.md#get_version) | **GET** /api/version | Get the Flamenco version of this Manager @@ -103,6 +105,7 @@ Class | Method | HTTP request | Description - [JobMetadata](flamenco/manager/docs/JobMetadata.md) - [JobSettings](flamenco/manager/docs/JobSettings.md) - [JobStatus](flamenco/manager/docs/JobStatus.md) + - [JobStatusChange](flamenco/manager/docs/JobStatusChange.md) - [JobUpdate](flamenco/manager/docs/JobUpdate.md) - [JobsQuery](flamenco/manager/docs/JobsQuery.md) - [JobsQueryResult](flamenco/manager/docs/JobsQueryResult.md) diff --git a/internal/manager/api_impl/jobs.go b/internal/manager/api_impl/jobs.go index 5b6fedb1..fb6916e9 100644 --- a/internal/manager/api_impl/jobs.go +++ b/internal/manager/api_impl/jobs.go @@ -4,6 +4,7 @@ package api_impl import ( "context" + "errors" "fmt" "net/http" @@ -119,6 +120,42 @@ func (f *Flamenco) FetchJob(e echo.Context, jobId string) error { return e.JSON(http.StatusOK, apiJob) } +func (f *Flamenco) SetJobStatus(e echo.Context, jobID string) error { + logger := requestLogger(e) + ctx := e.Request().Context() + + logger = logger.With().Str("job", jobID).Logger() + + var statusChange api.SetJobStatusJSONRequestBody + if err := e.Bind(&statusChange); err != nil { + logger.Warn().Err(err).Msg("bad request received") + return sendAPIError(e, http.StatusBadRequest, "invalid format") + } + + dbJob, err := f.persist.FetchJob(ctx, jobID) + if err != nil { + if errors.Is(err, persistence.ErrJobNotFound) { + return sendAPIError(e, http.StatusNotFound, "no such job") + } + logger.Error().Err(err).Msg("error fetching job") + return sendAPIError(e, http.StatusInternalServerError, "error fetching job") + } + + logger = logger.With(). + Str("currentstatus", string(dbJob.Status)). + Str("requestedStatus", string(statusChange.Status)). + Str("reason", statusChange.Reason). + Logger() + logger.Info().Msg("job status change requested") + + err = f.stateMachine.JobStatusChange(ctx, dbJob, statusChange.Status, statusChange.Reason) + if err != nil { + logger.Error().Err(err).Msg("error changing job status") + return sendAPIError(e, http.StatusInternalServerError, "unexpected error changing job status") + } + return e.String(http.StatusNoContent, "") +} + func (f *Flamenco) TaskUpdate(e echo.Context, taskID string) error { logger := requestLogger(e) worker := requestWorkerOrPanic(e) diff --git a/internal/manager/api_impl/jobs_test.go b/internal/manager/api_impl/jobs_test.go index 2ae59305..78ce8de1 100644 --- a/internal/manager/api_impl/jobs_test.go +++ b/internal/manager/api_impl/jobs_test.go @@ -195,3 +195,57 @@ func TestGetJobTypeError(t *testing.T) { assert.NoError(t, err) assertResponseAPIError(t, echoCtx, http.StatusInternalServerError, "error getting job type") } + +func TestSetJobStatus_nonexistentJob(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mf := newMockedFlamenco(mockCtrl) + + jobID := "18a9b096-d77e-438c-9be2-74397038298b" + statusUpdate := api.JobStatusChange{ + Status: api.JobStatusCancelRequested, + Reason: "someone pushed a button", + } + + mf.persistence.EXPECT().FetchJob(gomock.Any(), jobID).Return(nil, persistence.ErrJobNotFound) + + // Do the call. + echoCtx := mf.prepareMockedJSONRequest(statusUpdate) + err := mf.flamenco.SetJobStatus(echoCtx, jobID) + assert.NoError(t, err) + + assertResponseAPIError(t, echoCtx, http.StatusNotFound, "no such job") +} + +func TestSetJobStatus_happy(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mf := newMockedFlamenco(mockCtrl) + + jobID := "18a9b096-d77e-438c-9be2-74397038298b" + statusUpdate := api.JobStatusChange{ + Status: api.JobStatusCancelRequested, + Reason: "someone pushed a button", + } + dbJob := persistence.Job{ + UUID: jobID, + Name: "test job", + Status: api.JobStatusActive, + Settings: persistence.StringInterfaceMap{}, + Metadata: persistence.StringStringMap{}, + } + + // Set up expectations. + ctx := gomock.Any() + mf.persistence.EXPECT().FetchJob(ctx, jobID).Return(&dbJob, nil) + mf.stateMachine.EXPECT().JobStatusChange(ctx, &dbJob, statusUpdate.Status, "someone pushed a button") + + // Do the call. + echoCtx := mf.prepareMockedJSONRequest(statusUpdate) + err := mf.flamenco.SetJobStatus(echoCtx, jobID) + assert.NoError(t, err) + + assertResponseEmpty(t, echoCtx) +} diff --git a/internal/manager/api_impl/support_test.go b/internal/manager/api_impl/support_test.go index a69aa06a..bc145141 100644 --- a/internal/manager/api_impl/support_test.go +++ b/internal/manager/api_impl/support_test.go @@ -78,13 +78,17 @@ func (mf *mockedFlamenco) prepareMockedRequest(body io.Reader) echo.Context { return c } -func getRecordedResponse(echoCtx echo.Context) *http.Response { +func getRecordedResponseRecorder(echoCtx echo.Context) *httptest.ResponseRecorder { writer := echoCtx.Response().Writer resp, ok := writer.(*httptest.ResponseRecorder) if !ok { panic(fmt.Sprintf("response writer was not a `*httptest.ResponseRecorder` but a %T", writer)) } - return resp.Result() + return resp +} + +func getRecordedResponse(echoCtx echo.Context) *http.Response { + return getRecordedResponseRecorder(echoCtx).Result() } // assertResponseJSON asserts that a recorded response is JSON with the given HTTP status code. @@ -118,6 +122,13 @@ func assertResponseAPIError(t *testing.T, echoCtx echo.Context, expectStatusCode }) } +// assertResponseEmpty asserts the response is an empty 204 No Content response. +func assertResponseEmpty(t *testing.T, echoCtx echo.Context) { + resp := getRecordedResponseRecorder(echoCtx) + assert.Equal(t, http.StatusNoContent, resp.Code, "Unexpected status: %v", resp.Result().Status) + assert.Zero(t, resp.Body.Len(), "HTTP 204 No Content should have no content, got %v", resp.Body.String()) +} + func testWorker() persistence.Worker { return persistence.Worker{ Model: gorm.Model{ID: 1}, diff --git a/internal/worker/mocks/client.gen.go b/internal/worker/mocks/client.gen.go index af9965bc..c7386d3b 100644 --- a/internal/worker/mocks/client.gen.go +++ b/internal/worker/mocks/client.gen.go @@ -236,6 +236,46 @@ func (mr *MockFlamencoClientMockRecorder) ScheduleTaskWithResponse(arg0 interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScheduleTaskWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).ScheduleTaskWithResponse), varargs...) } +// SetJobStatusWithBodyWithResponse mocks base method. +func (m *MockFlamencoClient) SetJobStatusWithBodyWithResponse(arg0 context.Context, arg1, arg2 string, arg3 io.Reader, arg4 ...api.RequestEditorFn) (*api.SetJobStatusResponse, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2, arg3} + for _, a := range arg4 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SetJobStatusWithBodyWithResponse", varargs...) + ret0, _ := ret[0].(*api.SetJobStatusResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SetJobStatusWithBodyWithResponse indicates an expected call of SetJobStatusWithBodyWithResponse. +func (mr *MockFlamencoClientMockRecorder) SetJobStatusWithBodyWithResponse(arg0, arg1, arg2, arg3 interface{}, arg4 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2, arg3}, arg4...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetJobStatusWithBodyWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).SetJobStatusWithBodyWithResponse), varargs...) +} + +// SetJobStatusWithResponse mocks base method. +func (m *MockFlamencoClient) SetJobStatusWithResponse(arg0 context.Context, arg1 string, arg2 api.SetJobStatusJSONRequestBody, arg3 ...api.RequestEditorFn) (*api.SetJobStatusResponse, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SetJobStatusWithResponse", varargs...) + ret0, _ := ret[0].(*api.SetJobStatusResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SetJobStatusWithResponse indicates an expected call of SetJobStatusWithResponse. +func (mr *MockFlamencoClientMockRecorder) SetJobStatusWithResponse(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetJobStatusWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).SetJobStatusWithResponse), varargs...) +} + // ShamanCheckoutRequirementsWithBodyWithResponse mocks base method. func (m *MockFlamencoClient) ShamanCheckoutRequirementsWithBodyWithResponse(arg0 context.Context, arg1 string, arg2 io.Reader, arg3 ...api.RequestEditorFn) (*api.ShamanCheckoutRequirementsResponse, error) { m.ctrl.T.Helper() diff --git a/pkg/api/flamenco-manager.yaml b/pkg/api/flamenco-manager.yaml index b0bf0b53..8b1d3247 100644 --- a/pkg/api/flamenco-manager.yaml +++ b/pkg/api/flamenco-manager.yaml @@ -322,6 +322,36 @@ paths: application/json: schema: {$ref: "#/components/schemas/Job"} + /api/jobs/{job_id}/setstatus: + summary: Request a status change for the given job. + post: + operationId: setJobStatus + tags: [jobs] + parameters: + - name: job_id + in: path + required: true + schema: {type: string, format: uuid} + requestBody: + description: The status change to request. + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/JobStatusChange" + responses: + "204": + description: Status change was accepted. + "422": + description: The requested status change is not valid for the current status of the job. + default: + description: Unexpected error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + ## Shaman /shaman/checkout/requirements: @@ -791,6 +821,13 @@ components: items: {$ref: "#/components/schemas/Job"} required: [jobs] + JobStatusChange: + type: object + properties: + status: {$ref: "#/components/schemas/JobStatus"} + reason: {type: string, description: The reason for this status change.} + required: [status, reason] + Error: description: Generic error response. type: object diff --git a/pkg/api/openapi_client.gen.go b/pkg/api/openapi_client.gen.go index 7aeab204..12ff9910 100644 --- a/pkg/api/openapi_client.gen.go +++ b/pkg/api/openapi_client.gen.go @@ -112,6 +112,11 @@ type ClientInterface interface { // FetchJob request FetchJob(ctx context.Context, jobId string, reqEditors ...RequestEditorFn) (*http.Response, error) + // SetJobStatus request with any body + SetJobStatusWithBody(ctx context.Context, jobId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetJobStatus(ctx context.Context, jobId string, body SetJobStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetVersion request GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -257,6 +262,30 @@ func (c *Client) FetchJob(ctx context.Context, jobId string, reqEditors ...Reque return c.Client.Do(req) } +func (c *Client) SetJobStatusWithBody(ctx context.Context, jobId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetJobStatusRequestWithBody(c.Server, jobId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetJobStatus(ctx context.Context, jobId string, body SetJobStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetJobStatusRequest(c.Server, jobId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetVersionRequest(c.Server) if err != nil { @@ -675,6 +704,53 @@ func NewFetchJobRequest(server string, jobId string) (*http.Request, error) { return req, nil } +// NewSetJobStatusRequest calls the generic SetJobStatus builder with application/json body +func NewSetJobStatusRequest(server string, jobId string, body SetJobStatusJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetJobStatusRequestWithBody(server, jobId, "application/json", bodyReader) +} + +// NewSetJobStatusRequestWithBody generates requests for SetJobStatus with any type of body +func NewSetJobStatusRequestWithBody(server string, jobId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "job_id", runtime.ParamLocationPath, jobId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/jobs/%s/setstatus", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewGetVersionRequest generates requests for GetVersion func NewGetVersionRequest(server string) (*http.Request, error) { var err error @@ -1201,6 +1277,11 @@ type ClientWithResponsesInterface interface { // FetchJob request FetchJobWithResponse(ctx context.Context, jobId string, reqEditors ...RequestEditorFn) (*FetchJobResponse, error) + // SetJobStatus request with any body + SetJobStatusWithBodyWithResponse(ctx context.Context, jobId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetJobStatusResponse, error) + + SetJobStatusWithResponse(ctx context.Context, jobId string, body SetJobStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*SetJobStatusResponse, error) + // GetVersion request GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) @@ -1384,6 +1465,28 @@ func (r FetchJobResponse) StatusCode() int { return 0 } +type SetJobStatusResponse struct { + Body []byte + HTTPResponse *http.Response + JSONDefault *Error +} + +// Status returns HTTPResponse.Status +func (r SetJobStatusResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetJobStatusResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetVersionResponse struct { Body []byte HTTPResponse *http.Response @@ -1728,6 +1831,23 @@ func (c *ClientWithResponses) FetchJobWithResponse(ctx context.Context, jobId st return ParseFetchJobResponse(rsp) } +// SetJobStatusWithBodyWithResponse request with arbitrary body returning *SetJobStatusResponse +func (c *ClientWithResponses) SetJobStatusWithBodyWithResponse(ctx context.Context, jobId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetJobStatusResponse, error) { + rsp, err := c.SetJobStatusWithBody(ctx, jobId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetJobStatusResponse(rsp) +} + +func (c *ClientWithResponses) SetJobStatusWithResponse(ctx context.Context, jobId string, body SetJobStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*SetJobStatusResponse, error) { + rsp, err := c.SetJobStatus(ctx, jobId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetJobStatusResponse(rsp) +} + // GetVersionWithResponse request returning *GetVersionResponse func (c *ClientWithResponses) GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) { rsp, err := c.GetVersion(ctx, reqEditors...) @@ -2054,6 +2174,32 @@ func ParseFetchJobResponse(rsp *http.Response) (*FetchJobResponse, error) { return response, nil } +// ParseSetJobStatusResponse parses an HTTP response from a SetJobStatusWithResponse call +func ParseSetJobStatusResponse(rsp *http.Response) (*SetJobStatusResponse, error) { + bodyBytes, err := ioutil.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetJobStatusResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + // ParseGetVersionResponse parses an HTTP response from a GetVersionWithResponse call func ParseGetVersionResponse(rsp *http.Response) (*GetVersionResponse, error) { bodyBytes, err := ioutil.ReadAll(rsp.Body) diff --git a/pkg/api/openapi_server.gen.go b/pkg/api/openapi_server.gen.go index 28ace4df..ea914967 100644 --- a/pkg/api/openapi_server.gen.go +++ b/pkg/api/openapi_server.gen.go @@ -31,6 +31,9 @@ type ServerInterface interface { // Fetch info about the job. // (GET /api/jobs/{job_id}) FetchJob(ctx echo.Context, jobId string) error + + // (POST /api/jobs/{job_id}/setstatus) + SetJobStatus(ctx echo.Context, jobId string) error // Get the Flamenco version of this Manager // (GET /api/version) GetVersion(ctx echo.Context) error @@ -143,6 +146,22 @@ func (w *ServerInterfaceWrapper) FetchJob(ctx echo.Context) error { return err } +// SetJobStatus converts echo context to params. +func (w *ServerInterfaceWrapper) SetJobStatus(ctx echo.Context) error { + var err error + // ------------- Path parameter "job_id" ------------- + var jobId string + + err = runtime.BindStyledParameterWithLocation("simple", false, "job_id", runtime.ParamLocationPath, ctx.Param("job_id"), &jobId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter job_id: %s", err)) + } + + // Invoke the callback with all the unmarshalled arguments + err = w.Handler.SetJobStatus(ctx, jobId) + return err +} + // GetVersion converts echo context to params. func (w *ServerInterfaceWrapper) GetVersion(ctx echo.Context) error { var err error @@ -369,6 +388,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/api/jobs/type/:typeName", wrapper.GetJobType) router.GET(baseURL+"/api/jobs/types", wrapper.GetJobTypes) router.GET(baseURL+"/api/jobs/:job_id", wrapper.FetchJob) + router.POST(baseURL+"/api/jobs/:job_id/setstatus", wrapper.SetJobStatus) router.GET(baseURL+"/api/version", wrapper.GetVersion) router.POST(baseURL+"/api/worker/register-worker", wrapper.RegisterWorker) router.POST(baseURL+"/api/worker/sign-off", wrapper.SignOff) diff --git a/pkg/api/openapi_spec.gen.go b/pkg/api/openapi_spec.gen.go index 8584c74b..a9ecf5bb 100644 --- a/pkg/api/openapi_spec.gen.go +++ b/pkg/api/openapi_spec.gen.go @@ -18,107 +18,109 @@ import ( // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+Q823LcOHa/guKmyrsV9kUX3/QUrT3ekTNjK5a8k6qxSgLJw25YJMABQLV7XKraj8if", - "JFuVh+xTfsD7RyngALw00eqWLXm9Gz+4Wt3kwcG534CPUSrKSnDgWkUHHyOVzqGk9uOhUmzGITul6tL8", - "nYFKJas0Ezw66P1KmCKUaPOJKsK0+VtCCuwKMpIsiZ4D+UnIS5DjKI4qKSqQmoFdJRVlSXlmPzMNpf3w", - "TxLy6CD6zaRFbuIwmzzDF6LrONLLCqKDiEpJl+bv9yIxb7uvlZaMz9z355VkQjK97DzAuIYZSP8Efht4", - "ndMy/MPNMJWmut64HUO/E3zS7Iiqy/WI1DXLzA+5kCXV0QF+Ea8+eB1HEn6pmYQsOvjZP2SI4/bS4NbZ", - "wgqVOiTpYhW3/Dpr1hXJe0i1QfDwirKCJgW8FMkJaG3QGUjOCeOzAojC34nICSUvRUIMNBUQkLlgKX7s", - "w/lpDpzM2BXwmBSsZNrK2RUtWGb+r0ERLcx3CogDMiavebEktTI4kgXTc4JEs4ubtRsRHBB/VdgyyGld", - "6CFep3Mg7kfEg6i5WHCHDKkVSLIwuGegQZaM2/XnTHmSjBF8B2Z4ieabiRai0KxyCzHeLmTkUeY0BQsU", - "MqbN1hGiwz+nhYJ4SFw9B2mQpkUhFsS8uoooobk2z8yBvBcJmVNFEgBOVJ2UTGvIxuQnURcZYWVVLEkG", - "BeBrRUHgA1MIkKpLRXIhEfR7kcSE8swYEFFWrDDPMD1+x1tBT4QogHK7oytaDOlzvNRzwQl8qCQoxYQl", - "fgLEPF1TDZmhkZAZbtDzAexO+qxr8Gp4Ew9F4xKWQxyOMuCa5QykA9KIfEzKWmmDT83ZLzUKomPae6cI", - "wXWMYlA5C+jCIV8S+KAlJVTO6tJYGC9vSbUcmxfV+ESUcIy6tfzt70hq2FAryMyTqQSqAbfq9G/ZwaFV", - "8day3EKEWFlCxqiGYkkkGFCE2q1mkDPOzAuxMQR2ebNkbGkiau0wolKztC6obPiwRh5UnXjzeZPVDRiq", - "E/dmo+q3hnDqXr9iiq0qmZb1TQQyittXLScPb4/QQBpiebWS5LcFuwRCye8L4EaIaZaNBP/dmJyANuAu", - "LEMu0MygP6YcbQGnRbOGnlNtlq6LjD+wAtlYKuCZNSAqTOgVF2MUwD20pVs4afm04h3qZGR+QXFAhfA8", - "J89qKYHrYkmEsePUw7Ua1rHkakwuvj88+f675+cvjn747vz48PT7C4xSMiYh1UIuSUX1nPwzuXgXTX5j", - "/72LLgitKkPSDLcNvC7N/nJWwLl5PoqjjEn/0X7tPOqcqjlk5+2TZwEFXic0QwPvKNDZfcdqoPuiihw9", - "9/pst22ExonEmLwShIMytk5pWae6lqDIb637UjHJWGqWopKB+h2hEoiqq0pIvbp1h3xsIpu9XbPpQlAd", - "xVYWNm4yvDvv7ds1MUpkivxIOZ2BRBfAtFV9WhoDHQgNCppAcbuQzRFz+3AzFNIMooEVdXAigeh11tyk", - "G4ZaAeP+A1PaC4OV7vV0G9LIh3Gft+PTnkVcs912idAGfbw+2Jb7gUgwXtq6LEoUBocuyrSW6AOktYZN", - "ecT6IL0RoM7PHr0w4zqvhHb0nZRCDvfzB+AgWUrA/EwkqEpwBaGMJwvoxPenp8cEw3JinmjcYQOIHCnC", - "eFrUGcYvhhoVXRaCZkQJNOYNARHbHm1N0GVRYxwTCCb4+B1/ZhZ7ON0z5tFGA9Zp2EiIappQBeaXpFbL", - "MTFxp0XUI0UWrChIKrimjBNKHrwBLZejQxMXPsBH50BtnGXQYzxjKdWgXOS4mLN0TjQrMfQyrAClSUq5", - "sXMStGQmiHwhTAiKHgs8QKYIF5oYMaHGnnuT8UCRuvLBT1ow4NYlZoIoUYIJtGZEAlWCWytqPSl8QCVg", - "tCAJTS9FnqMVbDItb/2GaV4JStFZSPZWhMvyvX0+JFkvCloCT8UfQSoX+G8p5VftGzdj4R90NjKExUtM", - "o2lRvM6jg59vthYnPtY3b13HqwjTVLMrlyL3Bf55+5f39QVVmvg3iInaXUYQjIYxZA0ZFvODDfpZCUrT", - "supyMqMaRuaXEEwWAPf27dFzj+FLm0RvyL+3Tf2NR2ky/7rK7ng3K4y3mHqatet1agENr86uz1AMfgRN", - "jRWwnMwyG6jT4rjH4cHmVzITmTAtqVyS0gFzgZoakx+FtKa+KuBDN0px+l8KkzFaH1cbs0Yu6DgZpxdG", - "75EkPrG6BJuswAdqYDm1seJ8EJ1UkmkgLySbzU3cYqLaMZSUFQbrZSKB/0vigiYhZ/4J1LToxD5ATvT/", - "/s8VFB1X2FOXk05UEaYTxv/BdxtZ8SGX5YOttVCeGgpg2aUqQLvPHInFBB/llOETzYeKGjMexdEvNdT2", - "A5XpnF11PmJEh+BHzuo2QHpf2M8IpTYkGnUXj+JoQW1ZYJQLOTKxrwqGhC9F8tbKWzDeV6CbKlBMjLci", - "4gokORHpJeij1+iRMAdK55TPTHz/QkjCYWG+VDG5qCRcMVGrc5TmC3RMJuX/gB4T7XnfON2Rtnuj3Af0", - "ipbdJCacz/eQvpW96NYemyzz4TT+7EJkD/r6EuQ6M3XqrZPZszXk+OgXGKuAiXJFyGbza9yX+rcapCVN", - "xyTYGmF08NC44NasrTMU13FkC0TnydIWUVdxOfOfzhnvKW2jL04hz64HaQsi8jEqGWel0fmdcGDxxcb3", - "BStMmJW0xjf2pvSHo3/9rrWkwVKPyHMFfUSD8tXS6eMt6qdqS5u5bked1FjdZlcdrq3K8BvQteRYWzB2", - "BSvE1IflzAUkdgu9YvEt9aqTR62X3jegXHl5kNBtn8thULYxfQvnOS6nfCZ4zma1pDoYkqo5LSn/zkbT", - "WbBKj0WuOZAT+ygxfoJoSbnKQZLD4yNbmfFZ5zhc19NC0hn8IFIaLok/b+o6NokxkYGRELuWe3m80eys", - "rhKv7C5EpTcwY0qDhAxT0yGFaJZJUGGtMJbyvBskD70LSy/XJ7cF1ca8hmsdItcLKtcUQrZyCrilVn6b", - "wsN50xNSt1P7L+phNbSIG6J2e1meGHGUYqHQYhmtUrlDmTU7CvH5BNLa+Jwm++8zees08Kb8DxXk2RzS", - "S1EHWksnGCnZQAuNk54Dk+Tk+8Pdh49Ial5UdRkTxX611cBkqUFhZpyBMiiQwgm3LyGkbrW2MroSSmNY", - "Z/JAW9c8iNqi/XgmUEeig2jvYTLdf7qT7j5Opnt7e9lOnuw/zNPp4ydP6c5uSqePkp3s0f4023346Onj", - "J9PkyfRxBg+n+9nj6e5TmBpA7FeIDnb2d/dtIomrFWI2Y3zWXerRXvJ4N320lzzd393Ps5295One42me", - "PJpOHz2dPpmme3Tn4eOdx2m+R7P9/d1Hew+TnSeP00f0ydOH08dP26V2H18P/bOnyLFFYNBbonpuIlKJ", - "NQtnJF0ZvddH8XDG5Mi1xAtqggRfnHDmsGGALUhTRVJncCHDHLhZZEyOOBFFBpK4NF75CNPBsusuqCLv", - "a4X90HfNdsjR83dRTJJaN57MQSGsqblQxMJWRS5cbDRSRT2bqBQ4jIz2TbBtNTp6ftHrDrRK70RmSyeF", - "uL9gBZxUkG70Vwg87rNpsza1/nTYS5X2N0xCVrgSakh/hni4xHtVME7tn0j6jOU5SFuumlNOFnOqLSub", - "dCw2wtEFanMc4KqWhnGumdiqsS3XWXbeifCFWL1a4tqOJQ2rhwaugpTlzFkoyw/rwZ2tckh3/HmfNVWQ", - "Jd6de13pQvQYB6s4cxrAsG9quzCDMKyd+TiMYqFvowOlxdXYZE693YqjajsC/8T0vK0vbEXq2BVlU2vO", - "kjWkj4lJv4WOSQYV8MwOcnDbsEF3/A/Om23jpw47XAy1kavdDPMm9g7KRjW/5GLBbeJcCJphkccwrBe5", - "tvtHYG8QGzsz8AZNzWcHHjbQ6NFubSxxT0HDVwkQvoJ7W8/8Pr+wDRP2asitXIqSUCI7r3mXEndZ6ZJc", - "0Vd3kFcm7nhhQWGLSQKxgmY8iXvMfOcLbbggtnPaFtjXkoFWMRt9uB+x6C7UqNsdy0rHfH+p1ODQXd9w", - "rKi44/9tfe5dGcIbjF63xRQcJmgzknb2zIin76etSOA29b8vbxS4H/Y+/Qf5658+/fnTXz7916c///VP", - "n/77018+/We3iGnLtt1ymFvlPC2z6CD66P68tjFvzS/PUQj3zJ60pKk+p3XGhC+YGea53Gki7ZsTlU/e", - "i0RhDL+zuze2ILul2eNXfzB/Vio6MEqUS1oa9kY7ox2jYKykM1DnQp5fsQyEcYX2myiORK2rWuPgC3zQ", - "wLEPGY0r638Qg3P31BAvXKnBbBIml5vQGcCTQugb4XUURzHD/5Gj5ghfiQYK2xWODaW1pj227ZDwpkJ9", - "RwY2lRT9o+uL9eHZhm0q6J2Z5Fu0pZoGVFMBVyLXbYMq0G5yrapQcGJwWNczan8jdmKOa5IsCXWzIUbx", - "sVyPQ5do197V0+nuI1KImbNxdlzdjgvghIkb7ty2ff2aw6hg3M03urkGm7Q9UCRt5tTmdqDMhMfe1dqF", - "x+T1FciFMTiK+DZQscS9NB1w3ygNBa+FmIWi6RkxSHXmac1qcdMN8+NtBmlLCrsgUFkwHKoZ1u96srDt", - "JHuoso3cwXLpumLyFxQ7IZXYrBj+9IVFy1VPhSv16o3BJTr1yrO19DhhM/76tpTw9cvz9WMfd77tTu11", - "zW4HWN2wa001PLO93EBHATs0raG4VZE6GFd0gG2FVLYOqzvAZQMGfaOrNJUaMzm6oJe28q0KgMpENLYS", - "bXLhWmeY+WlQ7mmR58YSBGwrKoutZZ8YrHF7C4vAOa1DWfpbBdLw3phbY8LwYXL0PCYVVWohZOZ/Qu3A", - "cxmEav+o7Ki9sTOWXrbHQxVLW8Mz17qKrg2OjOcCx+W4pqlup5+aKSlyCtQoXy0L96Y6mExyH/MxMRl2", - "RN/gwPQLKktSujLa4fFRFEcFS8GlUm6dPxz/cLU3gL9YLMYzXpsQcOLeUZNZVYz2xtMx8PFclzgnwnTR", - "w9YtF3WGtaKd8XQ8tT3UCjitmIkX7VdYDLCcmdCKTdLVJtwMjZ2RUPvdUWZHEHW/W2fkD5MwC2p3OvUk", - "BW7fp1VVuBrQ5L1C0CjLmyQ92B20nOtTnJsQs2iSQZS/uiypXCLGWOvpgmlmHztTrZqauOhnG57ZnnoL", - "4zueVYJxbZ3ezM22DwA2fGiAXsdIW99RrYQK0BSzDxwFcVbk9yJb3hkd+wN0Q/rZYWnh8pqoa1BMuH99", - "jxy+AaEFVUTVaQoqr4tiSfCojp1LdeHQFctqWuDpnvHKeak7wQ77cAH87A/Et9n64obEJtSPDlmRWZWM", - "ztRxV/KwT94D99KfysBDRuAEsS9ak1/8MEpYwGy3/6UBfj8C1s7DBIg1qAJj9ddOP2hhtGn8tWWuN/4Q", - "QPkVGhRL1casxL6LAmWll6RgShOWEy5w4LekOp3b9gvgi9+OSL4Anc4RYZzxVxuE7nVih63bAZXczsTY", - "g3U8I0rI5hBhK4PGvU4+mv9f0RKub/Igfuy/Pzr/88eIma24GrVzkR7gQEbiDslW44+ze5Sf4eGFNRYV", - "f1t1RW563p+0WHMK5QbmHPFcEJqI2s7iO+XqnOobMEVtwQoVfUWKqRDJmofaQygB6hWDgyr2DIctL29N", - "wXapxi6/b08H9+j38b1Izlm2XpqtbqHr3izLCOxGSd40tHL2t/HGNlQOWRXWSqMbQ93CoeFLPHMBa2kw", - "92Tv5JvrZPaPzQGDeyPF6jGJz446GwnzgxArgefNcecze7gE+/e1ckMfWmA3DP9iitBU19TESLRdzrVV", - "GrJikjSRbpRstGgnyYIhg585cxNn9xM3BOo1AUK3NTeP/VeNFwbTd9vIwlf0/DWHDxWkGjI8fLUiiB59", - "F5EuPD+91LkvzgIvtZlN+6ZalSjFZnwk8vyG9IbN+Os8H6rr/rAM8O0R0tUxrEnvVTB+PjPGuKXZj1Re", - "dksXVBFfIdlA7We0cIdQUMKsihfOgPiM4ZLb89uwfCCBzATea2HBj8Ms4Rs4wu9Vqd0S69W5aYJ8TV0e", - "lgb/LpR5axk8rPUcuMZOgetHGGnwndZFc7T1jgVSAs2W5ikDD6dBez0S1jJ8KK7atWCC/r7DsuhvLRkW", - "U3dAqB2aM/tZY8zI+je+bZG6vXhgSLJoZ/El4KUQyzVECMvBKO1Ux4PGK1BJv1dD1l0oVCtoXCPucwt7", - "9o/l95w9d3xDIvixUz/hR02QagxGARnG+9ghdbak7dj2ZMWOADLeHgV39gXkqBApLaxpo4W6a3t2Bb3d", - "1GogqtpdWLbGvaZzyOoCTvFowP3l1d3r0wKMtRendSuN6wzVK+HuSOpfd2LzC38bwnUc7U/37q4m3Tvr", - "EED+GKQvej4HztBo7k+fBs7/oAC60/rO0+EoAYpTTJTwP9urpqB37QNu3c7kEC4WuNXdva/rWrwWUW6w", - "FFiAM2G3xQ5n6u3tLDNhb8ziwtpZ1LZbaqwr79EGfocam1TJypRyAi4D9eiOhkw+2uatK5+EdaUzhLFV", - "NRABfnkJ5e7dRWcn63TRxUOd2zE+z1uczsHDWljTmkLlPWpQRU7dUIj1yM5qdMUImRbjJR892FZnuvD/", - "XtzS23Y+BwdU9LJiqS2TdMdpKilmEpSK3S0B7qIwSXLKilrCRt/iPYoCnvWqYYbcHrqxYiYiQjXBE38T", - "P3w+wZMbN/iT/pmte2oS9hcJNXK6E9pNxOcOsHy9HC545iaArn/CirE/HNPpKHa15X4lucGEFpgn2ZsJ", - "lXM0+/ePwKmNxhfmP+Se9ax8NiZvFZALtULRdoz7wvAZD+sQS0rbuhMc1PhbqnE9wyNxnavXMAVVy7Jg", - "/LK5+ceeTkQKYBdZ4wkmRxTjXmlRkDm9ArxmEueu0Va6KeUEcnunCC2K5rLK1gu2xgKJumIsThxClKiu", - "MllkeiclqQQaNhbdKfttTUaXpfdqPkInPba1JH8DIxI86BDCt7lBxN6IJWym0mVE7B2Kv0HKnQzALX5b", - "umIP0rSnELs0cMez3JVpQmrlNB45RWWzsY2SfmjibLNMez+WrxD0AbYphzsXgp0LxKK1N3iBoGZF0aLQ", - "UQ8Lb/LRnxK6nny037Bfb2hEdw8MCAnPnBCuBKFbn/+y1zUMI1b/6K361/HwRuNfYfUAW3P6KbCq3/02", - "q7bHAc/uXeMGh0TWT1+0Z3u+Ne3pDn23h1mCx5rwROtQUW6y2o1E/v8WxjiUxDhr0t7yh5f+4eHyDHKQ", - "pDkrhb7ZUsN6+XfR7vTJu2jlZkGbbvNi6a4DrCXvXlCI21NN5IazaM3htAHDMVGnhRIIQ4kSBAcCBV5y", - "2I7th9C00mIJiDcQtiT89xEuM3pG+ei52eforQUQBWjYuUM3REMh2YxxWtg1DfwxOcrduYBCdM8RNIf4", - "mG7m+1fvh8R921H/5mAv5YQy+0QGSY2XK2yxt9cOsdELh1i0ccZnm0BGpBr0SGkJtOxbiKZSkDBu9HtY", - "KxjG8riGWjn5+5lJvBWvQQq/O32y6XEnjj1B7LT893ceByFI97pJAOzAGklAL8AJu78cszU6frrGjRi4", - "u26s+suB3WmCZS/LNr15GLhvEJXY3WexQWu9Braa4y8mlSIFZRmRgHmxWT9Z9vQOQ4mLtSp0QAzPLnDi", - "FK1LlxxuJ9+KB7KewdXu1vsd8krY4gfVwx+tfuZCpiwpliQthMIyib1LNRWcg716z92J5ypEzvDmjDM1", - "B9XjFxD4QFNNFC3BhZBa2DNH5pVM1Ca6wxfU+B33XH1gr4JAbXKykECIAyQR2XKtK+2WfOxNtU1aMSSL", - "qyGZz+hQcfB+EnV6XoNb9vsTToNRXqYVFPm4tWd2jmdoel+KxLdkbW3olxokAxV3xnvjlaGocW90TAWA", - "Hh4f9QeMux05UZY1d6fGjEkfzqc34F1pK+DrkX6Hx0exXciKXMt8tyFbXjF/4wWKmHWqDnzHr+uz6/8L", - "AAD//zdTo0YPZgAA", + "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", + "vqPX9dn1/wQAAP//GzfZKqRpAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/pkg/api/openapi_types.gen.go b/pkg/api/openapi_types.gen.go index 50f83ada..64ebb470 100644 --- a/pkg/api/openapi_types.gen.go +++ b/pkg/api/openapi_types.gen.go @@ -230,6 +230,13 @@ type JobSettings struct { // JobStatus defines model for JobStatus. type JobStatus string +// JobStatusChange defines model for JobStatusChange. +type JobStatusChange struct { + // The reason for this status change. + Reason string `json:"reason"` + Status JobStatus `json:"status"` +} + // Subset of a Job, sent over SocketIO when a job changes. For new jobs, `previous_status` will be excluded. type JobUpdate struct { // UUID of the Job @@ -416,6 +423,9 @@ type SubmitJobJSONBody SubmittedJob // QueryJobsJSONBody defines parameters for QueryJobs. type QueryJobsJSONBody JobsQuery +// SetJobStatusJSONBody defines parameters for SetJobStatus. +type SetJobStatusJSONBody JobStatusChange + // RegisterWorkerJSONBody defines parameters for RegisterWorker. type RegisterWorkerJSONBody WorkerRegistration @@ -449,6 +459,9 @@ type SubmitJobJSONRequestBody SubmitJobJSONBody // QueryJobsJSONRequestBody defines body for QueryJobs for application/json ContentType. type QueryJobsJSONRequestBody QueryJobsJSONBody +// SetJobStatusJSONRequestBody defines body for SetJobStatus for application/json ContentType. +type SetJobStatusJSONRequestBody SetJobStatusJSONBody + // RegisterWorkerJSONRequestBody defines body for RegisterWorker for application/json ContentType. type RegisterWorkerJSONRequestBody RegisterWorkerJSONBody diff --git a/web/app/src/manager-api/ApiClient.js b/web/app/src/manager-api/ApiClient.js index 2083a715..327bcec9 100644 --- a/web/app/src/manager-api/ApiClient.js +++ b/web/app/src/manager-api/ApiClient.js @@ -55,7 +55,7 @@ class ApiClient { * @default {} */ this.defaultHeaders = { - 'User-Agent': 'Flamenco/d79fde17-dirty / webbrowser' + 'User-Agent': 'Flamenco/b699647e-dirty / webbrowser' }; /** diff --git a/web/app/src/manager-api/index.js b/web/app/src/manager-api/index.js index 4ff6bb2b..7549a99c 100644 --- a/web/app/src/manager-api/index.js +++ b/web/app/src/manager-api/index.js @@ -25,6 +25,7 @@ import FlamencoVersion from './model/FlamencoVersion'; import Job from './model/Job'; import JobAllOf from './model/JobAllOf'; import JobStatus from './model/JobStatus'; +import JobStatusChange from './model/JobStatusChange'; import JobUpdate from './model/JobUpdate'; import JobsQuery from './model/JobsQuery'; import JobsQueryResult from './model/JobsQueryResult'; @@ -163,6 +164,12 @@ export { */ JobStatus, + /** + * The JobStatusChange model constructor. + * @property {module:model/JobStatusChange} + */ + JobStatusChange, + /** * The JobUpdate model constructor. * @property {module:model/JobUpdate} diff --git a/web/app/src/manager-api/manager/JobsApi.js b/web/app/src/manager-api/manager/JobsApi.js index 3a3b3d0f..bcbfb787 100644 --- a/web/app/src/manager-api/manager/JobsApi.js +++ b/web/app/src/manager-api/manager/JobsApi.js @@ -17,6 +17,7 @@ import AvailableJobType from '../model/AvailableJobType'; import AvailableJobTypes from '../model/AvailableJobTypes'; import Error from '../model/Error'; import Job from '../model/Job'; +import JobStatusChange from '../model/JobStatusChange'; import JobsQuery from '../model/JobsQuery'; import JobsQueryResult from '../model/JobsQueryResult'; import SubmittedJob from '../model/SubmittedJob'; @@ -217,6 +218,56 @@ export default class JobsApi { } + /** + * @param {String} jobId + * @param {module:model/JobStatusChange} jobStatusChange The status change to request. + * @return {Promise} a {@link https://www.promisejs.org/|Promise}, with an object containing HTTP response + */ + setJobStatusWithHttpInfo(jobId, jobStatusChange) { + let postBody = jobStatusChange; + // verify the required parameter 'jobId' is set + if (jobId === undefined || jobId === null) { + throw new Error("Missing the required parameter 'jobId' when calling setJobStatus"); + } + // verify the required parameter 'jobStatusChange' is set + if (jobStatusChange === undefined || jobStatusChange === null) { + throw new Error("Missing the required parameter 'jobStatusChange' when calling setJobStatus"); + } + + let pathParams = { + 'job_id': jobId + }; + let queryParams = { + }; + let headerParams = { + }; + let formParams = { + }; + + let authNames = []; + let contentTypes = ['application/json']; + let accepts = ['application/json']; + let returnType = null; + return this.apiClient.callApi( + '/api/jobs/{job_id}/setstatus', 'POST', + pathParams, queryParams, headerParams, formParams, postBody, + authNames, contentTypes, accepts, returnType, null + ); + } + + /** + * @param {String} jobId + * @param {module:model/JobStatusChange} jobStatusChange The status change to request. + * @return {Promise} a {@link https://www.promisejs.org/|Promise} + */ + setJobStatus(jobId, jobStatusChange) { + return this.setJobStatusWithHttpInfo(jobId, jobStatusChange) + .then(function(response_and_data) { + return response_and_data.data; + }); + } + + /** * Submit a new job for Flamenco Manager to execute. * @param {module:model/SubmittedJob} submittedJob Job to submit diff --git a/web/app/src/manager-api/model/JobStatusChange.js b/web/app/src/manager-api/model/JobStatusChange.js new file mode 100644 index 00000000..ddb082f9 --- /dev/null +++ b/web/app/src/manager-api/model/JobStatusChange.js @@ -0,0 +1,85 @@ +/** + * Flamenco manager + * Render Farm manager API + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + * + */ + +import ApiClient from '../ApiClient'; +import JobStatus from './JobStatus'; + +/** + * The JobStatusChange model module. + * @module model/JobStatusChange + * @version 0.0.0 + */ +class JobStatusChange { + /** + * Constructs a new JobStatusChange. + * @alias module:model/JobStatusChange + * @param status {module:model/JobStatus} + * @param reason {String} The reason for this status change. + */ + constructor(status, reason) { + + JobStatusChange.initialize(this, status, reason); + } + + /** + * Initializes the fields of this object. + * This method is used by the constructors of any subclasses, in order to implement multiple inheritance (mix-ins). + * Only for internal use. + */ + static initialize(obj, status, reason) { + obj['status'] = status; + obj['reason'] = reason; + } + + /** + * Constructs a JobStatusChange from a plain JavaScript object, optionally creating a new instance. + * Copies all relevant properties from data to obj if supplied or a new instance if not. + * @param {Object} data The plain JavaScript object bearing properties of interest. + * @param {module:model/JobStatusChange} obj Optional instance to populate. + * @return {module:model/JobStatusChange} The populated JobStatusChange instance. + */ + static constructFromObject(data, obj) { + if (data) { + obj = obj || new JobStatusChange(); + + if (data.hasOwnProperty('status')) { + obj['status'] = JobStatus.constructFromObject(data['status']); + } + if (data.hasOwnProperty('reason')) { + obj['reason'] = ApiClient.convertToType(data['reason'], 'String'); + } + } + return obj; + } + + +} + +/** + * @member {module:model/JobStatus} status + */ +JobStatusChange.prototype['status'] = undefined; + +/** + * The reason for this status change. + * @member {String} reason + */ +JobStatusChange.prototype['reason'] = undefined; + + + + + + +export default JobStatusChange; +