WIP: 103268-job-task-progress #104185

Draft
Nitin-Rawat-1 wants to merge 19 commits from Nitin-Rawat-1/flamenco:103268-job-task-progress into main

When changing the target branch, be careful to rebase the branch in your fork to match. See documentation.
94 changed files with 5827 additions and 301 deletions
Showing only changes of commit 91d1e28d8a - Show all commits

View File

@ -1,8 +0,0 @@
{
"project_id": "Flamenco",
"conduit_uri": "https://developer.blender.org/",
"phabricator.uri": "https://developer.blender.org/",
"git.default-relative-commit": "origin/main",
"arc.land.update.default": "rebase",
"arc.land.onto.default": "main"
}

View File

@ -4,6 +4,13 @@ This file contains the history of changes to Flamenco. Only changes that might
be interesting for users are listed here, such as new features and fixes for
bugs in actually-released versions.
## 3.3 - in development
- Improve speed of queueing up >100 simultaneous job deletions.
- Improve logging of job deletion.
- Add Worker Cluster support. Workers can be members of any number of clusters. Workers will only work on jobs that are assigned to that cluster. Jobs that do not have a cluster will be available to all workers, regardless of their cluster assignment. As a result, clusterless workers will only work on clusterless jobs.
## 3.2 - released 2023-02-21
- When rendering EXR files, use Blender's preview JPEG files to generate the preview video ([43bc22f10fae](https://developer.blender.org/rF43bc22f10fae0fcaed6a4a3b3ace1be617193e21)).

View File

@ -2,13 +2,13 @@ PKG := git.blender.org/flamenco
# To update the version number in all the relevant places, update the VERSION
# variable below and run `make update-version`.
VERSION := 3.2
RELEASE_CYCLE := release
VERSION := 3.3-alpha0
RELEASE_CYCLE := alpha
# _GIT_DESCRIPTION_OR_TAG is either something like '16-123abc' (when we're 16
# commits since the last tag) or it's something like `v3.0-beta2` (when exactly
# on a tagged version).
_GIT_DESCRIPTION_OR_TAG := $(subst v${VERSION}-,,$(shell git describe --dirty --always))
_GIT_DESCRIPTION_OR_TAG := $(subst v${VERSION}-,,$(shell git describe --tag --dirty --always))
# In the above cases, GITHASH is either `16-123abc` (in the same case above) or
# `123abc` (when the tag matches the current commit exactly) or `dirty` (when
# the tag matches the current commit exactly, and there are subsequent
@ -17,7 +17,7 @@ _GIT_DESCRIPTION_OR_TAG := $(subst v${VERSION}-,,$(shell git describe --dirty --
# ${GITHASH}.
GITHASH := $(subst v${VERSION},$(shell git rev-parse --short HEAD),${_GIT_DESCRIPTION_OR_TAG})
LDFLAGS := -X ${PKG}/internal/appinfo.ApplicationVersion=${VERSION} \
LDFLAGS := ${LDFLAGS} -X ${PKG}/internal/appinfo.ApplicationVersion=${VERSION} \
-X ${PKG}/internal/appinfo.ApplicationGitHash=${GITHASH} \
-X ${PKG}/internal/appinfo.ReleaseCycle=${RELEASE_CYCLE}
BUILD_FLAGS = -ldflags="${LDFLAGS}"

View File

@ -5,21 +5,21 @@
bl_info = {
"name": "Flamenco 3",
"author": "Sybren A. Stüvel",
"version": (3, 2),
"version": (3, 3),
"blender": (3, 1, 0),
"description": "Flamenco client for Blender.",
"location": "Output Properties > Flamenco",
"doc_url": "https://flamenco.blender.org/",
"category": "System",
"support": "COMMUNITY",
"warning": "",
"warning": "This is version 3.3-alpha0 of the add-on, which is not a stable release",
}
from pathlib import Path
__is_first_load = "operators" not in locals()
if __is_first_load:
from . import operators, gui, job_types, comms, preferences
from . import operators, gui, job_types, comms, preferences, worker_clusters
else:
import importlib
@ -28,6 +28,7 @@ else:
job_types = importlib.reload(job_types)
comms = importlib.reload(comms)
preferences = importlib.reload(preferences)
worker_clusters = importlib.reload(worker_clusters)
import bpy
@ -145,6 +146,7 @@ def register() -> None:
)
preferences.register()
worker_clusters.register()
operators.register()
gui.register()
job_types.register()
@ -162,4 +164,5 @@ def unregister() -> None:
job_types.unregister()
gui.unregister()
operators.unregister()
worker_clusters.unregister()
preferences.unregister()

View File

@ -43,6 +43,11 @@ class FLAMENCO_PT_job_submission(bpy.types.Panel):
col.prop(context.scene, "flamenco_job_name", text="Job Name")
col.prop(context.scene, "flamenco_job_priority", text="Priority")
# Worker cluster:
row = col.row(align=True)
row.prop(context.scene, "flamenco_worker_cluster", text="Cluster")
row.operator("flamenco.fetch_worker_clusters", text="", icon="FILE_REFRESH")
layout.separator()
col = layout.column()

View File

@ -53,6 +53,11 @@ def job_for_scene(scene: bpy.types.Scene) -> Optional[_SubmittedJob]:
submitter_platform=platform.system().lower(),
type_etag=propgroup.job_type.etag,
)
worker_cluster: str = getattr(scene, "flamenco_worker_cluster", "")
if worker_cluster and worker_cluster != "-":
job.worker_cluster = worker_cluster
return job

View File

@ -10,7 +10,7 @@
"""
__version__ = "3.2"
__version__ = "3.3-alpha0"
# import ApiClient
from flamenco.manager.api_client import ApiClient

View File

@ -23,6 +23,9 @@ from flamenco.manager.model_utils import ( # noqa: F401
)
from flamenco.manager.model.error import Error
from flamenco.manager.model.worker import Worker
from flamenco.manager.model.worker_cluster import WorkerCluster
from flamenco.manager.model.worker_cluster_change_request import WorkerClusterChangeRequest
from flamenco.manager.model.worker_cluster_list import WorkerClusterList
from flamenco.manager.model.worker_list import WorkerList
from flamenco.manager.model.worker_sleep_schedule import WorkerSleepSchedule
from flamenco.manager.model.worker_status_change_request import WorkerStatusChangeRequest
@ -39,6 +42,56 @@ class WorkerMgtApi(object):
if api_client is None:
api_client = ApiClient()
self.api_client = api_client
self.create_worker_cluster_endpoint = _Endpoint(
settings={
'response_type': (WorkerCluster,),
'auth': [],
'endpoint_path': '/api/v3/worker-mgt/clusters',
'operation_id': 'create_worker_cluster',
'http_method': 'POST',
'servers': None,
},
params_map={
'all': [
'worker_cluster',
],
'required': [
'worker_cluster',
],
'nullable': [
],
'enum': [
],
'validation': [
]
},
root_map={
'validations': {
},
'allowed_values': {
},
'openapi_types': {
'worker_cluster':
(WorkerCluster,),
},
'attribute_map': {
},
'location_map': {
'worker_cluster': 'body',
},
'collection_format_map': {
}
},
headers_map={
'accept': [
'application/json'
],
'content_type': [
'application/json'
]
},
api_client=api_client
)
self.delete_worker_endpoint = _Endpoint(
settings={
'response_type': None,
@ -88,6 +141,55 @@ class WorkerMgtApi(object):
},
api_client=api_client
)
self.delete_worker_cluster_endpoint = _Endpoint(
settings={
'response_type': None,
'auth': [],
'endpoint_path': '/api/v3/worker-mgt/cluster/{cluster_id}',
'operation_id': 'delete_worker_cluster',
'http_method': 'DELETE',
'servers': None,
},
params_map={
'all': [
'cluster_id',
],
'required': [
'cluster_id',
],
'nullable': [
],
'enum': [
],
'validation': [
]
},
root_map={
'validations': {
},
'allowed_values': {
},
'openapi_types': {
'cluster_id':
(str,),
},
'attribute_map': {
'cluster_id': 'cluster_id',
},
'location_map': {
'cluster_id': 'path',
},
'collection_format_map': {
}
},
headers_map={
'accept': [
'application/json'
],
'content_type': [],
},
api_client=api_client
)
self.fetch_worker_endpoint = _Endpoint(
settings={
'response_type': (Worker,),
@ -137,6 +239,97 @@ class WorkerMgtApi(object):
},
api_client=api_client
)
self.fetch_worker_cluster_endpoint = _Endpoint(
settings={
'response_type': (WorkerCluster,),
'auth': [],
'endpoint_path': '/api/v3/worker-mgt/cluster/{cluster_id}',
'operation_id': 'fetch_worker_cluster',
'http_method': 'GET',
'servers': None,
},
params_map={
'all': [
'cluster_id',
],
'required': [
'cluster_id',
],
'nullable': [
],
'enum': [
],
'validation': [
]
},
root_map={
'validations': {
},
'allowed_values': {
},
'openapi_types': {
'cluster_id':
(str,),
},
'attribute_map': {
'cluster_id': 'cluster_id',
},
'location_map': {
'cluster_id': 'path',
},
'collection_format_map': {
}
},
headers_map={
'accept': [
'application/json'
],
'content_type': [],
},
api_client=api_client
)
self.fetch_worker_clusters_endpoint = _Endpoint(
settings={
'response_type': (WorkerClusterList,),
'auth': [],
'endpoint_path': '/api/v3/worker-mgt/clusters',
'operation_id': 'fetch_worker_clusters',
'http_method': 'GET',
'servers': None,
},
params_map={
'all': [
],
'required': [],
'nullable': [
],
'enum': [
],
'validation': [
]
},
root_map={
'validations': {
},
'allowed_values': {
},
'openapi_types': {
},
'attribute_map': {
},
'location_map': {
},
'collection_format_map': {
}
},
headers_map={
'accept': [
'application/json'
],
'content_type': [],
},
api_client=api_client
)
self.fetch_worker_sleep_schedule_endpoint = _Endpoint(
settings={
'response_type': (WorkerSleepSchedule,),
@ -284,6 +477,62 @@ class WorkerMgtApi(object):
},
api_client=api_client
)
self.set_worker_clusters_endpoint = _Endpoint(
settings={
'response_type': None,
'auth': [],
'endpoint_path': '/api/v3/worker-mgt/workers/{worker_id}/setclusters',
'operation_id': 'set_worker_clusters',
'http_method': 'POST',
'servers': None,
},
params_map={
'all': [
'worker_id',
'worker_cluster_change_request',
],
'required': [
'worker_id',
'worker_cluster_change_request',
],
'nullable': [
],
'enum': [
],
'validation': [
]
},
root_map={
'validations': {
},
'allowed_values': {
},
'openapi_types': {
'worker_id':
(str,),
'worker_cluster_change_request':
(WorkerClusterChangeRequest,),
},
'attribute_map': {
'worker_id': 'worker_id',
},
'location_map': {
'worker_id': 'path',
'worker_cluster_change_request': 'body',
},
'collection_format_map': {
}
},
headers_map={
'accept': [
'application/json'
],
'content_type': [
'application/json'
]
},
api_client=api_client
)
self.set_worker_sleep_schedule_endpoint = _Endpoint(
settings={
'response_type': None,
@ -340,6 +589,139 @@ class WorkerMgtApi(object):
},
api_client=api_client
)
self.update_worker_cluster_endpoint = _Endpoint(
settings={
'response_type': None,
'auth': [],
'endpoint_path': '/api/v3/worker-mgt/cluster/{cluster_id}',
'operation_id': 'update_worker_cluster',
'http_method': 'PUT',
'servers': None,
},
params_map={
'all': [
'cluster_id',
'worker_cluster',
],
'required': [
'cluster_id',
'worker_cluster',
],
'nullable': [
],
'enum': [
],
'validation': [
]
},
root_map={
'validations': {
},
'allowed_values': {
},
'openapi_types': {
'cluster_id':
(str,),
'worker_cluster':
(WorkerCluster,),
},
'attribute_map': {
'cluster_id': 'cluster_id',
},
'location_map': {
'cluster_id': 'path',
'worker_cluster': 'body',
},
'collection_format_map': {
}
},
headers_map={
'accept': [
'application/json'
],
'content_type': [
'application/json'
]
},
api_client=api_client
)
def create_worker_cluster(
self,
worker_cluster,
**kwargs
):
"""Create a new worker cluster. # noqa: E501
This method makes a synchronous HTTP request by default. To make an
asynchronous HTTP request, please pass async_req=True
>>> thread = api.create_worker_cluster(worker_cluster, async_req=True)
>>> result = thread.get()
Args:
worker_cluster (WorkerCluster): The worker cluster.
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:
WorkerCluster
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['worker_cluster'] = \
worker_cluster
return self.create_worker_cluster_endpoint.call_with_http_info(**kwargs)
def delete_worker(
self,
@ -418,6 +800,83 @@ class WorkerMgtApi(object):
worker_id
return self.delete_worker_endpoint.call_with_http_info(**kwargs)
def delete_worker_cluster(
self,
cluster_id,
**kwargs
):
"""Remove this worker cluster. This unassigns all workers from the cluster and removes it. # noqa: E501
This method makes a synchronous HTTP request by default. To make an
asynchronous HTTP request, please pass async_req=True
>>> thread = api.delete_worker_cluster(cluster_id, async_req=True)
>>> result = thread.get()
Args:
cluster_id (str):
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['cluster_id'] = \
cluster_id
return self.delete_worker_cluster_endpoint.call_with_http_info(**kwargs)
def fetch_worker(
self,
worker_id,
@ -495,6 +954,155 @@ class WorkerMgtApi(object):
worker_id
return self.fetch_worker_endpoint.call_with_http_info(**kwargs)
def fetch_worker_cluster(
self,
cluster_id,
**kwargs
):
"""Get a single worker cluster. # noqa: E501
This method makes a synchronous HTTP request by default. To make an
asynchronous HTTP request, please pass async_req=True
>>> thread = api.fetch_worker_cluster(cluster_id, async_req=True)
>>> result = thread.get()
Args:
cluster_id (str):
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:
WorkerCluster
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['cluster_id'] = \
cluster_id
return self.fetch_worker_cluster_endpoint.call_with_http_info(**kwargs)
def fetch_worker_clusters(
self,
**kwargs
):
"""Get list of worker clusters. # noqa: E501
This method makes a synchronous HTTP request by default. To make an
asynchronous HTTP request, please pass async_req=True
>>> thread = api.fetch_worker_clusters(async_req=True)
>>> result = thread.get()
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:
WorkerClusterList
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')
return self.fetch_worker_clusters_endpoint.call_with_http_info(**kwargs)
def fetch_worker_sleep_schedule(
self,
worker_id,
@ -725,6 +1333,87 @@ class WorkerMgtApi(object):
worker_status_change_request
return self.request_worker_status_change_endpoint.call_with_http_info(**kwargs)
def set_worker_clusters(
self,
worker_id,
worker_cluster_change_request,
**kwargs
):
"""set_worker_clusters # 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_worker_clusters(worker_id, worker_cluster_change_request, async_req=True)
>>> result = thread.get()
Args:
worker_id (str):
worker_cluster_change_request (WorkerClusterChangeRequest): The list of cluster IDs this worker should be a member of.
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['worker_id'] = \
worker_id
kwargs['worker_cluster_change_request'] = \
worker_cluster_change_request
return self.set_worker_clusters_endpoint.call_with_http_info(**kwargs)
def set_worker_sleep_schedule(
self,
worker_id,
@ -806,3 +1495,84 @@ class WorkerMgtApi(object):
worker_sleep_schedule
return self.set_worker_sleep_schedule_endpoint.call_with_http_info(**kwargs)
def update_worker_cluster(
self,
cluster_id,
worker_cluster,
**kwargs
):
"""Update an existing worker cluster. # noqa: E501
This method makes a synchronous HTTP request by default. To make an
asynchronous HTTP request, please pass async_req=True
>>> thread = api.update_worker_cluster(cluster_id, worker_cluster, async_req=True)
>>> result = thread.get()
Args:
cluster_id (str):
worker_cluster (WorkerCluster): The updated worker cluster.
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['cluster_id'] = \
cluster_id
kwargs['worker_cluster'] = \
worker_cluster
return self.update_worker_cluster_endpoint.call_with_http_info(**kwargs)

View File

@ -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/3.2 (Blender add-on)'
self.user_agent = 'Flamenco/3.3-alpha0 (Blender add-on)'
def __enter__(self):
return self

View File

@ -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: 3.2".\
"SDK Package Version: 3.3-alpha0".\
format(env=sys.platform, pyversion=sys.version)
def get_host_settings(self):

View File

@ -17,6 +17,7 @@ Name | Type | Description | Notes
**settings** | [**JobSettings**](JobSettings.md) | | [optional]
**metadata** | [**JobMetadata**](JobMetadata.md) | | [optional]
**storage** | [**JobStorageInfo**](JobStorageInfo.md) | | [optional]
**worker_cluster** | **str** | Worker Cluster that should execute this job. When a cluster ID is given, only Workers in that cluster will be scheduled to work on it. If empty or ommitted, all workers can work on this job. | [optional]
**delete_requested_at** | **datetime** | If job deletion was requested, this is the timestamp at which that request was stored on Flamenco Manager. | [optional]
**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]

View File

@ -1225,6 +1225,7 @@ with flamenco.manager.ApiClient() as api_client:
storage=JobStorageInfo(
shaman_checkout_id="shaman_checkout_id_example",
),
worker_cluster="worker_cluster_example",
) # SubmittedJob | Job to submit
# example passing only required values which don't have defaults set
@ -1306,6 +1307,7 @@ with flamenco.manager.ApiClient() as api_client:
storage=JobStorageInfo(
shaman_checkout_id="shaman_checkout_id_example",
),
worker_cluster="worker_cluster_example",
) # SubmittedJob | Job to check
# example passing only required values which don't have defaults set

View File

@ -13,6 +13,7 @@ Name | Type | Description | Notes
**settings** | [**JobSettings**](JobSettings.md) | | [optional]
**metadata** | [**JobMetadata**](JobMetadata.md) | | [optional]
**storage** | [**JobStorageInfo**](JobStorageInfo.md) | | [optional]
**worker_cluster** | **str** | Worker Cluster that should execute this job. When a cluster ID is given, only Workers in that cluster will be scheduled to work on it. If empty or ommitted, all workers can work on this job. | [optional]
**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)

View File

@ -15,6 +15,7 @@ Name | Type | Description | Notes
**status_change** | [**WorkerStatusChangeRequest**](WorkerStatusChangeRequest.md) | | [optional]
**last_seen** | **datetime** | Last time this worker was seen by the Manager. | [optional]
**task** | [**WorkerTask**](WorkerTask.md) | | [optional]
**clusters** | [**[WorkerCluster]**](WorkerCluster.md) | Clusters of which this Worker is a member. | [optional]
**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)

View File

@ -8,6 +8,7 @@ Name | Type | Description | Notes
**platform** | **str** | Operating system of the Worker |
**supported_task_types** | **[str]** | |
**task** | [**WorkerTask**](WorkerTask.md) | | [optional]
**clusters** | [**[WorkerCluster]**](WorkerCluster.md) | Clusters of which this Worker is a member. | [optional]
**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)

View File

@ -0,0 +1,15 @@
# WorkerCluster
Cluster of workers. A job can optionally specify which cluster it should be limited to. Workers can be part of multiple clusters simultaneously.
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**name** | **str** | |
**id** | **str** | UUID of the cluster. Can be ommitted when creating a new cluster, in which case a random UUID will be assigned. | [optional]
**description** | **str** | | [optional]
**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)

View File

@ -0,0 +1,13 @@
# WorkerClusterChangeRequest
Request to change which clusters this Worker is assigned to.
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**cluster_ids** | **[str]** | |
**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)

View File

@ -0,0 +1,12 @@
# WorkerClusterList
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**clusters** | [**[WorkerCluster]**](WorkerCluster.md) | | [optional]
**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)

View File

@ -4,14 +4,91 @@ All URIs are relative to *http://localhost*
Method | HTTP request | Description
------------- | ------------- | -------------
[**create_worker_cluster**](WorkerMgtApi.md#create_worker_cluster) | **POST** /api/v3/worker-mgt/clusters | Create a new worker cluster.
[**delete_worker**](WorkerMgtApi.md#delete_worker) | **DELETE** /api/v3/worker-mgt/workers/{worker_id} | Remove the given worker. It is recommended to only call this function when the worker is in `offline` state. If the worker is still running, stop it first. Any task still assigned to the worker will be requeued.
[**delete_worker_cluster**](WorkerMgtApi.md#delete_worker_cluster) | **DELETE** /api/v3/worker-mgt/cluster/{cluster_id} | Remove this worker cluster. This unassigns all workers from the cluster and removes it.
[**fetch_worker**](WorkerMgtApi.md#fetch_worker) | **GET** /api/v3/worker-mgt/workers/{worker_id} | Fetch info about the worker.
[**fetch_worker_cluster**](WorkerMgtApi.md#fetch_worker_cluster) | **GET** /api/v3/worker-mgt/cluster/{cluster_id} | Get a single worker cluster.
[**fetch_worker_clusters**](WorkerMgtApi.md#fetch_worker_clusters) | **GET** /api/v3/worker-mgt/clusters | Get list of worker clusters.
[**fetch_worker_sleep_schedule**](WorkerMgtApi.md#fetch_worker_sleep_schedule) | **GET** /api/v3/worker-mgt/workers/{worker_id}/sleep-schedule |
[**fetch_workers**](WorkerMgtApi.md#fetch_workers) | **GET** /api/v3/worker-mgt/workers | Get list of workers.
[**request_worker_status_change**](WorkerMgtApi.md#request_worker_status_change) | **POST** /api/v3/worker-mgt/workers/{worker_id}/setstatus |
[**set_worker_clusters**](WorkerMgtApi.md#set_worker_clusters) | **POST** /api/v3/worker-mgt/workers/{worker_id}/setclusters |
[**set_worker_sleep_schedule**](WorkerMgtApi.md#set_worker_sleep_schedule) | **POST** /api/v3/worker-mgt/workers/{worker_id}/sleep-schedule |
[**update_worker_cluster**](WorkerMgtApi.md#update_worker_cluster) | **PUT** /api/v3/worker-mgt/cluster/{cluster_id} | Update an existing worker cluster.
# **create_worker_cluster**
> WorkerCluster create_worker_cluster(worker_cluster)
Create a new worker cluster.
### Example
```python
import time
import flamenco.manager
from flamenco.manager.api import worker_mgt_api
from flamenco.manager.model.error import Error
from flamenco.manager.model.worker_cluster import WorkerCluster
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 = worker_mgt_api.WorkerMgtApi(api_client)
worker_cluster = WorkerCluster(
id="id_example",
name="name_example",
description="description_example",
) # WorkerCluster | The worker cluster.
# example passing only required values which don't have defaults set
try:
# Create a new worker cluster.
api_response = api_instance.create_worker_cluster(worker_cluster)
pprint(api_response)
except flamenco.manager.ApiException as e:
print("Exception when calling WorkerMgtApi->create_worker_cluster: %s\n" % e)
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**worker_cluster** | [**WorkerCluster**](WorkerCluster.md)| The worker cluster. |
### Return type
[**WorkerCluster**](WorkerCluster.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
### HTTP response details
| Status code | Description | Response headers |
|-------------|-------------|------------------|
**200** | The cluster was created. The created cluster is returned, so that the caller can know its UUID. | - |
**0** | Error message | - |
[[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)
# **delete_worker**
> delete_worker(worker_id)
@ -77,6 +154,71 @@ 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)
# **delete_worker_cluster**
> delete_worker_cluster(cluster_id)
Remove this worker cluster. This unassigns all workers from the cluster and removes it.
### Example
```python
import time
import flamenco.manager
from flamenco.manager.api import worker_mgt_api
from flamenco.manager.model.error import Error
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 = worker_mgt_api.WorkerMgtApi(api_client)
cluster_id = "cluster_id_example" # str |
# example passing only required values which don't have defaults set
try:
# Remove this worker cluster. This unassigns all workers from the cluster and removes it.
api_instance.delete_worker_cluster(cluster_id)
except flamenco.manager.ApiException as e:
print("Exception when calling WorkerMgtApi->delete_worker_cluster: %s\n" % e)
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**cluster_id** | **str**| |
### Return type
void (empty response body)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
### HTTP response details
| Status code | Description | Response headers |
|-------------|-------------|------------------|
**204** | The cluster has been removed. | - |
**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)
# **fetch_worker**
> Worker fetch_worker(worker_id)
@ -142,6 +284,132 @@ 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)
# **fetch_worker_cluster**
> WorkerCluster fetch_worker_cluster(cluster_id)
Get a single worker cluster.
### Example
```python
import time
import flamenco.manager
from flamenco.manager.api import worker_mgt_api
from flamenco.manager.model.worker_cluster import WorkerCluster
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 = worker_mgt_api.WorkerMgtApi(api_client)
cluster_id = "cluster_id_example" # str |
# example passing only required values which don't have defaults set
try:
# Get a single worker cluster.
api_response = api_instance.fetch_worker_cluster(cluster_id)
pprint(api_response)
except flamenco.manager.ApiException as e:
print("Exception when calling WorkerMgtApi->fetch_worker_cluster: %s\n" % e)
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**cluster_id** | **str**| |
### Return type
[**WorkerCluster**](WorkerCluster.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
### HTTP response details
| Status code | Description | Response headers |
|-------------|-------------|------------------|
**200** | The worker cluster. | - |
[[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)
# **fetch_worker_clusters**
> WorkerClusterList fetch_worker_clusters()
Get list of worker clusters.
### Example
```python
import time
import flamenco.manager
from flamenco.manager.api import worker_mgt_api
from flamenco.manager.model.worker_cluster_list import WorkerClusterList
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 = worker_mgt_api.WorkerMgtApi(api_client)
# example, this endpoint has no required or optional parameters
try:
# Get list of worker clusters.
api_response = api_instance.fetch_worker_clusters()
pprint(api_response)
except flamenco.manager.ApiException as e:
print("Exception when calling WorkerMgtApi->fetch_worker_clusters: %s\n" % e)
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**WorkerClusterList**](WorkerClusterList.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
### HTTP response details
| Status code | Description | Response headers |
|-------------|-------------|------------------|
**200** | Worker clusters. | - |
[[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)
# **fetch_worker_sleep_schedule**
> WorkerSleepSchedule fetch_worker_sleep_schedule(worker_id)
@ -331,6 +599,77 @@ No authorization required
- **Accept**: application/json
### HTTP response details
| Status code | Description | Response headers |
|-------------|-------------|------------------|
**204** | Status change was accepted. | - |
**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)
# **set_worker_clusters**
> set_worker_clusters(worker_id, worker_cluster_change_request)
### Example
```python
import time
import flamenco.manager
from flamenco.manager.api import worker_mgt_api
from flamenco.manager.model.error import Error
from flamenco.manager.model.worker_cluster_change_request import WorkerClusterChangeRequest
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 = worker_mgt_api.WorkerMgtApi(api_client)
worker_id = "worker_id_example" # str |
worker_cluster_change_request = WorkerClusterChangeRequest(
cluster_ids=[
"cluster_ids_example",
],
) # WorkerClusterChangeRequest | The list of cluster IDs this worker should be a member of.
# example passing only required values which don't have defaults set
try:
api_instance.set_worker_clusters(worker_id, worker_cluster_change_request)
except flamenco.manager.ApiException as e:
print("Exception when calling WorkerMgtApi->set_worker_clusters: %s\n" % e)
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**worker_id** | **str**| |
**worker_cluster_change_request** | [**WorkerClusterChangeRequest**](WorkerClusterChangeRequest.md)| The list of cluster IDs this worker should be a member of. |
### 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 |
@ -412,3 +751,75 @@ 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)
# **update_worker_cluster**
> update_worker_cluster(cluster_id, worker_cluster)
Update an existing worker cluster.
### Example
```python
import time
import flamenco.manager
from flamenco.manager.api import worker_mgt_api
from flamenco.manager.model.error import Error
from flamenco.manager.model.worker_cluster import WorkerCluster
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 = worker_mgt_api.WorkerMgtApi(api_client)
cluster_id = "cluster_id_example" # str |
worker_cluster = WorkerCluster(
id="id_example",
name="name_example",
description="description_example",
) # WorkerCluster | The updated worker cluster.
# example passing only required values which don't have defaults set
try:
# Update an existing worker cluster.
api_instance.update_worker_cluster(cluster_id, worker_cluster)
except flamenco.manager.ApiException as e:
print("Exception when calling WorkerMgtApi->update_worker_cluster: %s\n" % e)
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**cluster_id** | **str**| |
**worker_cluster** | [**WorkerCluster**](WorkerCluster.md)| The updated worker cluster. |
### 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** | The cluster update has been stored. | - |
**0** | Error message | - |
[[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)

View File

@ -110,6 +110,7 @@ class Job(ModelComposed):
'settings': (JobSettings,), # noqa: E501
'metadata': (JobMetadata,), # noqa: E501
'storage': (JobStorageInfo,), # noqa: E501
'worker_cluster': (str,), # noqa: E501
'delete_requested_at': (datetime,), # noqa: E501
}
@ -132,6 +133,7 @@ class Job(ModelComposed):
'settings': 'settings', # noqa: E501
'metadata': 'metadata', # noqa: E501
'storage': 'storage', # noqa: E501
'worker_cluster': 'worker_cluster', # noqa: E501
'delete_requested_at': 'delete_requested_at', # noqa: E501
}
@ -187,6 +189,7 @@ class Job(ModelComposed):
settings (JobSettings): [optional] # noqa: E501
metadata (JobMetadata): [optional] # noqa: E501
storage (JobStorageInfo): [optional] # noqa: E501
worker_cluster (str): Worker Cluster that should execute this job. When a cluster ID is given, only Workers in that cluster will be scheduled to work on it. If empty or ommitted, all workers can work on this job. . [optional] # noqa: E501
delete_requested_at (datetime): If job deletion was requested, this is the timestamp at which that request was stored on Flamenco Manager. . [optional] # noqa: E501
"""
@ -301,6 +304,7 @@ class Job(ModelComposed):
settings (JobSettings): [optional] # noqa: E501
metadata (JobMetadata): [optional] # noqa: E501
storage (JobStorageInfo): [optional] # noqa: E501
worker_cluster (str): Worker Cluster that should execute this job. When a cluster ID is given, only Workers in that cluster will be scheduled to work on it. If empty or ommitted, all workers can work on this job. . [optional] # noqa: E501
delete_requested_at (datetime): If job deletion was requested, this is the timestamp at which that request was stored on Flamenco Manager. . [optional] # noqa: E501
"""

View File

@ -99,6 +99,7 @@ class SubmittedJob(ModelNormal):
'settings': (JobSettings,), # noqa: E501
'metadata': (JobMetadata,), # noqa: E501
'storage': (JobStorageInfo,), # noqa: E501
'worker_cluster': (str,), # noqa: E501
}
@cached_property
@ -115,6 +116,7 @@ class SubmittedJob(ModelNormal):
'settings': 'settings', # noqa: E501
'metadata': 'metadata', # noqa: E501
'storage': 'storage', # noqa: E501
'worker_cluster': 'worker_cluster', # noqa: E501
}
read_only_vars = {
@ -168,6 +170,7 @@ class SubmittedJob(ModelNormal):
settings (JobSettings): [optional] # noqa: E501
metadata (JobMetadata): [optional] # noqa: E501
storage (JobStorageInfo): [optional] # noqa: E501
worker_cluster (str): Worker Cluster that should execute this job. When a cluster ID is given, only Workers in that cluster will be scheduled to work on it. If empty or ommitted, all workers can work on this job. . [optional] # noqa: E501
"""
priority = kwargs.get('priority', 50)
@ -264,6 +267,7 @@ class SubmittedJob(ModelNormal):
settings (JobSettings): [optional] # noqa: E501
metadata (JobMetadata): [optional] # noqa: E501
storage (JobStorageInfo): [optional] # noqa: E501
worker_cluster (str): Worker Cluster that should execute this job. When a cluster ID is given, only Workers in that cluster will be scheduled to work on it. If empty or ommitted, all workers can work on this job. . [optional] # noqa: E501
"""
priority = kwargs.get('priority', 50)

View File

@ -31,11 +31,13 @@ from flamenco.manager.exceptions import ApiAttributeError
def lazy_import():
from flamenco.manager.model.worker_all_of import WorkerAllOf
from flamenco.manager.model.worker_cluster import WorkerCluster
from flamenco.manager.model.worker_status import WorkerStatus
from flamenco.manager.model.worker_status_change_request import WorkerStatusChangeRequest
from flamenco.manager.model.worker_summary import WorkerSummary
from flamenco.manager.model.worker_task import WorkerTask
globals()['WorkerAllOf'] = WorkerAllOf
globals()['WorkerCluster'] = WorkerCluster
globals()['WorkerStatus'] = WorkerStatus
globals()['WorkerStatusChangeRequest'] = WorkerStatusChangeRequest
globals()['WorkerSummary'] = WorkerSummary
@ -105,6 +107,7 @@ class Worker(ModelComposed):
'status_change': (WorkerStatusChangeRequest,), # noqa: E501
'last_seen': (datetime,), # noqa: E501
'task': (WorkerTask,), # noqa: E501
'clusters': ([WorkerCluster],), # noqa: E501
}
@cached_property
@ -123,6 +126,7 @@ class Worker(ModelComposed):
'status_change': 'status_change', # noqa: E501
'last_seen': 'last_seen', # noqa: E501
'task': 'task', # noqa: E501
'clusters': 'clusters', # noqa: E501
}
read_only_vars = {
@ -174,6 +178,7 @@ class Worker(ModelComposed):
status_change (WorkerStatusChangeRequest): [optional] # noqa: E501
last_seen (datetime): Last time this worker was seen by the Manager.. [optional] # noqa: E501
task (WorkerTask): [optional] # noqa: E501
clusters ([WorkerCluster]): Clusters of which this Worker is a member.. [optional] # noqa: E501
"""
_check_type = kwargs.pop('_check_type', True)
@ -283,6 +288,7 @@ class Worker(ModelComposed):
status_change (WorkerStatusChangeRequest): [optional] # noqa: E501
last_seen (datetime): Last time this worker was seen by the Manager.. [optional] # noqa: E501
task (WorkerTask): [optional] # noqa: E501
clusters ([WorkerCluster]): Clusters of which this Worker is a member.. [optional] # noqa: E501
"""
_check_type = kwargs.pop('_check_type', True)

View File

@ -30,7 +30,9 @@ from flamenco.manager.exceptions import ApiAttributeError
def lazy_import():
from flamenco.manager.model.worker_cluster import WorkerCluster
from flamenco.manager.model.worker_task import WorkerTask
globals()['WorkerCluster'] = WorkerCluster
globals()['WorkerTask'] = WorkerTask
@ -91,6 +93,7 @@ class WorkerAllOf(ModelNormal):
'platform': (str,), # noqa: E501
'supported_task_types': ([str],), # noqa: E501
'task': (WorkerTask,), # noqa: E501
'clusters': ([WorkerCluster],), # noqa: E501
}
@cached_property
@ -103,6 +106,7 @@ class WorkerAllOf(ModelNormal):
'platform': 'platform', # noqa: E501
'supported_task_types': 'supported_task_types', # noqa: E501
'task': 'task', # noqa: E501
'clusters': 'clusters', # noqa: E501
}
read_only_vars = {
@ -152,6 +156,7 @@ class WorkerAllOf(ModelNormal):
through its discriminator because we passed in
_visited_composed_classes = (Animal,)
task (WorkerTask): [optional] # noqa: E501
clusters ([WorkerCluster]): Clusters of which this Worker is a member.. [optional] # noqa: E501
"""
_check_type = kwargs.pop('_check_type', True)
@ -242,6 +247,7 @@ class WorkerAllOf(ModelNormal):
through its discriminator because we passed in
_visited_composed_classes = (Animal,)
task (WorkerTask): [optional] # noqa: E501
clusters ([WorkerCluster]): Clusters of which this Worker is a member.. [optional] # noqa: E501
"""
_check_type = kwargs.pop('_check_type', True)

View File

@ -0,0 +1,269 @@
"""
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
class WorkerCluster(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
"""
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.
"""
return {
'name': (str,), # noqa: E501
'id': (str,), # noqa: E501
'description': (str,), # noqa: E501
}
@cached_property
def discriminator():
return None
attribute_map = {
'name': 'name', # noqa: E501
'id': 'id', # noqa: E501
'description': 'description', # noqa: E501
}
read_only_vars = {
}
_composed_schemas = {}
@classmethod
@convert_js_args_to_python_args
def _from_openapi_data(cls, name, *args, **kwargs): # noqa: E501
"""WorkerCluster - a model defined in OpenAPI
Args:
name (str):
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,)
id (str): UUID of the cluster. Can be ommitted when creating a new cluster, in which case a random UUID will be assigned. . [optional] # noqa: E501
description (str): [optional] # noqa: E501
"""
_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.name = name
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, name, *args, **kwargs): # noqa: E501
"""WorkerCluster - a model defined in OpenAPI
Args:
name (str):
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,)
id (str): UUID of the cluster. Can be ommitted when creating a new cluster, in which case a random UUID will be assigned. . [optional] # noqa: E501
description (str): [optional] # noqa: E501
"""
_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.name = name
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.")

View File

@ -0,0 +1,261 @@
"""
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
class WorkerClusterChangeRequest(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
"""
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.
"""
return {
'cluster_ids': ([str],), # noqa: E501
}
@cached_property
def discriminator():
return None
attribute_map = {
'cluster_ids': 'cluster_ids', # noqa: E501
}
read_only_vars = {
}
_composed_schemas = {}
@classmethod
@convert_js_args_to_python_args
def _from_openapi_data(cls, cluster_ids, *args, **kwargs): # noqa: E501
"""WorkerClusterChangeRequest - a model defined in OpenAPI
Args:
cluster_ids ([str]):
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.cluster_ids = cluster_ids
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, cluster_ids, *args, **kwargs): # noqa: E501
"""WorkerClusterChangeRequest - a model defined in OpenAPI
Args:
cluster_ids ([str]):
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.cluster_ids = cluster_ids
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.")

View File

@ -0,0 +1,261 @@
"""
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.worker_cluster import WorkerCluster
globals()['WorkerCluster'] = WorkerCluster
class WorkerClusterList(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 {
'clusters': ([WorkerCluster],), # noqa: E501
}
@cached_property
def discriminator():
return None
attribute_map = {
'clusters': 'clusters', # noqa: E501
}
read_only_vars = {
}
_composed_schemas = {}
@classmethod
@convert_js_args_to_python_args
def _from_openapi_data(cls, *args, **kwargs): # noqa: E501
"""WorkerClusterList - a model defined in OpenAPI
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,)
clusters ([WorkerCluster]): [optional] # noqa: E501
"""
_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__,)
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, *args, **kwargs): # noqa: E501
"""WorkerClusterList - a model defined in OpenAPI
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,)
clusters ([WorkerCluster]): [optional] # noqa: E501
"""
_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__,)
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.")

View File

@ -76,6 +76,9 @@ from flamenco.manager.model.task_update import TaskUpdate
from flamenco.manager.model.task_worker import TaskWorker
from flamenco.manager.model.worker import Worker
from flamenco.manager.model.worker_all_of import WorkerAllOf
from flamenco.manager.model.worker_cluster import WorkerCluster
from flamenco.manager.model.worker_cluster_change_request import WorkerClusterChangeRequest
from flamenco.manager.model.worker_cluster_list import WorkerClusterList
from flamenco.manager.model.worker_list import WorkerList
from flamenco.manager.model.worker_registration import WorkerRegistration
from flamenco.manager.model.worker_sign_on import WorkerSignOn

View File

@ -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: 3.2
- Package version: 3.3-alpha0
- Build package: org.openapitools.codegen.languages.PythonClientCodegen
For more information, please visit [https://flamenco.io/](https://flamenco.io/)
@ -117,12 +117,18 @@ Class | Method | HTTP request | Description
*WorkerApi* | [**task_update**](flamenco/manager/docs/WorkerApi.md#task_update) | **POST** /api/v3/worker/task/{task_id} | Update the task, typically to indicate progress, completion, or failure.
*WorkerApi* | [**worker_state**](flamenco/manager/docs/WorkerApi.md#worker_state) | **GET** /api/v3/worker/state |
*WorkerApi* | [**worker_state_changed**](flamenco/manager/docs/WorkerApi.md#worker_state_changed) | **POST** /api/v3/worker/state-changed | Worker changed state. This could be as acknowledgement of a Manager-requested state change, or in response to worker-local signals.
*WorkerMgtApi* | [**create_worker_cluster**](flamenco/manager/docs/WorkerMgtApi.md#create_worker_cluster) | **POST** /api/v3/worker-mgt/clusters | Create a new worker cluster.
*WorkerMgtApi* | [**delete_worker**](flamenco/manager/docs/WorkerMgtApi.md#delete_worker) | **DELETE** /api/v3/worker-mgt/workers/{worker_id} | Remove the given worker. It is recommended to only call this function when the worker is in `offline` state. If the worker is still running, stop it first. Any task still assigned to the worker will be requeued.
*WorkerMgtApi* | [**delete_worker_cluster**](flamenco/manager/docs/WorkerMgtApi.md#delete_worker_cluster) | **DELETE** /api/v3/worker-mgt/cluster/{cluster_id} | Remove this worker cluster. This unassigns all workers from the cluster and removes it.
*WorkerMgtApi* | [**fetch_worker**](flamenco/manager/docs/WorkerMgtApi.md#fetch_worker) | **GET** /api/v3/worker-mgt/workers/{worker_id} | Fetch info about the worker.
*WorkerMgtApi* | [**fetch_worker_cluster**](flamenco/manager/docs/WorkerMgtApi.md#fetch_worker_cluster) | **GET** /api/v3/worker-mgt/cluster/{cluster_id} | Get a single worker cluster.
*WorkerMgtApi* | [**fetch_worker_clusters**](flamenco/manager/docs/WorkerMgtApi.md#fetch_worker_clusters) | **GET** /api/v3/worker-mgt/clusters | Get list of worker clusters.
*WorkerMgtApi* | [**fetch_worker_sleep_schedule**](flamenco/manager/docs/WorkerMgtApi.md#fetch_worker_sleep_schedule) | **GET** /api/v3/worker-mgt/workers/{worker_id}/sleep-schedule |
*WorkerMgtApi* | [**fetch_workers**](flamenco/manager/docs/WorkerMgtApi.md#fetch_workers) | **GET** /api/v3/worker-mgt/workers | Get list of workers.
*WorkerMgtApi* | [**request_worker_status_change**](flamenco/manager/docs/WorkerMgtApi.md#request_worker_status_change) | **POST** /api/v3/worker-mgt/workers/{worker_id}/setstatus |
*WorkerMgtApi* | [**set_worker_clusters**](flamenco/manager/docs/WorkerMgtApi.md#set_worker_clusters) | **POST** /api/v3/worker-mgt/workers/{worker_id}/setclusters |
*WorkerMgtApi* | [**set_worker_sleep_schedule**](flamenco/manager/docs/WorkerMgtApi.md#set_worker_sleep_schedule) | **POST** /api/v3/worker-mgt/workers/{worker_id}/sleep-schedule |
*WorkerMgtApi* | [**update_worker_cluster**](flamenco/manager/docs/WorkerMgtApi.md#update_worker_cluster) | **PUT** /api/v3/worker-mgt/cluster/{cluster_id} | Update an existing worker cluster.
## Documentation For Models
@ -194,6 +200,9 @@ Class | Method | HTTP request | Description
- [TaskWorker](flamenco/manager/docs/TaskWorker.md)
- [Worker](flamenco/manager/docs/Worker.md)
- [WorkerAllOf](flamenco/manager/docs/WorkerAllOf.md)
- [WorkerCluster](flamenco/manager/docs/WorkerCluster.md)
- [WorkerClusterChangeRequest](flamenco/manager/docs/WorkerClusterChangeRequest.md)
- [WorkerClusterList](flamenco/manager/docs/WorkerClusterList.md)
- [WorkerList](flamenco/manager/docs/WorkerList.md)
- [WorkerRegistration](flamenco/manager/docs/WorkerRegistration.md)
- [WorkerSignOn](flamenco/manager/docs/WorkerSignOn.md)

View File

@ -10,7 +10,7 @@ from urllib3.exceptions import HTTPError, MaxRetryError
import bpy
from . import job_types, job_submission, preferences
from . import job_types, job_submission, preferences, worker_clusters
from .job_types_propgroup import JobTypePropertyGroup
from .bat.submodules import bpathlib
@ -83,6 +83,37 @@ class FLAMENCO_OT_fetch_job_types(FlamencoOpMixin, bpy.types.Operator):
return {"FINISHED"}
class FLAMENCO_OT_fetch_worker_clusters(FlamencoOpMixin, bpy.types.Operator):
bl_idname = "flamenco.fetch_worker_clusters"
bl_label = "Fetch Worker Clusters"
bl_description = "Query Flamenco Manager to obtain the available worker clusters"
def execute(self, context: bpy.types.Context) -> set[str]:
api_client = self.get_api_client(context)
from flamenco.manager import ApiException
scene = context.scene
old_cluster = getattr(scene, "flamenco_worker_cluster", "")
try:
worker_clusters.refresh(context, api_client)
except ApiException as ex:
self.report({"ERROR"}, "Error getting job types: %s" % ex)
return {"CANCELLED"}
except MaxRetryError as ex:
# This is the common error, when for example the port number is
# incorrect and nothing is listening.
self.report({"ERROR"}, "Unable to reach Manager")
return {"CANCELLED"}
if old_cluster:
# TODO: handle cases where the old cluster no longer exists.
scene.flamenco_worker_cluster = old_cluster
return {"FINISHED"}
class FLAMENCO_OT_ping_manager(FlamencoOpMixin, bpy.types.Operator):
bl_idname = "flamenco.ping_manager"
bl_label = "Flamenco: Ping Manager"
@ -165,7 +196,9 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
if not context.blend_data.filepath:
# The file path needs to be known before the file can be submitted.
self.report({"ERROR"}, "Please save your .blend file before submitting to Flamenco")
self.report(
{"ERROR"}, "Please save your .blend file before submitting to Flamenco"
)
return {"CANCELLED"}
filepath = self._save_blendfile(context)
@ -633,6 +666,7 @@ class FLAMENCO3_OT_explore_file_path(bpy.types.Operator):
classes = (
FLAMENCO_OT_fetch_job_types,
FLAMENCO_OT_fetch_worker_clusters,
FLAMENCO_OT_ping_manager,
FLAMENCO_OT_eval_setting,
FLAMENCO_OT_submit_job,

View File

@ -34,6 +34,12 @@ def _manager_url_updated(prefs, context):
comms.ping_manager_with_report(context.window_manager, api_client, prefs)
class WorkerCluster(bpy.types.PropertyGroup):
id: bpy.props.StringProperty(name="id")
name: bpy.props.StringProperty(name="Name")
description: bpy.props.StringProperty(name="Description")
class FlamencoPreferences(bpy.types.AddonPreferences):
bl_idname = "flamenco"
@ -71,6 +77,13 @@ class FlamencoPreferences(bpy.types.AddonPreferences):
get=lambda prefs: prefs.job_storage,
)
worker_clusters: bpy.props.CollectionProperty( # type: ignore
type=WorkerCluster,
name="Worker Clusters",
description="Cache for the worker clusters available on the configured Manager",
options={"HIDDEN"},
)
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
layout.use_property_decorate = False
@ -117,7 +130,10 @@ def manager_url(context: bpy.types.Context) -> str:
return str(prefs.manager_url)
classes = (FlamencoPreferences,)
classes = (
WorkerCluster,
FlamencoPreferences,
)
_register, _unregister = bpy.utils.register_classes_factory(classes)

View File

@ -0,0 +1,74 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import TYPE_CHECKING, Union
import bpy
from . import preferences
if TYPE_CHECKING:
from flamenco.manager import ApiClient as _ApiClient
else:
_ApiClient = object
_enum_items: list[Union[tuple[str, str, str], tuple[str, str, str, int, int]]] = []
def refresh(context: bpy.types.Context, api_client: _ApiClient) -> None:
"""Fetch the available worker clusters from the Manager."""
from flamenco.manager import ApiClient
from flamenco.manager.api import worker_mgt_api
from flamenco.manager.model.worker_cluster_list import WorkerClusterList
assert isinstance(api_client, ApiClient)
api = worker_mgt_api.WorkerMgtApi(api_client)
response: WorkerClusterList = api.fetch_worker_clusters()
# Store on the preferences, so a cached version persists until the next refresh.
prefs = preferences.get(context)
prefs.worker_clusters.clear()
for cluster in response.clusters:
rna_cluster = prefs.worker_clusters.add()
rna_cluster.id = cluster.id
rna_cluster.name = cluster.name
rna_cluster.description = getattr(cluster, "description", "")
def _get_enum_items(self, context):
global _enum_items
prefs = preferences.get(context)
_enum_items = [
("-", "All", "No specific cluster assigned, any worker can handle this job"),
]
_enum_items.extend(
(cluster.id, cluster.name, cluster.description)
for cluster in prefs.worker_clusters
)
return _enum_items
def register() -> None:
bpy.types.Scene.flamenco_worker_cluster = bpy.props.EnumProperty(
name="Worker Cluster",
items=_get_enum_items,
description="The set of Workers that can handle tasks of this job",
)
def unregister() -> None:
to_del = ((bpy.types.Scene, "flamenco_worker_cluster"),)
for ob, attr in to_del:
try:
delattr(ob, attr)
except AttributeError:
pass
if __name__ == "__main__":
import doctest
print(doctest.testmod())

5
go.mod
View File

@ -51,11 +51,12 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
golang.org/x/mod v0.7.0 // indirect
golang.org/x/mod v0.8.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba // indirect
golang.org/x/tools v0.5.1-0.20230117180257-8aba49bb5ea2 // indirect
golang.org/x/tools v0.6.1-0.20230217175706-3102dad5faf9 // indirect
golang.org/x/vuln v0.0.0-20230320232729-bfc1eaef17a4 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
modernc.org/libc v1.16.17 // indirect
modernc.org/mathutil v1.4.1 // indirect

6
go.sum
View File

@ -185,6 +185,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
@ -254,6 +256,10 @@ golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.5.1-0.20230117180257-8aba49bb5ea2 h1:v0FhRDmSCNH/0EurAT6T8KRY4aNuUhz6/WwBMxG+gvQ=
golang.org/x/tools v0.5.1-0.20230117180257-8aba49bb5ea2/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
golang.org/x/tools v0.6.1-0.20230217175706-3102dad5faf9 h1:IuFp2CklNBim6OdHXn/1P4VoeKt5pA2jcDKWlboqtlQ=
golang.org/x/tools v0.6.1-0.20230217175706-3102dad5faf9/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/vuln v0.0.0-20230320232729-bfc1eaef17a4 h1:E/sS+2T8wsKgQNbdkQFdIFrytP7CK17WA5z0wbVoFgU=
golang.org/x/vuln v0.0.0-20230320232729-bfc1eaef17a4/go.mod h1:ydpjOTRSBwOBFJRP/w5NF2HSPnFg1JxobEZQGOirxgo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -10,10 +10,18 @@ import (
"github.com/adrg/xdg"
)
// customHome can be set at link time to specify the home directory for the worker.
// This can be overruled at runtime by setting the FLAMENCO_HOME enviroment variable.
// Only used in InFlamencoHome() function.
var customHome = ""
// InFlamencoHome returns the filename in the 'flamenco home' dir, and ensures
// that the directory exists.
func InFlamencoHome(filename string) (string, error) {
flamencoHome := os.Getenv("FLAMENCO_HOME")
flamencoHome := customHome
if envHome, ok := os.LookupEnv("FLAMENCO_HOME"); ok {
flamencoHome = envHome
}
if flamencoHome == "" {
return xdg.DataFile(path.Join(xdgApplicationName, filename))
}

View File

@ -65,6 +65,14 @@ type PersistenceService interface {
RemoveFromJobBlocklist(ctx context.Context, jobUUID, workerUUID, taskType string) error
ClearJobBlocklist(ctx context.Context, job *persistence.Job) error
// Worker cluster management.
WorkerSetClusters(ctx context.Context, worker *persistence.Worker, clusterUUIDs []string) error
CreateWorkerCluster(ctx context.Context, cluster *persistence.WorkerCluster) error
FetchWorkerCluster(ctx context.Context, uuid string) (*persistence.WorkerCluster, error)
FetchWorkerClusters(ctx context.Context) ([]*persistence.WorkerCluster, error)
DeleteWorkerCluster(ctx context.Context, uuid string) error
SaveWorkerCluster(ctx context.Context, cluster *persistence.WorkerCluster) error
// WorkersLeftToRun returns a set of worker UUIDs that can run tasks of the given type on the given job.
WorkersLeftToRun(ctx context.Context, job *persistence.Job, taskType string) (map[string]bool, error)
// CountTaskFailuresOfWorker returns the number of task failures of this worker, on this particular job and task type.

View File

@ -618,6 +618,9 @@ func jobDBtoAPI(dbJob *persistence.Job) api.Job {
if dbJob.DeleteRequestedAt.Valid {
apiJob.DeleteRequestedAt = &dbJob.DeleteRequestedAt.Time
}
if dbJob.WorkerCluster != nil {
apiJob.WorkerCluster = &dbJob.WorkerCluster.UUID
}
return apiJob
}

View File

@ -17,6 +17,7 @@ import (
"git.blender.org/flamenco/pkg/moremock"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func ptr[T any](value T) *T {
@ -319,6 +320,103 @@ func TestSubmitJobWithShamanCheckoutID(t *testing.T) {
assert.NoError(t, err)
}
func TestSubmitJobWithWorkerCluster(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
mf := newMockedFlamenco(mockCtrl)
worker := testWorker()
workerClusterUUID := "04435762-9dc8-4f13-80b7-643a6fa5b6fd"
cluster := persistence.WorkerCluster{
Model: persistence.Model{ID: 47},
UUID: workerClusterUUID,
Name: "first cluster",
Description: "my first cluster",
}
submittedJob := api.SubmittedJob{
Name: "поднео посао",
Type: "test",
Priority: 50,
SubmitterPlatform: worker.Platform,
WorkerCluster: &workerClusterUUID,
}
mf.expectConvertTwoWayVariables(t,
config.VariableAudienceWorkers,
config.VariablePlatform(worker.Platform),
map[string]string{},
)
// Expect the job compiler to be called.
authoredJob := job_compilers.AuthoredJob{
JobID: "afc47568-bd9d-4368-8016-e91d945db36d",
WorkerClusterUUID: workerClusterUUID,
Name: submittedJob.Name,
JobType: submittedJob.Type,
Priority: submittedJob.Priority,
Status: api.JobStatusUnderConstruction,
Created: mf.clock.Now(),
}
mf.jobCompiler.EXPECT().Compile(gomock.Any(), submittedJob).Return(&authoredJob, nil)
// Expect the job to be saved with 'queued' status:
queuedJob := authoredJob
queuedJob.Status = api.JobStatusQueued
mf.persistence.EXPECT().StoreAuthoredJob(gomock.Any(), queuedJob).Return(nil)
// Expect the job to be fetched from the database again:
dbJob := persistence.Job{
Model: persistence.Model{
ID: 47,
CreatedAt: mf.clock.Now(),
UpdatedAt: mf.clock.Now(),
},
UUID: queuedJob.JobID,
Name: queuedJob.Name,
JobType: queuedJob.JobType,
Priority: queuedJob.Priority,
Status: queuedJob.Status,
Settings: persistence.StringInterfaceMap{},
Metadata: persistence.StringStringMap{},
WorkerClusterID: &cluster.ID,
WorkerCluster: &cluster,
}
mf.persistence.EXPECT().FetchJob(gomock.Any(), queuedJob.JobID).Return(&dbJob, nil)
// Expect the new job to be broadcast.
jobUpdate := api.SocketIOJobUpdate{
Id: dbJob.UUID,
Name: &dbJob.Name,
Priority: dbJob.Priority,
Status: dbJob.Status,
Type: dbJob.JobType,
Updated: dbJob.UpdatedAt,
}
mf.broadcaster.EXPECT().BroadcastNewJob(jobUpdate)
// Do the call.
echoCtx := mf.prepareMockedJSONRequest(submittedJob)
requestWorkerStore(echoCtx, &worker)
require.NoError(t, mf.flamenco.SubmitJob(echoCtx))
submittedJob.Metadata = new(api.JobMetadata)
submittedJob.Settings = new(api.JobSettings)
submittedJob.SubmitterPlatform = "" // Not persisted in the database.
assertResponseJSON(t, echoCtx, http.StatusOK, api.Job{
SubmittedJob: submittedJob,
Id: dbJob.UUID,
Created: dbJob.CreatedAt,
Updated: dbJob.UpdatedAt,
DeleteRequestedAt: nil,
Activity: "",
Status: api.JobStatusQueued,
})
}
func TestGetJobTypeHappy(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()

View File

@ -141,6 +141,20 @@ func (mr *MockPersistenceServiceMockRecorder) CreateWorker(arg0, arg1 interface{
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateWorker", reflect.TypeOf((*MockPersistenceService)(nil).CreateWorker), arg0, arg1)
}
// CreateWorkerCluster mocks base method.
func (m *MockPersistenceService) CreateWorkerCluster(arg0 context.Context, arg1 *persistence.WorkerCluster) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateWorkerCluster", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// CreateWorkerCluster indicates an expected call of CreateWorkerCluster.
func (mr *MockPersistenceServiceMockRecorder) CreateWorkerCluster(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateWorkerCluster", reflect.TypeOf((*MockPersistenceService)(nil).CreateWorkerCluster), arg0, arg1)
}
// DeleteWorker mocks base method.
func (m *MockPersistenceService) DeleteWorker(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
@ -155,6 +169,20 @@ func (mr *MockPersistenceServiceMockRecorder) DeleteWorker(arg0, arg1 interface{
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorker", reflect.TypeOf((*MockPersistenceService)(nil).DeleteWorker), arg0, arg1)
}
// DeleteWorkerCluster mocks base method.
func (m *MockPersistenceService) DeleteWorkerCluster(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteWorkerCluster", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteWorkerCluster indicates an expected call of DeleteWorkerCluster.
func (mr *MockPersistenceServiceMockRecorder) DeleteWorkerCluster(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkerCluster", reflect.TypeOf((*MockPersistenceService)(nil).DeleteWorkerCluster), arg0, arg1)
}
// FetchJob mocks base method.
func (m *MockPersistenceService) FetchJob(arg0 context.Context, arg1 string) (*persistence.Job, error) {
m.ctrl.T.Helper()
@ -230,6 +258,36 @@ func (mr *MockPersistenceServiceMockRecorder) FetchWorker(arg0, arg1 interface{}
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchWorker", reflect.TypeOf((*MockPersistenceService)(nil).FetchWorker), arg0, arg1)
}
// FetchWorkerCluster mocks base method.
func (m *MockPersistenceService) FetchWorkerCluster(arg0 context.Context, arg1 string) (*persistence.WorkerCluster, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FetchWorkerCluster", arg0, arg1)
ret0, _ := ret[0].(*persistence.WorkerCluster)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FetchWorkerCluster indicates an expected call of FetchWorkerCluster.
func (mr *MockPersistenceServiceMockRecorder) FetchWorkerCluster(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchWorkerCluster", reflect.TypeOf((*MockPersistenceService)(nil).FetchWorkerCluster), arg0, arg1)
}
// FetchWorkerClusters mocks base method.
func (m *MockPersistenceService) FetchWorkerClusters(arg0 context.Context) ([]*persistence.WorkerCluster, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FetchWorkerClusters", arg0)
ret0, _ := ret[0].([]*persistence.WorkerCluster)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FetchWorkerClusters indicates an expected call of FetchWorkerClusters.
func (mr *MockPersistenceServiceMockRecorder) FetchWorkerClusters(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchWorkerClusters", reflect.TypeOf((*MockPersistenceService)(nil).FetchWorkerClusters), arg0)
}
// FetchWorkerTask mocks base method.
func (m *MockPersistenceService) FetchWorkerTask(arg0 context.Context, arg1 *persistence.Worker) (*persistence.Task, error) {
m.ctrl.T.Helper()
@ -375,6 +433,20 @@ func (mr *MockPersistenceServiceMockRecorder) SaveWorker(arg0, arg1 interface{})
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveWorker", reflect.TypeOf((*MockPersistenceService)(nil).SaveWorker), arg0, arg1)
}
// SaveWorkerCluster mocks base method.
func (m *MockPersistenceService) SaveWorkerCluster(arg0 context.Context, arg1 *persistence.WorkerCluster) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SaveWorkerCluster", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// SaveWorkerCluster indicates an expected call of SaveWorkerCluster.
func (mr *MockPersistenceServiceMockRecorder) SaveWorkerCluster(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveWorkerCluster", reflect.TypeOf((*MockPersistenceService)(nil).SaveWorkerCluster), arg0, arg1)
}
// SaveWorkerStatus mocks base method.
func (m *MockPersistenceService) SaveWorkerStatus(arg0 context.Context, arg1 *persistence.Worker) error {
m.ctrl.T.Helper()
@ -460,6 +532,20 @@ func (mr *MockPersistenceServiceMockRecorder) WorkerSeen(arg0, arg1 interface{})
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WorkerSeen", reflect.TypeOf((*MockPersistenceService)(nil).WorkerSeen), arg0, arg1)
}
// WorkerSetClusters mocks base method.
func (m *MockPersistenceService) WorkerSetClusters(arg0 context.Context, arg1 *persistence.Worker, arg2 []string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WorkerSetClusters", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// WorkerSetClusters indicates an expected call of WorkerSetClusters.
func (mr *MockPersistenceServiceMockRecorder) WorkerSetClusters(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WorkerSetClusters", reflect.TypeOf((*MockPersistenceService)(nil).WorkerSetClusters), arg0, arg1, arg2)
}
// WorkersLeftToRun mocks base method.
func (m *MockPersistenceService) WorkersLeftToRun(arg0 context.Context, arg1 *persistence.Job, arg2 string) (map[string]bool, error) {
m.ctrl.T.Helper()

View File

@ -17,7 +17,7 @@ func (f *Flamenco) FetchWorkers(e echo.Context) error {
logger := requestLogger(e)
dbWorkers, err := f.persist.FetchWorkers(e.Request().Context())
if err != nil {
logger.Error().Err(err).Msg("error fetching all workers")
logger.Error().Err(err).Msg("fetching all workers")
return sendAPIError(e, http.StatusInternalServerError, "error fetching workers: %v", err)
}
@ -47,13 +47,13 @@ func (f *Flamenco) FetchWorker(e echo.Context, workerUUID string) error {
return sendAPIError(e, http.StatusNotFound, "worker %q not found", workerUUID)
}
if err != nil {
logger.Error().Err(err).Msg("error fetching worker")
logger.Error().Err(err).Msg("fetching worker")
return sendAPIError(e, http.StatusInternalServerError, "error fetching worker: %v", err)
}
dbTask, err := f.persist.FetchWorkerTask(ctx, dbWorker)
if err != nil {
logger.Error().Err(err).Msg("error fetching task assigned to worker")
logger.Error().Err(err).Msg("fetching task assigned to worker")
return sendAPIError(e, http.StatusInternalServerError, "error fetching task assigned to worker: %v", err)
}
@ -91,14 +91,14 @@ func (f *Flamenco) DeleteWorker(e echo.Context, workerUUID string) error {
return sendAPIError(e, http.StatusNotFound, "worker %q not found", workerUUID)
}
if err != nil {
logger.Error().Err(err).Msg("error fetching worker for deletion")
logger.Error().Err(err).Msg("fetching worker for deletion")
return sendAPIError(e, http.StatusInternalServerError,
"error fetching worker for deletion: %v", err)
}
err = f.stateMachine.RequeueActiveTasksOfWorker(ctx, worker, "worker is being deleted")
if err != nil {
logger.Error().Err(err).Msg("error requeueing tasks before deleting worker")
logger.Error().Err(err).Msg("requeueing tasks before deleting worker")
return sendAPIError(e, http.StatusInternalServerError,
"error requeueing tasks before deleting worker: %v", err)
}
@ -110,7 +110,7 @@ func (f *Flamenco) DeleteWorker(e echo.Context, workerUUID string) error {
return sendAPIError(e, http.StatusNotFound, "worker %q not found", workerUUID)
}
if err != nil {
logger.Error().Err(err).Msg("error deleting worker")
logger.Error().Err(err).Msg("deleting worker")
return sendAPIError(e, http.StatusInternalServerError, "error deleting worker: %v", err)
}
logger.Info().Msg("deleted worker")
@ -150,7 +150,7 @@ func (f *Flamenco) RequestWorkerStatusChange(e echo.Context, workerUUID string)
return sendAPIError(e, http.StatusNotFound, "worker %q not found", workerUUID)
}
if err != nil {
logger.Error().Err(err).Msg("error fetching worker")
logger.Error().Err(err).Msg("fetching worker")
return sendAPIError(e, http.StatusInternalServerError, "error fetching worker: %v", err)
}
@ -171,7 +171,7 @@ func (f *Flamenco) RequestWorkerStatusChange(e echo.Context, workerUUID string)
// Store the status change.
if err := f.persist.SaveWorker(e.Request().Context(), dbWorker); err != nil {
logger.Error().Err(err).Msg("error saving worker after status change request")
logger.Error().Err(err).Msg("saving worker after status change request")
return sendAPIError(e, http.StatusInternalServerError, "error saving worker: %v", err)
}
@ -182,6 +182,202 @@ func (f *Flamenco) RequestWorkerStatusChange(e echo.Context, workerUUID string)
return e.NoContent(http.StatusNoContent)
}
func (f *Flamenco) SetWorkerClusters(e echo.Context, workerUUID string) error {
ctx := e.Request().Context()
logger := requestLogger(e)
logger = logger.With().Str("worker", workerUUID).Logger()
if !uuid.IsValid(workerUUID) {
return sendAPIError(e, http.StatusBadRequest, "not a valid UUID")
}
// Decode the request body.
var change api.WorkerClusterChangeRequest
if err := e.Bind(&change); err != nil {
logger.Warn().Err(err).Msg("bad request received")
return sendAPIError(e, http.StatusBadRequest, "invalid format")
}
// Fetch the worker.
dbWorker, err := f.persist.FetchWorker(ctx, workerUUID)
if errors.Is(err, persistence.ErrWorkerNotFound) {
logger.Debug().Msg("non-existent worker requested")
return sendAPIError(e, http.StatusNotFound, "worker %q not found", workerUUID)
}
if err != nil {
logger.Error().Err(err).Msg("fetching worker")
return sendAPIError(e, http.StatusInternalServerError, "error fetching worker: %v", err)
}
logger = logger.With().
Strs("clusters", change.ClusterIds).
Logger()
logger.Info().Msg("worker cluster change requested")
// Store the new cluster assignment.
if err := f.persist.WorkerSetClusters(ctx, dbWorker, change.ClusterIds); err != nil {
logger.Error().Err(err).Msg("saving worker after cluster change request")
return sendAPIError(e, http.StatusInternalServerError, "error saving worker: %v", err)
}
// Broadcast the change.
update := webupdates.NewWorkerUpdate(dbWorker)
f.broadcaster.BroadcastWorkerUpdate(update)
return e.NoContent(http.StatusNoContent)
}
func (f *Flamenco) DeleteWorkerCluster(e echo.Context, clusterUUID string) error {
ctx := e.Request().Context()
logger := requestLogger(e)
logger = logger.With().Str("cluster", clusterUUID).Logger()
if !uuid.IsValid(clusterUUID) {
return sendAPIError(e, http.StatusBadRequest, "not a valid UUID")
}
err := f.persist.DeleteWorkerCluster(ctx, clusterUUID)
switch {
case errors.Is(err, persistence.ErrWorkerClusterNotFound):
logger.Debug().Msg("non-existent worker cluster requested")
return sendAPIError(e, http.StatusNotFound, "worker cluster %q not found", clusterUUID)
case err != nil:
logger.Error().Err(err).Msg("deleting worker cluster")
return sendAPIError(e, http.StatusInternalServerError, "error deleting worker cluster: %v", err)
}
// TODO: SocketIO broadcast of cluster deletion.
logger.Info().Msg("worker cluster deleted")
return e.NoContent(http.StatusNoContent)
}
func (f *Flamenco) FetchWorkerCluster(e echo.Context, clusterUUID string) error {
ctx := e.Request().Context()
logger := requestLogger(e)
logger = logger.With().Str("cluster", clusterUUID).Logger()
if !uuid.IsValid(clusterUUID) {
return sendAPIError(e, http.StatusBadRequest, "not a valid UUID")
}
cluster, err := f.persist.FetchWorkerCluster(ctx, clusterUUID)
switch {
case errors.Is(err, persistence.ErrWorkerClusterNotFound):
logger.Debug().Msg("non-existent worker cluster requested")
return sendAPIError(e, http.StatusNotFound, "worker cluster %q not found", clusterUUID)
case err != nil:
logger.Error().Err(err).Msg("fetching worker cluster")
return sendAPIError(e, http.StatusInternalServerError, "error fetching worker cluster: %v", err)
}
return e.JSON(http.StatusOK, workerClusterDBtoAPI(*cluster))
}
func (f *Flamenco) UpdateWorkerCluster(e echo.Context, clusterUUID string) error {
ctx := e.Request().Context()
logger := requestLogger(e)
logger = logger.With().Str("cluster", clusterUUID).Logger()
if !uuid.IsValid(clusterUUID) {
return sendAPIError(e, http.StatusBadRequest, "not a valid UUID")
}
// Decode the request body.
var update api.UpdateWorkerClusterJSONBody
if err := e.Bind(&update); err != nil {
logger.Warn().Err(err).Msg("bad request received")
return sendAPIError(e, http.StatusBadRequest, "invalid format")
}
dbCluster, err := f.persist.FetchWorkerCluster(ctx, clusterUUID)
switch {
case errors.Is(err, persistence.ErrWorkerClusterNotFound):
logger.Debug().Msg("non-existent worker cluster requested")
return sendAPIError(e, http.StatusNotFound, "worker cluster %q not found", clusterUUID)
case err != nil:
logger.Error().Err(err).Msg("fetching worker cluster")
return sendAPIError(e, http.StatusInternalServerError, "error fetching worker cluster: %v", err)
}
// Update the cluster.
dbCluster.Name = update.Name
if update.Description == nil {
dbCluster.Description = ""
} else {
dbCluster.Description = *update.Description
}
if err := f.persist.SaveWorkerCluster(ctx, dbCluster); err != nil {
logger.Error().Err(err).Msg("saving worker cluster")
return sendAPIError(e, http.StatusInternalServerError, "error saving worker cluster")
}
// TODO: SocketIO broadcast of cluster update.
return e.NoContent(http.StatusNoContent)
}
func (f *Flamenco) FetchWorkerClusters(e echo.Context) error {
ctx := e.Request().Context()
logger := requestLogger(e)
dbClusters, err := f.persist.FetchWorkerClusters(ctx)
if err != nil {
logger.Error().Err(err).Msg("fetching worker clusters")
return sendAPIError(e, http.StatusInternalServerError, "error saving worker cluster")
}
apiClusters := []api.WorkerCluster{}
for _, dbCluster := range dbClusters {
apiCluster := workerClusterDBtoAPI(*dbCluster)
apiClusters = append(apiClusters, apiCluster)
}
clusterList := api.WorkerClusterList{
Clusters: &apiClusters,
}
return e.JSON(http.StatusOK, &clusterList)
}
func (f *Flamenco) CreateWorkerCluster(e echo.Context) error {
ctx := e.Request().Context()
logger := requestLogger(e)
// Decode the request body.
var apiCluster api.CreateWorkerClusterJSONBody
if err := e.Bind(&apiCluster); err != nil {
logger.Warn().Err(err).Msg("bad request received")
return sendAPIError(e, http.StatusBadRequest, "invalid format")
}
// Convert to persistence layer model.
var clusterUUID string
if apiCluster.Id != nil && *apiCluster.Id != "" {
clusterUUID = *apiCluster.Id
} else {
clusterUUID = uuid.New()
}
dbCluster := persistence.WorkerCluster{
UUID: clusterUUID,
Name: apiCluster.Name,
}
if apiCluster.Description != nil {
dbCluster.Description = *apiCluster.Description
}
// Store in the database.
if err := f.persist.CreateWorkerCluster(ctx, &dbCluster); err != nil {
logger.Error().Err(err).Msg("creating worker cluster")
return sendAPIError(e, http.StatusInternalServerError, "error creating worker cluster")
}
// TODO: SocketIO broadcast of cluster creation.
return e.JSON(http.StatusOK, workerClusterDBtoAPI(dbCluster))
}
func workerSummary(w persistence.Worker) api.WorkerSummary {
summary := api.WorkerSummary{
Id: w.UUID,
@ -211,5 +407,26 @@ func workerDBtoAPI(w persistence.Worker) api.Worker {
SupportedTaskTypes: w.TaskTypes(),
}
if len(w.Clusters) > 0 {
clusters := []api.WorkerCluster{}
for i := range w.Clusters {
clusters = append(clusters, workerClusterDBtoAPI(*w.Clusters[i]))
}
apiWorker.Clusters = &clusters
}
return apiWorker
}
func workerClusterDBtoAPI(wc persistence.WorkerCluster) api.WorkerCluster {
uuid := wc.UUID // Take a copy for safety.
apiCluster := api.WorkerCluster{
Id: &uuid,
Name: wc.Name,
}
if len(wc.Description) > 0 {
apiCluster.Description = &wc.Description
}
return apiCluster
}

View File

@ -10,6 +10,7 @@ import (
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.blender.org/flamenco/internal/manager/persistence"
"git.blender.org/flamenco/pkg/api"
@ -260,3 +261,59 @@ func TestRequestWorkerStatusChangeRevert(t *testing.T) {
assert.NoError(t, err)
assertResponseNoContent(t, echo)
}
func TestWorkerClusterCRUDHappyFlow(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
mf := newMockedFlamenco(mockCtrl)
// Create a cluster.
UUID := "18d9234e-5135-458f-a1ba-a350c3d4e837"
apiCluster := api.WorkerCluster{
Id: &UUID,
Name: "ʻO nā manu ʻino",
Description: ptr("Ke aloha"),
}
expectDBCluster := persistence.WorkerCluster{
UUID: UUID,
Name: apiCluster.Name,
Description: *apiCluster.Description,
}
mf.persistence.EXPECT().CreateWorkerCluster(gomock.Any(), &expectDBCluster)
// TODO: expect SocketIO broadcast of the cluster creation.
echo := mf.prepareMockedJSONRequest(apiCluster)
require.NoError(t, mf.flamenco.CreateWorkerCluster(echo))
assertResponseJSON(t, echo, http.StatusOK, &apiCluster)
// Fetch the cluster
mf.persistence.EXPECT().FetchWorkerCluster(gomock.Any(), UUID).Return(&expectDBCluster, nil)
echo = mf.prepareMockedRequest(nil)
require.NoError(t, mf.flamenco.FetchWorkerCluster(echo, UUID))
assertResponseJSON(t, echo, http.StatusOK, &apiCluster)
// Update & save.
newUUID := "60442762-83d3-4fc3-bf75-6ab5799cdbaa"
newAPICluster := api.WorkerCluster{
Id: &newUUID, // Intentionally change the UUID. This should just be ignored.
Name: "updated name",
}
expectNewDBCluster := persistence.WorkerCluster{
UUID: UUID,
Name: newAPICluster.Name,
Description: "",
}
// TODO: expect SocketIO broadcast of the cluster update.
mf.persistence.EXPECT().FetchWorkerCluster(gomock.Any(), UUID).Return(&expectDBCluster, nil)
mf.persistence.EXPECT().SaveWorkerCluster(gomock.Any(), &expectNewDBCluster)
echo = mf.prepareMockedJSONRequest(newAPICluster)
require.NoError(t, mf.flamenco.UpdateWorkerCluster(echo, UUID))
assertResponseNoContent(t, echo)
// Delete.
mf.persistence.EXPECT().DeleteWorkerCluster(gomock.Any(), UUID)
// TODO: expect SocketIO broadcast of the cluster deletion.
echo = mf.prepareMockedJSONRequest(newAPICluster)
require.NoError(t, mf.flamenco.DeleteWorkerCluster(echo, UUID))
assertResponseNoContent(t, echo)
}

View File

@ -33,8 +33,12 @@ func (f *Flamenco) RegisterWorker(e echo.Context) error {
}
// TODO: validate the request, should at least have non-empty name, secret, and platform.
logger.Info().Str("name", req.Name).Msg("registering new worker")
workerUUID := uuid.New()
logger = logger.With().
Str("name", req.Name).
Str("uuid", workerUUID).
Logger()
logger.Info().Msg("registering new worker")
hashedPassword, err := passwordHasher.GenerateHashedPassword([]byte(req.Secret))
if err != nil {
@ -43,7 +47,7 @@ func (f *Flamenco) RegisterWorker(e echo.Context) error {
}
dbWorker := persistence.Worker{
UUID: uuid.New(),
UUID: workerUUID,
Name: req.Name,
Secret: string(hashedPassword),
Platform: req.Platform,

View File

@ -25,9 +25,12 @@ import (
shaman_config "git.blender.org/flamenco/pkg/shaman/config"
)
const (
configFilename = "flamenco-manager.yaml"
// configFilename is used to specify where flamenco will write its config file.
// If the path is not absolute, it will use the flamenco binary location as the
// relative root path. This is not intended to be changed during runtime.
var configFilename = "flamenco-manager.yaml"
const (
latestConfigVersion = 3
// // relative to the Flamenco Server Base URL:

View File

@ -20,7 +20,9 @@ type Author struct {
}
type AuthoredJob struct {
JobID string
JobID string
WorkerClusterUUID string
Name string
JobType string
Priority int

View File

@ -127,6 +127,10 @@ func (s *Service) Compile(ctx context.Context, sj api.SubmittedJob) (*AuthoredJo
aj.Storage.ShamanCheckoutID = *sj.Storage.ShamanCheckoutId
}
if sj.WorkerCluster != nil {
aj.WorkerClusterUUID = *sj.WorkerCluster
}
compiler, err := vm.getCompileJob()
if err != nil {
return nil, err
@ -139,12 +143,13 @@ func (s *Service) Compile(ctx context.Context, sj api.SubmittedJob) (*AuthoredJo
Int("num_tasks", len(aj.Tasks)).
Str("name", aj.Name).
Str("jobtype", aj.JobType).
Str("job", aj.JobID).
Msg("job compiled")
return &aj, nil
}
// ListJobTypes returns the list of available job types.
// ListJobTypes returns the list of available job types.
func (s *Service) ListJobTypes() api.AvailableJobTypes {
jobTypes := make([]api.AvailableJobType, 0)

View File

@ -45,11 +45,12 @@ func exampleSubmittedJob() api.SubmittedJob {
"user.name": "Sybren Stüvel",
}}
sj := api.SubmittedJob{
Name: "3Д рендеринг",
Priority: 50,
Type: "simple-blender-render",
Settings: &settings,
Metadata: &metadata,
Name: "3Д рендеринг",
Priority: 50,
Type: "simple-blender-render",
Settings: &settings,
Metadata: &metadata,
WorkerCluster: ptr("acce9983-e663-4210-b3cc-f7bfa629cb21"),
}
return sj
}
@ -79,6 +80,7 @@ func TestSimpleBlenderRenderHappy(t *testing.T) {
// Properties should be copied as-is.
assert.Equal(t, sj.Name, aj.Name)
assert.Equal(t, *sj.WorkerCluster, aj.WorkerClusterUUID)
assert.Equal(t, sj.Type, aj.JobType)
assert.Equal(t, sj.Priority, aj.Priority)
assert.EqualValues(t, sj.Settings.AdditionalProperties, aj.Settings)
@ -137,6 +139,35 @@ func TestSimpleBlenderRenderHappy(t *testing.T) {
assert.Equal(t, expectDeps, tVideo.Dependencies)
}
func TestJobWithoutCluster(t *testing.T) {
c := mockedClock(t)
s, err := Load(c)
require.NoError(t, err)
// Compiling a job should be really fast.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
sj := exampleSubmittedJob()
// Try with nil WorkerCluster.
{
sj.WorkerCluster = nil
aj, err := s.Compile(ctx, sj)
require.NoError(t, err)
assert.Zero(t, aj.WorkerClusterUUID)
}
// Try with empty WorkerCluster.
{
sj.WorkerCluster = ptr("")
aj, err := s.Compile(ctx, sj)
require.NoError(t, err)
assert.Zero(t, aj.WorkerClusterUUID)
}
}
func TestSimpleBlenderRenderWindowsPaths(t *testing.T) {
c := mockedClock(t)

View File

@ -28,7 +28,13 @@ import (
// memory at a time. This is variable to allow unit testing with lower limits.
var jobDeletionQueueSize = defaultJobDeletionQueueSize
const defaultJobDeletionQueueSize = 100
const (
defaultJobDeletionQueueSize = 100
// jobDeletionCheckInterval determines how often the database is checked for
// jobs that have been requested to be deleted.
jobDeletionCheckInterval = 1 * time.Minute
)
// Service can mark jobs as "deletion requested", as well as delete those jobs
// in a background goroutine.
@ -106,7 +112,7 @@ func (s *Service) Run(ctx context.Context) {
return
case jobUUID := <-s.queue:
s.deleteJob(ctx, jobUUID)
case <-time.After(1 * time.Minute):
case <-time.After(jobDeletionCheckInterval):
// Inspect the database to see if there was anything marked for deletion
// without getting into our queue. This can happen when lots of jobs are
// queued in quick succession, as then the queue channel gets full.
@ -126,13 +132,20 @@ func (s *Service) queuePendingDeletions(ctx context.Context) {
return
}
for _, jobUUID := range jobUUIDs {
numDeletionsQueued := len(jobUUIDs)
queueLoop:
for index, jobUUID := range jobUUIDs {
select {
case s.queue <- jobUUID:
log.Debug().Str("job", jobUUID).Msg("job deleter: job queued for deletion")
case <-time.After(100 * time.Millisecond):
log.Info().Msg("job deleter: job deletion queue is full")
break
numRemaining := numDeletionsQueued - index
log.Info().
Int("deletionsQueued", len(s.queue)).
Int("deletionsRemaining", numRemaining).
Stringer("checkInterval", jobDeletionCheckInterval).
Msg("job deleter: job deletion queue is full, remaining deletions will be picked up later")
break queueLoop
}
}
}
@ -145,13 +158,13 @@ func (s *Service) deleteJob(ctx context.Context, jobUUID string) error {
return err
}
logger.Info().Msg("job deleter: removing logs, last-rendered images, etc.")
logger.Debug().Msg("job deleter: removing logs, last-rendered images, etc.")
if err := s.storage.RemoveJobStorage(ctx, jobUUID); err != nil {
logger.Error().Err(err).Msg("job deleter: error removing job logs, job deletion aborted")
return err
}
logger.Info().Msg("job deleter: removing job from database")
logger.Debug().Msg("job deleter: removing job from database")
if err := s.persist.DeleteJob(ctx, jobUUID); err != nil {
logger.Error().Err(err).Msg("job deleter: unable to remove job from database")
return err

View File

@ -16,6 +16,7 @@ func (db *DB) migrate() error {
&Task{},
&TaskFailure{},
&Worker{},
&WorkerCluster{},
)
if err != nil {
return fmt.Errorf("failed to automigrate database: %v", err)

View File

@ -9,9 +9,10 @@ import (
)
var (
ErrJobNotFound = PersistenceError{Message: "job not found", Err: gorm.ErrRecordNotFound}
ErrTaskNotFound = PersistenceError{Message: "task not found", Err: gorm.ErrRecordNotFound}
ErrWorkerNotFound = PersistenceError{Message: "worker not found", Err: gorm.ErrRecordNotFound}
ErrJobNotFound = PersistenceError{Message: "job not found", Err: gorm.ErrRecordNotFound}
ErrTaskNotFound = PersistenceError{Message: "task not found", Err: gorm.ErrRecordNotFound}
ErrWorkerNotFound = PersistenceError{Message: "worker not found", Err: gorm.ErrRecordNotFound}
ErrWorkerClusterNotFound = PersistenceError{Message: "worker cluster not found", Err: gorm.ErrRecordNotFound}
)
type PersistenceError struct {
@ -39,6 +40,10 @@ func workerError(errorToWrap error, message string, msgArgs ...interface{}) erro
return wrapError(translateGormWorkerError(errorToWrap), message, msgArgs...)
}
func workerClusterError(errorToWrap error, message string, msgArgs ...interface{}) error {
return wrapError(translateGormWorkerClusterError(errorToWrap), message, msgArgs...)
}
func wrapError(errorToWrap error, message string, format ...interface{}) error {
// Only format if there are arguments for formatting.
var formattedMsg string
@ -80,3 +85,12 @@ func translateGormWorkerError(gormError error) error {
}
return gormError
}
// translateGormWorkerClusterError translates a Gorm error to a persistence layer error.
// This helps to keep Gorm as "implementation detail" of the persistence layer.
func translateGormWorkerClusterError(gormError error) error {
if errors.Is(gormError, gorm.ErrRecordNotFound) {
return ErrWorkerClusterNotFound
}
return gormError
}

View File

@ -35,6 +35,9 @@ type Job struct {
DeleteRequestedAt sql.NullTime
Storage JobStorageInfo `gorm:"embedded;embeddedPrefix:storage_"`
WorkerClusterID *uint
WorkerCluster *WorkerCluster `gorm:"foreignkey:WorkerClusterID;references:ID;constraint:OnDelete:SET NULL"`
}
type StringInterfaceMap map[string]interface{}
@ -145,6 +148,16 @@ func (db *DB) StoreAuthoredJob(ctx context.Context, authoredJob job_compilers.Au
},
}
// Find and assign the worker cluster.
if authoredJob.WorkerClusterUUID != "" {
dbCluster, err := fetchWorkerCluster(tx, authoredJob.WorkerClusterUUID)
if err != nil {
return err
}
dbJob.WorkerClusterID = &dbCluster.ID
dbJob.WorkerCluster = dbCluster
}
if err := tx.Create(&dbJob).Error; err != nil {
return jobError(err, "storing job")
}
@ -212,6 +225,7 @@ func (db *DB) FetchJob(ctx context.Context, jobUUID string) (*Job, error) {
dbJob := Job{}
findResult := db.gormDB.WithContext(ctx).
Limit(1).
Preload("WorkerCluster").
Find(&dbJob, "uuid = ?", jobUUID)
if findResult.Error != nil {
return nil, jobError(findResult.Error, "fetching job")

View File

@ -103,13 +103,26 @@ func (db *DB) WorkersLeftToRun(ctx context.Context, job *Job, taskType string) (
Where("JB.job_id = ?", job.ID).
Where("JB.task_type = ?", taskType)
// Find the workers NOT blocked.
workers := []*Worker{}
tx := db.gormDB.WithContext(ctx).
query := db.gormDB.WithContext(ctx).
Model(&Worker{}).
Select("uuid").
Where("id not in (?)", blockedWorkers).
Scan(&workers)
Where("id not in (?)", blockedWorkers)
if job.WorkerClusterID == nil {
// Count all workers, so no extra restrictions are necessary.
} else {
// Only count workers in the job's cluster.
jobCluster := db.gormDB.
Table("worker_cluster_membership").
Select("worker_id").
Where("worker_cluster_id = ?", *job.WorkerClusterID)
query = query.
Where("id in (?)", jobCluster)
}
// Find the workers NOT blocked.
workers := []*Worker{}
tx := query.Scan(&workers)
if tx.Error != nil {
return nil, tx.Error
}

View File

@ -4,6 +4,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// SPDX-License-Identifier: GPL-3.0-or-later
@ -125,6 +126,16 @@ func TestWorkersLeftToRun(t *testing.T) {
worker1 := createWorker(ctx, t, db)
worker2 := createWorkerFrom(ctx, t, db, *worker1)
// Create one worker cluster. It will not be used by this job, but one of the
// workers will be assigned to it. It can get this job's tasks, though.
// Because the job is clusterless, it can be run by all.
cluster1 := WorkerCluster{UUID: "11157623-4b14-4801-bee2-271dddab6309", Name: "Cluster 1"}
require.NoError(t, db.CreateWorkerCluster(ctx, &cluster1))
workerC1 := createWorker(ctx, t, db, func(w *Worker) {
w.UUID = "c1c1c1c1-0000-1111-2222-333333333333"
w.Clusters = []*WorkerCluster{&cluster1}
})
uuidMap := func(workers ...*Worker) map[string]bool {
theMap := map[string]bool{}
for _, worker := range workers {
@ -133,21 +144,22 @@ func TestWorkersLeftToRun(t *testing.T) {
return theMap
}
// Two workers, no blocklist.
// Three workers, no blocklist.
left, err = db.WorkersLeftToRun(ctx, job, "blender")
if assert.NoError(t, err) {
assert.Equal(t, uuidMap(worker1, worker2), left)
assert.Equal(t, uuidMap(worker1, worker2, workerC1), left)
}
// Two workers, one blocked.
_ = db.AddWorkerToJobBlocklist(ctx, job, worker1, "blender")
left, err = db.WorkersLeftToRun(ctx, job, "blender")
if assert.NoError(t, err) {
assert.Equal(t, uuidMap(worker2), left)
assert.Equal(t, uuidMap(worker2, workerC1), left)
}
// Two workers, both blocked.
// All workers blocked.
_ = db.AddWorkerToJobBlocklist(ctx, job, worker2, "blender")
_ = db.AddWorkerToJobBlocklist(ctx, job, workerC1, "blender")
left, err = db.WorkersLeftToRun(ctx, job, "blender")
assert.NoError(t, err)
assert.Empty(t, left)
@ -156,10 +168,75 @@ func TestWorkersLeftToRun(t *testing.T) {
fakeJob := Job{Model: Model{ID: 327}}
left, err = db.WorkersLeftToRun(ctx, &fakeJob, "blender")
if assert.NoError(t, err) {
assert.Equal(t, uuidMap(worker1, worker2), left)
assert.Equal(t, uuidMap(worker1, worker2, workerC1), left)
}
}
func TestWorkersLeftToRunWithClusters(t *testing.T) {
ctx, cancel, db := persistenceTestFixtures(t, schedulerTestTimeout)
defer cancel()
// Create clusters.
cluster1 := WorkerCluster{UUID: "11157623-4b14-4801-bee2-271dddab6309", Name: "Cluster 1"}
cluster2 := WorkerCluster{UUID: "22257623-4b14-4801-bee2-271dddab6309", Name: "Cluster 2"}
cluster3 := WorkerCluster{UUID: "33357623-4b14-4801-bee2-271dddab6309", Name: "Cluster 3"}
require.NoError(t, db.CreateWorkerCluster(ctx, &cluster1))
require.NoError(t, db.CreateWorkerCluster(ctx, &cluster2))
require.NoError(t, db.CreateWorkerCluster(ctx, &cluster3))
// Create a job in cluster1.
authoredJob := createTestAuthoredJobWithTasks()
authoredJob.WorkerClusterUUID = cluster1.UUID
job := persistAuthoredJob(t, ctx, db, authoredJob)
// Clusters 1 + 3
workerC13 := createWorker(ctx, t, db, func(w *Worker) {
w.UUID = "c13c1313-0000-1111-2222-333333333333"
w.Clusters = []*WorkerCluster{&cluster1, &cluster3}
})
// Cluster 1
workerC1 := createWorker(ctx, t, db, func(w *Worker) {
w.UUID = "c1c1c1c1-0000-1111-2222-333333333333"
w.Clusters = []*WorkerCluster{&cluster1}
})
// Cluster 2 worker, this one should never appear.
createWorker(ctx, t, db, func(w *Worker) {
w.UUID = "c2c2c2c2-0000-1111-2222-333333333333"
w.Clusters = []*WorkerCluster{&cluster2}
})
// No clusters, so should be able to run only clusterless jobs. Which is none
// in this test.
createWorker(ctx, t, db, func(w *Worker) {
w.UUID = "00000000-0000-1111-2222-333333333333"
w.Clusters = nil
})
uuidMap := func(workers ...*Worker) map[string]bool {
theMap := map[string]bool{}
for _, worker := range workers {
theMap[worker.UUID] = true
}
return theMap
}
// All Cluster 1 workers, no blocklist.
left, err := db.WorkersLeftToRun(ctx, job, "blender")
require.NoError(t, err)
assert.Equal(t, uuidMap(workerC13, workerC1), left)
// One worker blocked, one worker remain.
_ = db.AddWorkerToJobBlocklist(ctx, job, workerC1, "blender")
left, err = db.WorkersLeftToRun(ctx, job, "blender")
require.NoError(t, err)
assert.Equal(t, uuidMap(workerC13), left)
// All clustered workers blocked.
_ = db.AddWorkerToJobBlocklist(ctx, job, workerC13, "blender")
left, err = db.WorkersLeftToRun(ctx, job, "blender")
assert.NoError(t, err)
assert.Empty(t, left)
}
func TestCountTaskFailuresOfWorker(t *testing.T) {
ctx, close, db, dbJob, authoredJob := jobTasksTestFixtures(t)
defer close()

View File

@ -64,6 +64,8 @@ func (db *DB) QueryJobs(ctx context.Context, apiQ api.JobsQuery) ([]*Job, error)
}
}
q.Preload("Cluster")
result := []*Job{}
tx := q.Scan(&result)
return result, tx.Error

View File

@ -676,7 +676,7 @@ func jobTasksTestFixtures(t *testing.T) (context.Context, context.CancelFunc, *D
return ctx, cancel, db, dbJob, authoredJob
}
func createWorker(ctx context.Context, t *testing.T, db *DB) *Worker {
func createWorker(ctx context.Context, t *testing.T, db *DB, updaters ...func(*Worker)) *Worker {
w := Worker{
UUID: "f0a123a9-ab05-4ce2-8577-94802cfe74a4",
Name: "дрон",
@ -685,6 +685,11 @@ func createWorker(ctx context.Context, t *testing.T, db *DB) *Worker {
Software: "3.0",
Status: api.WorkerStatusAwake,
SupportedTaskTypes: "blender,ffmpeg,file-management",
Clusters: nil,
}
for _, updater := range updaters {
updater(&w)
}
err := db.CreateWorker(ctx, &w)

View File

@ -26,13 +26,18 @@ func (db *DB) ScheduleTask(ctx context.Context, w *Worker) (*Task, error) {
logger := log.With().Str("worker", w.UUID).Logger()
logger.Trace().Msg("finding task for worker")
hasWorkerClusters, err := db.HasWorkerClusters(ctx)
if err != nil {
return nil, err
}
// Run two queries in one transaction:
// 1. find task, and
// 2. assign the task to the worker.
var task *Task
txErr := db.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
task, err = findTaskForWorker(tx, w)
task, err = findTaskForWorker(tx, w, hasWorkerClusters)
if err != nil {
if isDatabaseBusyError(err) {
logger.Trace().Err(err).Msg("database busy while finding task for worker")
@ -79,7 +84,7 @@ func (db *DB) ScheduleTask(ctx context.Context, w *Worker) (*Task, error) {
return task, nil
}
func findTaskForWorker(tx *gorm.DB, w *Worker) (*Task, error) {
func findTaskForWorker(tx *gorm.DB, w *Worker, checkWorkerClusters bool) (*Task, error) {
task := Task{}
// If a task is alreay active & assigned to this worker, return just that.
@ -114,18 +119,37 @@ func findTaskForWorker(tx *gorm.DB, w *Worker) (*Task, error) {
// a 'schedulable' status might have been assigned to a worker, representing
// the last worker to touch it -- it's not meant to indicate "ownership" of
// the task.
findTaskResult := tx.
Model(&task).
findTaskQuery := tx.Model(&task).
Joins("left join jobs on tasks.job_id = jobs.id").
Joins("left join task_failures TF on tasks.id = TF.task_id and TF.worker_id=?", w.ID).
Where("tasks.status in ?", schedulableTaskStatuses). // Schedulable task statuses
Where("jobs.status in ?", schedulableJobStatuses). // Schedulable job statuses
Where("tasks.type in ?", w.TaskTypes()). // Supported task types
Where("tasks.id not in (?)", incompleteDepsQuery). // Dependencies completed
Where("TF.worker_id is NULL"). // Not failed before
Where("tasks.type not in (?)", blockedTaskTypesQuery). // Non-blocklisted
Order("jobs.priority desc"). // Highest job priority
Order("tasks.priority desc"). // Highest task priority
Where("tasks.status in ?", schedulableTaskStatuses). // Schedulable task statuses
Where("jobs.status in ?", schedulableJobStatuses). // Schedulable job statuses
Where("tasks.type in ?", w.TaskTypes()). // Supported task types
Where("tasks.id not in (?)", incompleteDepsQuery). // Dependencies completed
Where("TF.worker_id is NULL"). // Not failed before
Where("tasks.type not in (?)", blockedTaskTypesQuery) // Non-blocklisted
if checkWorkerClusters {
// The system has one or more clusters, so limit the available jobs to those
// that have no cluster, or overlap with the Worker's clusters.
if len(w.Clusters) == 0 {
// Clusterless workers only get clusterless jobs.
findTaskQuery = findTaskQuery.
Where("jobs.worker_cluster_id is NULL")
} else {
// Clustered workers get clusterless jobs AND jobs of their own clusters.
clusterIDs := []uint{}
for _, cluster := range w.Clusters {
clusterIDs = append(clusterIDs, cluster.ID)
}
findTaskQuery = findTaskQuery.
Where("jobs.worker_cluster_id is NULL or worker_cluster_id in ?", clusterIDs)
}
}
findTaskResult := findTaskQuery.
Order("jobs.priority desc"). // Highest job priority
Order("tasks.priority desc"). // Highest task priority
Limit(1).
Preload("Job").
Find(&task)

View File

@ -8,6 +8,7 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.blender.org/flamenco/internal/manager/job_compilers"
"git.blender.org/flamenco/internal/uuid"
@ -289,6 +290,90 @@ func TestPreviouslyFailed(t *testing.T) {
assert.Equal(t, att2.Name, task.Name, "the second task should have been chosen")
}
func TestWorkerClusterJobWithCluster(t *testing.T) {
ctx, cancel, db := persistenceTestFixtures(t, schedulerTestTimeout)
defer cancel()
// Create worker clusters:
cluster1 := WorkerCluster{UUID: "f0157623-4b14-4801-bee2-271dddab6309", Name: "Cluster 1"}
cluster2 := WorkerCluster{UUID: "2f71dba1-cf92-4752-8386-f5926affabd5", Name: "Cluster 2"}
require.NoError(t, db.CreateWorkerCluster(ctx, &cluster1))
require.NoError(t, db.CreateWorkerCluster(ctx, &cluster2))
// Create a worker in cluster1:
workerC := linuxWorker(t, db, func(w *Worker) {
w.Clusters = []*WorkerCluster{&cluster1}
})
// Create a worker without cluster:
workerNC := linuxWorker(t, db, func(w *Worker) {
w.UUID = "c53f8f68-4149-4790-991c-ba73a326551e"
w.Clusters = nil
})
{ // Test job with different cluster:
authTask := authorTestTask("the task", "blender")
job := authorTestJob("499cf0f8-e83d-4cb1-837a-df94789d07db", "simple-blender-render", authTask)
job.WorkerClusterUUID = cluster2.UUID
constructTestJob(ctx, t, db, job)
task, err := db.ScheduleTask(ctx, &workerC)
require.NoError(t, err)
assert.Nil(t, task, "job with different cluster should not be scheduled")
}
{ // Test job with matching cluster:
authTask := authorTestTask("the task", "blender")
job := authorTestJob("5d4c2321-0bb7-4c13-a9dd-32a2c0cd156e", "simple-blender-render", authTask)
job.WorkerClusterUUID = cluster1.UUID
constructTestJob(ctx, t, db, job)
task, err := db.ScheduleTask(ctx, &workerC)
require.NoError(t, err)
require.NotNil(t, task, "job with matching cluster should be scheduled")
assert.Equal(t, authTask.UUID, task.UUID)
task, err = db.ScheduleTask(ctx, &workerNC)
require.NoError(t, err)
assert.Nil(t, task, "job with cluster should not be scheduled for worker without cluster")
}
}
func TestWorkerClusterJobWithoutCluster(t *testing.T) {
ctx, cancel, db := persistenceTestFixtures(t, schedulerTestTimeout)
defer cancel()
// Create worker cluster:
cluster1 := WorkerCluster{UUID: "f0157623-4b14-4801-bee2-271dddab6309", Name: "Cluster 1"}
require.NoError(t, db.CreateWorkerCluster(ctx, &cluster1))
// Create a worker in cluster1:
workerC := linuxWorker(t, db, func(w *Worker) {
w.Clusters = []*WorkerCluster{&cluster1}
})
// Create a worker without cluster:
workerNC := linuxWorker(t, db, func(w *Worker) {
w.UUID = "c53f8f68-4149-4790-991c-ba73a326551e"
w.Clusters = nil
})
// Test cluster-less job:
authTask := authorTestTask("the task", "blender")
job := authorTestJob("b6a1d859-122f-4791-8b78-b943329a9989", "simple-blender-render", authTask)
constructTestJob(ctx, t, db, job)
task, err := db.ScheduleTask(ctx, &workerC)
require.NoError(t, err)
require.NotNil(t, task, "job without cluster should always be scheduled to worker in some cluster")
assert.Equal(t, authTask.UUID, task.UUID)
task, err = db.ScheduleTask(ctx, &workerNC)
require.NoError(t, err)
require.NotNil(t, task, "job without cluster should always be scheduled to worker without cluster")
assert.Equal(t, authTask.UUID, task.UUID)
}
func TestBlocklisted(t *testing.T) {
ctx, cancel, db := persistenceTestFixtures(t, schedulerTestTimeout)
defer cancel()
@ -383,7 +468,7 @@ func setTaskStatus(t *testing.T, db *DB, taskUUID string, status api.TaskStatus)
}
}
func linuxWorker(t *testing.T, db *DB) Worker {
func linuxWorker(t *testing.T, db *DB, updaters ...func(worker *Worker)) Worker {
w := Worker{
UUID: "b13b8322-3e96-41c3-940a-3d581008a5f8",
Name: "Linux",
@ -392,6 +477,10 @@ func linuxWorker(t *testing.T, db *DB) Worker {
SupportedTaskTypes: "blender,ffmpeg,file-management,misc",
}
for _, updater := range updaters {
updater(&w)
}
err := db.gormDB.Save(&w).Error
if err != nil {
t.Logf("cannot save Linux worker: %v", err)

View File

@ -10,9 +10,12 @@ import (
"testing"
"time"
"git.blender.org/flamenco/internal/uuid"
"git.blender.org/flamenco/pkg/api"
"github.com/glebarez/sqlite"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
@ -87,3 +90,44 @@ func persistenceTestFixtures(t *testing.T, testContextTimeout time.Duration) (co
return ctx, cancel, db
}
type WorkerTestFixture struct {
db *DB
ctx context.Context
done func()
worker *Worker
cluster *WorkerCluster
}
func workerTestFixtures(t *testing.T, testContextTimeout time.Duration) WorkerTestFixture {
ctx, cancel, db := persistenceTestFixtures(t, testContextTimeout)
w := Worker{
UUID: uuid.New(),
Name: "дрон",
Address: "fe80::5054:ff:fede:2ad7",
Platform: "linux",
Software: "3.0",
Status: api.WorkerStatusAwake,
SupportedTaskTypes: "blender,ffmpeg,file-management",
}
wc := WorkerCluster{
UUID: uuid.New(),
Name: "arbejdsklynge",
Description: "Worker cluster in Danish",
}
require.NoError(t, db.CreateWorker(ctx, &w))
require.NoError(t, db.CreateWorkerCluster(ctx, &wc))
return WorkerTestFixture{
db: db,
ctx: ctx,
done: cancel,
worker: &w,
cluster: &wc,
}
}

View File

@ -47,7 +47,7 @@ func TestFetchTimedOutTasks(t *testing.T) {
// tests that the expected task is returned.
assert.Equal(t, task.UUID, timedout[0].UUID)
assert.Equal(t, job, timedout[0].Job, "the job should be included in the result as well")
assert.Equal(t, w, timedout[0].Worker, "the worker should be included in the result as well")
assert.Equal(t, w.UUID, timedout[0].Worker.UUID, "the worker should be included in the result as well")
}
}

View File

@ -0,0 +1,112 @@
package persistence
// SPDX-License-Identifier: GPL-3.0-or-later
import (
"context"
"fmt"
"gorm.io/gorm"
)
type WorkerCluster struct {
Model
UUID string `gorm:"type:char(36);default:'';unique;index"`
Name string `gorm:"type:varchar(64);default:'';unique"`
Description string `gorm:"type:varchar(255);default:''"`
Workers []*Worker `gorm:"many2many:worker_cluster_membership;constraint:OnDelete:CASCADE"`
}
func (db *DB) CreateWorkerCluster(ctx context.Context, wc *WorkerCluster) error {
if err := db.gormDB.WithContext(ctx).Create(wc).Error; err != nil {
return fmt.Errorf("creating new worker cluster: %w", err)
}
return nil
}
// HasWorkerClusters returns whether there are any clusters defined at all.
func (db *DB) HasWorkerClusters(ctx context.Context) (bool, error) {
var count int64
tx := db.gormDB.WithContext(ctx).
Model(&WorkerCluster{}).
Count(&count)
if err := tx.Error; err != nil {
return false, workerClusterError(err, "counting worker clusters")
}
return count > 0, nil
}
func (db *DB) FetchWorkerCluster(ctx context.Context, uuid string) (*WorkerCluster, error) {
tx := db.gormDB.WithContext(ctx)
return fetchWorkerCluster(tx, uuid)
}
// fetchWorkerCluster fetches the worker cluster using the given database instance.
func fetchWorkerCluster(gormDB *gorm.DB, uuid string) (*WorkerCluster, error) {
w := WorkerCluster{}
tx := gormDB.First(&w, "uuid = ?", uuid)
if tx.Error != nil {
return nil, workerClusterError(tx.Error, "fetching worker cluster")
}
return &w, nil
}
func (db *DB) SaveWorkerCluster(ctx context.Context, cluster *WorkerCluster) error {
if err := db.gormDB.WithContext(ctx).Save(cluster).Error; err != nil {
return workerClusterError(err, "saving worker cluster")
}
return nil
}
// DeleteWorkerCluster deletes the given cluster, after unassigning all workers from it.
func (db *DB) DeleteWorkerCluster(ctx context.Context, uuid string) error {
tx := db.gormDB.WithContext(ctx).
Where("uuid = ?", uuid).
Delete(&WorkerCluster{})
if tx.Error != nil {
return workerClusterError(tx.Error, "deleting worker cluster")
}
if tx.RowsAffected == 0 {
return ErrWorkerClusterNotFound
}
return nil
}
func (db *DB) FetchWorkerClusters(ctx context.Context) ([]*WorkerCluster, error) {
clusters := make([]*WorkerCluster, 0)
tx := db.gormDB.WithContext(ctx).Model(&WorkerCluster{}).Scan(&clusters)
if tx.Error != nil {
return nil, workerClusterError(tx.Error, "fetching all worker clusters")
}
return clusters, nil
}
func (db *DB) fetchWorkerClustersWithUUID(ctx context.Context, clusterUUIDs []string) ([]*WorkerCluster, error) {
clusters := make([]*WorkerCluster, 0)
tx := db.gormDB.WithContext(ctx).
Model(&WorkerCluster{}).
Where("uuid in ?", clusterUUIDs).
Scan(&clusters)
if tx.Error != nil {
return nil, workerClusterError(tx.Error, "fetching all worker clusters")
}
return clusters, nil
}
func (db *DB) WorkerSetClusters(ctx context.Context, worker *Worker, clusterUUIDs []string) error {
clusters, err := db.fetchWorkerClustersWithUUID(ctx, clusterUUIDs)
if err != nil {
return workerClusterError(err, "fetching worker clusters")
}
err = db.gormDB.WithContext(ctx).
Model(worker).
Association("Clusters").
Replace(clusters)
if err != nil {
return workerClusterError(err, "updating worker clusters")
}
return nil
}

View File

@ -0,0 +1,165 @@
package persistence
// SPDX-License-Identifier: GPL-3.0-or-later
import (
"testing"
"time"
"git.blender.org/flamenco/internal/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateFetchCluster(t *testing.T) {
f := workerTestFixtures(t, 1*time.Second)
defer f.done()
// Test fetching non-existent cluster
fetchedCluster, err := f.db.FetchWorkerCluster(f.ctx, "7ee21bc8-ff1a-42d2-a6b6-cc4b529b189f")
assert.ErrorIs(t, err, ErrWorkerClusterNotFound)
assert.Nil(t, fetchedCluster)
// New cluster creation is already done in the workerTestFixtures() call.
assert.NotNil(t, f.cluster)
fetchedCluster, err = f.db.FetchWorkerCluster(f.ctx, f.cluster.UUID)
require.NoError(t, err)
assert.NotNil(t, fetchedCluster)
// Test contents of fetched cluster.
assert.Equal(t, f.cluster.UUID, fetchedCluster.UUID)
assert.Equal(t, f.cluster.Name, fetchedCluster.Name)
assert.Equal(t, f.cluster.Description, fetchedCluster.Description)
assert.Zero(t, fetchedCluster.Workers)
}
func TestFetchDeleteClusters(t *testing.T) {
f := workerTestFixtures(t, 1*time.Second)
defer f.done()
// Single cluster was created by fixture.
has, err := f.db.HasWorkerClusters(f.ctx)
require.NoError(t, err)
assert.True(t, has, "expecting HasWorkerClusters to return true")
secondCluster := WorkerCluster{
UUID: uuid.New(),
Name: "arbeiderscluster",
Description: "Worker cluster in Dutch",
}
require.NoError(t, f.db.CreateWorkerCluster(f.ctx, &secondCluster))
allClusters, err := f.db.FetchWorkerClusters(f.ctx)
require.NoError(t, err)
require.Len(t, allClusters, 2)
var allClusterIDs [2]string
for idx := range allClusters {
allClusterIDs[idx] = allClusters[idx].UUID
}
assert.Contains(t, allClusterIDs, f.cluster.UUID)
assert.Contains(t, allClusterIDs, secondCluster.UUID)
has, err = f.db.HasWorkerClusters(f.ctx)
require.NoError(t, err)
assert.True(t, has, "expecting HasWorkerClusters to return true")
// Test deleting the 2nd cluster.
require.NoError(t, f.db.DeleteWorkerCluster(f.ctx, secondCluster.UUID))
allClusters, err = f.db.FetchWorkerClusters(f.ctx)
require.NoError(t, err)
require.Len(t, allClusters, 1)
assert.Equal(t, f.cluster.UUID, allClusters[0].UUID)
// Test deleting the 1st cluster.
require.NoError(t, f.db.DeleteWorkerCluster(f.ctx, f.cluster.UUID))
has, err = f.db.HasWorkerClusters(f.ctx)
require.NoError(t, err)
assert.False(t, has, "expecting HasWorkerClusters to return false")
}
func TestAssignUnassignWorkerClusters(t *testing.T) {
f := workerTestFixtures(t, 1*time.Second)
defer f.done()
assertClusters := func(msgLabel string, clusterUUIDs ...string) {
w, err := f.db.FetchWorker(f.ctx, f.worker.UUID)
require.NoError(t, err)
// Catch doubly-reported clusters, as the maps below would hide those cases.
assert.Len(t, w.Clusters, len(clusterUUIDs), msgLabel)
expectClusters := make(map[string]bool)
for _, cid := range clusterUUIDs {
expectClusters[cid] = true
}
actualClusters := make(map[string]bool)
for _, c := range w.Clusters {
actualClusters[c.UUID] = true
}
assert.Equal(t, expectClusters, actualClusters, msgLabel)
}
secondCluster := WorkerCluster{
UUID: uuid.New(),
Name: "arbeiderscluster",
Description: "Worker cluster in Dutch",
}
require.NoError(t, f.db.CreateWorkerCluster(f.ctx, &secondCluster))
// By default the Worker should not be part of a cluster.
assertClusters("default cluster assignment")
require.NoError(t, f.db.WorkerSetClusters(f.ctx, f.worker, []string{f.cluster.UUID}))
assertClusters("setting one cluster", f.cluster.UUID)
// Double assignments should also just work.
require.NoError(t, f.db.WorkerSetClusters(f.ctx, f.worker, []string{f.cluster.UUID, f.cluster.UUID}))
assertClusters("setting twice the same cluster", f.cluster.UUID)
// Multiple cluster memberships.
require.NoError(t, f.db.WorkerSetClusters(f.ctx, f.worker, []string{f.cluster.UUID, secondCluster.UUID}))
assertClusters("setting two different clusters", f.cluster.UUID, secondCluster.UUID)
// Remove memberships.
require.NoError(t, f.db.WorkerSetClusters(f.ctx, f.worker, []string{secondCluster.UUID}))
assertClusters("unassigning from first cluster", secondCluster.UUID)
require.NoError(t, f.db.WorkerSetClusters(f.ctx, f.worker, []string{}))
assertClusters("unassigning from second cluster")
}
func TestSaveWorkerCluster(t *testing.T) {
f := workerTestFixtures(t, 1*time.Second)
defer f.done()
f.cluster.Name = "übercluster"
f.cluster.Description = "ʻO kēlā hui ma laila"
require.NoError(t, f.db.SaveWorkerCluster(f.ctx, f.cluster))
fetched, err := f.db.FetchWorkerCluster(f.ctx, f.cluster.UUID)
require.NoError(t, err)
assert.Equal(t, f.cluster.Name, fetched.Name)
assert.Equal(t, f.cluster.Description, fetched.Description)
}
func TestDeleteWorkerClusterWithWorkersAssigned(t *testing.T) {
f := workerTestFixtures(t, 1*time.Second)
defer f.done()
// Assign the worker.
require.NoError(t, f.db.WorkerSetClusters(f.ctx, f.worker, []string{f.cluster.UUID}))
// Delete the cluster.
require.NoError(t, f.db.DeleteWorkerCluster(f.ctx, f.cluster.UUID))
// Check the Worker has been unassigned from the cluster.
w, err := f.db.FetchWorker(f.ctx, f.worker.UUID)
require.NoError(t, err)
assert.Empty(t, w.Clusters)
}

View File

@ -16,7 +16,7 @@ type Worker struct {
Model
DeletedAt gorm.DeletedAt `gorm:"index"`
UUID string `gorm:"type:char(36);default:'';unique;index;default:''"`
UUID string `gorm:"type:char(36);default:'';unique;index"`
Secret string `gorm:"type:varchar(255);default:''"`
Name string `gorm:"type:varchar(64);default:''"`
@ -30,6 +30,8 @@ type Worker struct {
LazyStatusRequest bool `gorm:"type:smallint;default:0"`
SupportedTaskTypes string `gorm:"type:varchar(255);default:''"` // comma-separated list of task types.
Clusters []*WorkerCluster `gorm:"many2many:worker_cluster_membership;constraint:OnDelete:CASCADE"`
}
func (w *Worker) Identifier() string {
@ -71,6 +73,7 @@ func (db *DB) CreateWorker(ctx context.Context, w *Worker) error {
func (db *DB) FetchWorker(ctx context.Context, uuid string) (*Worker, error) {
w := Worker{}
tx := db.gormDB.WithContext(ctx).
Preload("Clusters").
First(&w, "uuid = ?", uuid)
if tx.Error != nil {
return nil, workerError(tx.Error, "fetching worker")

View File

@ -8,6 +8,7 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.blender.org/flamenco/internal/uuid"
"git.blender.org/flamenco/pkg/api"
@ -317,3 +318,19 @@ func TestDeleteWorker(t *testing.T) {
assert.True(t, fetchedTask.Worker.DeletedAt.Valid)
}
}
func TestDeleteWorkerWithClusterAssigned(t *testing.T) {
f := workerTestFixtures(t, 1*time.Second)
defer f.done()
// Assign the worker.
require.NoError(t, f.db.WorkerSetClusters(f.ctx, f.worker, []string{f.cluster.UUID}))
// Delete the Worker.
require.NoError(t, f.db.DeleteWorker(f.ctx, f.worker.UUID))
// Check the Worker has been unassigned from the cluster.
cluster, err := f.db.FetchWorkerCluster(f.ctx, f.cluster.UUID)
require.NoError(t, err)
assert.Empty(t, cluster.Workers)
}

View File

@ -32,6 +32,8 @@ func NewWorkerUpdate(worker *persistence.Worker) api.SocketIOWorkerUpdate {
workerUpdate.LastSeen = &worker.LastSeenAt
}
// TODO: add cluster IDs.
return workerUpdate
}

View File

@ -22,7 +22,11 @@ var (
errURLWithoutHostName = errors.New("manager URL should contain a host name")
)
const (
var (
// config- and credentialsFilename are used to specify where flamenco will
// write its config/credentials file. If the path is not absolute, it will
// use the flamenco binary location as the relative root path. These are not
// intended to be changed during runtime.
credentialsFilename = "flamenco-worker-credentials.yaml"
configFilename = "flamenco-worker.yaml"
)

View File

@ -116,6 +116,46 @@ func (mr *MockFlamencoClientMockRecorder) CheckSharedStoragePathWithResponse(arg
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckSharedStoragePathWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).CheckSharedStoragePathWithResponse), varargs...)
}
// CreateWorkerClusterWithBodyWithResponse mocks base method.
func (m *MockFlamencoClient) CreateWorkerClusterWithBodyWithResponse(arg0 context.Context, arg1 string, arg2 io.Reader, arg3 ...api.RequestEditorFn) (*api.CreateWorkerClusterResponse, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1, arg2}
for _, a := range arg3 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "CreateWorkerClusterWithBodyWithResponse", varargs...)
ret0, _ := ret[0].(*api.CreateWorkerClusterResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CreateWorkerClusterWithBodyWithResponse indicates an expected call of CreateWorkerClusterWithBodyWithResponse.
func (mr *MockFlamencoClientMockRecorder) CreateWorkerClusterWithBodyWithResponse(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, "CreateWorkerClusterWithBodyWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).CreateWorkerClusterWithBodyWithResponse), varargs...)
}
// CreateWorkerClusterWithResponse mocks base method.
func (m *MockFlamencoClient) CreateWorkerClusterWithResponse(arg0 context.Context, arg1 api.CreateWorkerClusterJSONRequestBody, arg2 ...api.RequestEditorFn) (*api.CreateWorkerClusterResponse, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1}
for _, a := range arg2 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "CreateWorkerClusterWithResponse", varargs...)
ret0, _ := ret[0].(*api.CreateWorkerClusterResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CreateWorkerClusterWithResponse indicates an expected call of CreateWorkerClusterWithResponse.
func (mr *MockFlamencoClientMockRecorder) CreateWorkerClusterWithResponse(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0, arg1}, arg2...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateWorkerClusterWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).CreateWorkerClusterWithResponse), varargs...)
}
// DeleteJobWhatWouldItDoWithResponse mocks base method.
func (m *MockFlamencoClient) DeleteJobWhatWouldItDoWithResponse(arg0 context.Context, arg1 string, arg2 ...api.RequestEditorFn) (*api.DeleteJobWhatWouldItDoResponse, error) {
m.ctrl.T.Helper()
@ -156,6 +196,26 @@ func (mr *MockFlamencoClientMockRecorder) DeleteJobWithResponse(arg0, arg1 inter
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteJobWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).DeleteJobWithResponse), varargs...)
}
// DeleteWorkerClusterWithResponse mocks base method.
func (m *MockFlamencoClient) DeleteWorkerClusterWithResponse(arg0 context.Context, arg1 string, arg2 ...api.RequestEditorFn) (*api.DeleteWorkerClusterResponse, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1}
for _, a := range arg2 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "DeleteWorkerClusterWithResponse", varargs...)
ret0, _ := ret[0].(*api.DeleteWorkerClusterResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DeleteWorkerClusterWithResponse indicates an expected call of DeleteWorkerClusterWithResponse.
func (mr *MockFlamencoClientMockRecorder) DeleteWorkerClusterWithResponse(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0, arg1}, arg2...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkerClusterWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).DeleteWorkerClusterWithResponse), varargs...)
}
// DeleteWorkerWithResponse mocks base method.
func (m *MockFlamencoClient) DeleteWorkerWithResponse(arg0 context.Context, arg1 string, arg2 ...api.RequestEditorFn) (*api.DeleteWorkerResponse, error) {
m.ctrl.T.Helper()
@ -336,6 +396,46 @@ func (mr *MockFlamencoClientMockRecorder) FetchTaskWithResponse(arg0, arg1 inter
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchTaskWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).FetchTaskWithResponse), varargs...)
}
// FetchWorkerClusterWithResponse mocks base method.
func (m *MockFlamencoClient) FetchWorkerClusterWithResponse(arg0 context.Context, arg1 string, arg2 ...api.RequestEditorFn) (*api.FetchWorkerClusterResponse, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1}
for _, a := range arg2 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "FetchWorkerClusterWithResponse", varargs...)
ret0, _ := ret[0].(*api.FetchWorkerClusterResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FetchWorkerClusterWithResponse indicates an expected call of FetchWorkerClusterWithResponse.
func (mr *MockFlamencoClientMockRecorder) FetchWorkerClusterWithResponse(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0, arg1}, arg2...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchWorkerClusterWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).FetchWorkerClusterWithResponse), varargs...)
}
// FetchWorkerClustersWithResponse mocks base method.
func (m *MockFlamencoClient) FetchWorkerClustersWithResponse(arg0 context.Context, arg1 ...api.RequestEditorFn) (*api.FetchWorkerClustersResponse, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "FetchWorkerClustersWithResponse", varargs...)
ret0, _ := ret[0].(*api.FetchWorkerClustersResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FetchWorkerClustersWithResponse indicates an expected call of FetchWorkerClustersWithResponse.
func (mr *MockFlamencoClientMockRecorder) FetchWorkerClustersWithResponse(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchWorkerClustersWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).FetchWorkerClustersWithResponse), varargs...)
}
// FetchWorkerSleepScheduleWithResponse mocks base method.
func (m *MockFlamencoClient) FetchWorkerSleepScheduleWithResponse(arg0 context.Context, arg1 string, arg2 ...api.RequestEditorFn) (*api.FetchWorkerSleepScheduleResponse, error) {
m.ctrl.T.Helper()
@ -916,6 +1016,46 @@ func (mr *MockFlamencoClientMockRecorder) SetTaskStatusWithResponse(arg0, arg1,
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTaskStatusWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).SetTaskStatusWithResponse), varargs...)
}
// SetWorkerClustersWithBodyWithResponse mocks base method.
func (m *MockFlamencoClient) SetWorkerClustersWithBodyWithResponse(arg0 context.Context, arg1, arg2 string, arg3 io.Reader, arg4 ...api.RequestEditorFn) (*api.SetWorkerClustersResponse, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1, arg2, arg3}
for _, a := range arg4 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "SetWorkerClustersWithBodyWithResponse", varargs...)
ret0, _ := ret[0].(*api.SetWorkerClustersResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SetWorkerClustersWithBodyWithResponse indicates an expected call of SetWorkerClustersWithBodyWithResponse.
func (mr *MockFlamencoClientMockRecorder) SetWorkerClustersWithBodyWithResponse(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, "SetWorkerClustersWithBodyWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).SetWorkerClustersWithBodyWithResponse), varargs...)
}
// SetWorkerClustersWithResponse mocks base method.
func (m *MockFlamencoClient) SetWorkerClustersWithResponse(arg0 context.Context, arg1 string, arg2 api.SetWorkerClustersJSONRequestBody, arg3 ...api.RequestEditorFn) (*api.SetWorkerClustersResponse, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1, arg2}
for _, a := range arg3 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "SetWorkerClustersWithResponse", varargs...)
ret0, _ := ret[0].(*api.SetWorkerClustersResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SetWorkerClustersWithResponse indicates an expected call of SetWorkerClustersWithResponse.
func (mr *MockFlamencoClientMockRecorder) SetWorkerClustersWithResponse(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, "SetWorkerClustersWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).SetWorkerClustersWithResponse), varargs...)
}
// SetWorkerSleepScheduleWithBodyWithResponse mocks base method.
func (m *MockFlamencoClient) SetWorkerSleepScheduleWithBodyWithResponse(arg0 context.Context, arg1, arg2 string, arg3 io.Reader, arg4 ...api.RequestEditorFn) (*api.SetWorkerSleepScheduleResponse, error) {
m.ctrl.T.Helper()
@ -1316,6 +1456,46 @@ func (mr *MockFlamencoClientMockRecorder) TaskUpdateWithResponse(arg0, arg1, arg
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TaskUpdateWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).TaskUpdateWithResponse), varargs...)
}
// UpdateWorkerClusterWithBodyWithResponse mocks base method.
func (m *MockFlamencoClient) UpdateWorkerClusterWithBodyWithResponse(arg0 context.Context, arg1, arg2 string, arg3 io.Reader, arg4 ...api.RequestEditorFn) (*api.UpdateWorkerClusterResponse, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1, arg2, arg3}
for _, a := range arg4 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "UpdateWorkerClusterWithBodyWithResponse", varargs...)
ret0, _ := ret[0].(*api.UpdateWorkerClusterResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateWorkerClusterWithBodyWithResponse indicates an expected call of UpdateWorkerClusterWithBodyWithResponse.
func (mr *MockFlamencoClientMockRecorder) UpdateWorkerClusterWithBodyWithResponse(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, "UpdateWorkerClusterWithBodyWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).UpdateWorkerClusterWithBodyWithResponse), varargs...)
}
// UpdateWorkerClusterWithResponse mocks base method.
func (m *MockFlamencoClient) UpdateWorkerClusterWithResponse(arg0 context.Context, arg1 string, arg2 api.UpdateWorkerClusterJSONRequestBody, arg3 ...api.RequestEditorFn) (*api.UpdateWorkerClusterResponse, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1, arg2}
for _, a := range arg3 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "UpdateWorkerClusterWithResponse", varargs...)
ret0, _ := ret[0].(*api.UpdateWorkerClusterResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateWorkerClusterWithResponse indicates an expected call of UpdateWorkerClusterWithResponse.
func (mr *MockFlamencoClientMockRecorder) UpdateWorkerClusterWithResponse(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, "UpdateWorkerClusterWithResponse", reflect.TypeOf((*MockFlamencoClient)(nil).UpdateWorkerClusterWithResponse), varargs...)
}
// WorkerStateChangedWithBodyWithResponse mocks base method.
func (m *MockFlamencoClient) WorkerStateChangedWithBodyWithResponse(arg0 context.Context, arg1 string, arg2 io.Reader, arg3 ...api.RequestEditorFn) (*api.WorkerStateChangedResponse, error) {
m.ctrl.T.Helper()

View File

@ -565,6 +565,33 @@ paths:
schema:
$ref: "#/components/schemas/Error"
/api/v3/worker-mgt/workers/{worker_id}/setclusters:
summary: Update the cluster membership of this Worker.
post:
operationId: setWorkerClusters
tags: [worker-mgt]
parameters:
- name: worker_id
in: path
required: true
schema: { type: string, format: uuid }
requestBody:
description: The list of cluster IDs this worker should be a member of.
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/WorkerClusterChangeRequest"
responses:
"204":
description: Status change was accepted.
default:
description: Unexpected error.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/api/v3/worker-mgt/workers/{worker_id}/sleep-schedule:
summary: Get or update the worker's sleep schedule.
get:
@ -615,6 +642,91 @@ paths:
schema:
$ref: "#/components/schemas/Error"
/api/v3/worker-mgt/clusters:
summary: Manage worker clusters.
get:
operationId: fetchWorkerClusters
summary: Get list of worker clusters.
tags: [worker-mgt]
responses:
"200":
description: Worker clusters.
content:
application/json:
schema: { $ref: "#/components/schemas/WorkerClusterList" }
post:
operationId: createWorkerCluster
summary: Create a new worker cluster.
tags: [worker-mgt]
requestBody:
description: The worker cluster.
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/WorkerCluster"
responses:
"200":
description: The cluster was created. The created cluster is returned, so that the caller can know its UUID.
content:
application/json:
schema: { $ref: "#/components/schemas/WorkerCluster" }
default:
description: Error message
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/api/v3/worker-mgt/cluster/{cluster_id}:
summary: Get, update, or delete a worker cluster.
parameters:
- name: cluster_id
in: path
required: true
schema: { type: string, format: uuid }
get:
operationId: fetchWorkerCluster
summary: Get a single worker cluster.
tags: [worker-mgt]
responses:
"200":
description: The worker cluster.
content:
application/json:
schema: { $ref: "#/components/schemas/WorkerCluster" }
put:
operationId: updateWorkerCluster
summary: Update an existing worker cluster.
tags: [worker-mgt]
requestBody:
description: The updated worker cluster.
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/WorkerCluster"
responses:
"204":
description: The cluster update has been stored.
default:
description: Error message
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
delete:
operationId: deleteWorkerCluster
summary: Remove this worker cluster. This unassigns all workers from the cluster and removes it.
tags: [worker-mgt]
responses:
"204":
description: The cluster has been removed.
default:
description: Unexpected error.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
## Jobs
/api/v3/jobs/types:
@ -1394,6 +1506,12 @@ components:
type: array
items: { type: string }
name: { type: string }
example:
"name": "example-worker"
"secret": "do-not-tell-anyone"
"platform": "linux"
"software": "3.2"
"supported_task_types": ["blender", "ffmpeg", "file-management", "misc"]
RegisteredWorker:
type: object
@ -1681,6 +1799,13 @@ components:
test/debug scripts easier, as they can use a static document on all
platforms.
"storage": { $ref: "#/components/schemas/JobStorageInfo" }
"worker_cluster":
type: string
format: uuid
description: >
Worker Cluster that should execute this job. When a cluster ID is
given, only Workers in that cluster will be scheduled to work on it.
If empty or ommitted, all workers can work on this job.
required: [name, type, priority, submitter_platform]
example:
type: "simple-blender-render"
@ -1794,7 +1919,7 @@ components:
description: Filter by job settings, using `LIKE` notation.
example:
"limit": 5
"order_by": ["updated", "status"]
"order_by": ["updated_at", "status"]
"status_in": ["active", "queued", "failed"]
"metadata": { project: "Sprite Fright" }
@ -2298,6 +2423,10 @@ components:
type: array
items: { type: string }
"task": { $ref: "#/components/schemas/WorkerTask" }
"clusters":
type: array
items: { $ref: "#/components/schemas/WorkerCluster" }
description: Clusters of which this Worker is a member.
required:
- id
- name
@ -2351,6 +2480,47 @@ components:
start_time: "09:00"
end_time: "18:00"
WorkerCluster:
type: object
description: >
Cluster of workers. A job can optionally specify which cluster it should
be limited to. Workers can be part of multiple clusters simultaneously.
properties:
"id":
type: string
format: uuid
description: >
UUID of the cluster. Can be ommitted when creating a new cluster, in
which case a random UUID will be assigned.
"name":
type: string
"description":
type: string
required: [name]
example:
name: GPU-EEVEE
description: All workers that can do GPU rendering with EEVEE.
WorkerClusterList:
type: object
properties:
"clusters":
type: array
items: { $ref: "#/components/schemas/WorkerCluster" }
WorkerClusterChangeRequest:
type: object
description: Request to change which clusters this Worker is assigned to.
properties:
"cluster_ids":
type: array
items:
type: string
format: uuid
required: [cluster_ids]
example:
"cluster_ids": ["4312d68c-ea6d-4566-9bf6-e9f09be48ceb"]
securitySchemes:
worker_auth:
description: Username is the worker ID, password is the secret given at worker registration.

View File

@ -212,6 +212,25 @@ type ClientInterface interface {
// GetVersion request
GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// DeleteWorkerCluster request
DeleteWorkerCluster(ctx context.Context, clusterId string, reqEditors ...RequestEditorFn) (*http.Response, error)
// FetchWorkerCluster request
FetchWorkerCluster(ctx context.Context, clusterId string, reqEditors ...RequestEditorFn) (*http.Response, error)
// UpdateWorkerCluster request with any body
UpdateWorkerClusterWithBody(ctx context.Context, clusterId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
UpdateWorkerCluster(ctx context.Context, clusterId string, body UpdateWorkerClusterJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// FetchWorkerClusters request
FetchWorkerClusters(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// CreateWorkerCluster request with any body
CreateWorkerClusterWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
CreateWorkerCluster(ctx context.Context, body CreateWorkerClusterJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// FetchWorkers request
FetchWorkers(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
@ -221,6 +240,11 @@ type ClientInterface interface {
// FetchWorker request
FetchWorker(ctx context.Context, workerId string, reqEditors ...RequestEditorFn) (*http.Response, error)
// SetWorkerClusters request with any body
SetWorkerClustersWithBody(ctx context.Context, workerId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
SetWorkerClusters(ctx context.Context, workerId string, body SetWorkerClustersJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// RequestWorkerStatusChange request with any body
RequestWorkerStatusChangeWithBody(ctx context.Context, workerId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
@ -803,6 +827,90 @@ func (c *Client) GetVersion(ctx context.Context, reqEditors ...RequestEditorFn)
return c.Client.Do(req)
}
func (c *Client) DeleteWorkerCluster(ctx context.Context, clusterId string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewDeleteWorkerClusterRequest(c.Server, clusterId)
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) FetchWorkerCluster(ctx context.Context, clusterId string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewFetchWorkerClusterRequest(c.Server, clusterId)
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) UpdateWorkerClusterWithBody(ctx context.Context, clusterId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewUpdateWorkerClusterRequestWithBody(c.Server, clusterId, 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) UpdateWorkerCluster(ctx context.Context, clusterId string, body UpdateWorkerClusterJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewUpdateWorkerClusterRequest(c.Server, clusterId, 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) FetchWorkerClusters(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewFetchWorkerClustersRequest(c.Server)
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) CreateWorkerClusterWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewCreateWorkerClusterRequestWithBody(c.Server, 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) CreateWorkerCluster(ctx context.Context, body CreateWorkerClusterJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewCreateWorkerClusterRequest(c.Server, 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) FetchWorkers(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewFetchWorkersRequest(c.Server)
if err != nil {
@ -839,6 +947,30 @@ func (c *Client) FetchWorker(ctx context.Context, workerId string, reqEditors ..
return c.Client.Do(req)
}
func (c *Client) SetWorkerClustersWithBody(ctx context.Context, workerId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewSetWorkerClustersRequestWithBody(c.Server, workerId, 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) SetWorkerClusters(ctx context.Context, workerId string, body SetWorkerClustersJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewSetWorkerClustersRequest(c.Server, workerId, 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) RequestWorkerStatusChangeWithBody(ctx context.Context, workerId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewRequestWorkerStatusChangeRequestWithBody(c.Server, workerId, contentType, body)
if err != nil {
@ -2277,6 +2409,188 @@ func NewGetVersionRequest(server string) (*http.Request, error) {
return req, nil
}
// NewDeleteWorkerClusterRequest generates requests for DeleteWorkerCluster
func NewDeleteWorkerClusterRequest(server string, clusterId string) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithLocation("simple", false, "cluster_id", runtime.ParamLocationPath, clusterId)
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/v3/worker-mgt/cluster/%s", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest("DELETE", queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewFetchWorkerClusterRequest generates requests for FetchWorkerCluster
func NewFetchWorkerClusterRequest(server string, clusterId string) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithLocation("simple", false, "cluster_id", runtime.ParamLocationPath, clusterId)
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/v3/worker-mgt/cluster/%s", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest("GET", queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewUpdateWorkerClusterRequest calls the generic UpdateWorkerCluster builder with application/json body
func NewUpdateWorkerClusterRequest(server string, clusterId string, body UpdateWorkerClusterJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewUpdateWorkerClusterRequestWithBody(server, clusterId, "application/json", bodyReader)
}
// NewUpdateWorkerClusterRequestWithBody generates requests for UpdateWorkerCluster with any type of body
func NewUpdateWorkerClusterRequestWithBody(server string, clusterId string, contentType string, body io.Reader) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithLocation("simple", false, "cluster_id", runtime.ParamLocationPath, clusterId)
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/v3/worker-mgt/cluster/%s", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest("PUT", queryURL.String(), body)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", contentType)
return req, nil
}
// NewFetchWorkerClustersRequest generates requests for FetchWorkerClusters
func NewFetchWorkerClustersRequest(server string) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/v3/worker-mgt/clusters")
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest("GET", queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewCreateWorkerClusterRequest calls the generic CreateWorkerCluster builder with application/json body
func NewCreateWorkerClusterRequest(server string, body CreateWorkerClusterJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewCreateWorkerClusterRequestWithBody(server, "application/json", bodyReader)
}
// NewCreateWorkerClusterRequestWithBody generates requests for CreateWorkerCluster with any type of body
func NewCreateWorkerClusterRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/v3/worker-mgt/clusters")
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
}
// NewFetchWorkersRequest generates requests for FetchWorkers
func NewFetchWorkersRequest(server string) (*http.Request, error) {
var err error
@ -2372,6 +2686,53 @@ func NewFetchWorkerRequest(server string, workerId string) (*http.Request, error
return req, nil
}
// NewSetWorkerClustersRequest calls the generic SetWorkerClusters builder with application/json body
func NewSetWorkerClustersRequest(server string, workerId string, body SetWorkerClustersJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewSetWorkerClustersRequestWithBody(server, workerId, "application/json", bodyReader)
}
// NewSetWorkerClustersRequestWithBody generates requests for SetWorkerClusters with any type of body
func NewSetWorkerClustersRequestWithBody(server string, workerId string, contentType string, body io.Reader) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithLocation("simple", false, "worker_id", runtime.ParamLocationPath, workerId)
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/v3/worker-mgt/workers/%s/setclusters", 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
}
// NewRequestWorkerStatusChangeRequest calls the generic RequestWorkerStatusChange builder with application/json body
func NewRequestWorkerStatusChangeRequest(server string, workerId string, body RequestWorkerStatusChangeJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
@ -3028,6 +3389,25 @@ type ClientWithResponsesInterface interface {
// GetVersion request
GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error)
// DeleteWorkerCluster request
DeleteWorkerClusterWithResponse(ctx context.Context, clusterId string, reqEditors ...RequestEditorFn) (*DeleteWorkerClusterResponse, error)
// FetchWorkerCluster request
FetchWorkerClusterWithResponse(ctx context.Context, clusterId string, reqEditors ...RequestEditorFn) (*FetchWorkerClusterResponse, error)
// UpdateWorkerCluster request with any body
UpdateWorkerClusterWithBodyWithResponse(ctx context.Context, clusterId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateWorkerClusterResponse, error)
UpdateWorkerClusterWithResponse(ctx context.Context, clusterId string, body UpdateWorkerClusterJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateWorkerClusterResponse, error)
// FetchWorkerClusters request
FetchWorkerClustersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*FetchWorkerClustersResponse, error)
// CreateWorkerCluster request with any body
CreateWorkerClusterWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateWorkerClusterResponse, error)
CreateWorkerClusterWithResponse(ctx context.Context, body CreateWorkerClusterJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateWorkerClusterResponse, error)
// FetchWorkers request
FetchWorkersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*FetchWorkersResponse, error)
@ -3037,6 +3417,11 @@ type ClientWithResponsesInterface interface {
// FetchWorker request
FetchWorkerWithResponse(ctx context.Context, workerId string, reqEditors ...RequestEditorFn) (*FetchWorkerResponse, error)
// SetWorkerClusters request with any body
SetWorkerClustersWithBodyWithResponse(ctx context.Context, workerId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetWorkerClustersResponse, error)
SetWorkerClustersWithResponse(ctx context.Context, workerId string, body SetWorkerClustersJSONRequestBody, reqEditors ...RequestEditorFn) (*SetWorkerClustersResponse, error)
// RequestWorkerStatusChange request with any body
RequestWorkerStatusChangeWithBodyWithResponse(ctx context.Context, workerId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestWorkerStatusChangeResponse, error)
@ -3814,6 +4199,117 @@ func (r GetVersionResponse) StatusCode() int {
return 0
}
type DeleteWorkerClusterResponse struct {
Body []byte
HTTPResponse *http.Response
JSONDefault *Error
}
// Status returns HTTPResponse.Status
func (r DeleteWorkerClusterResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r DeleteWorkerClusterResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type FetchWorkerClusterResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *WorkerCluster
}
// Status returns HTTPResponse.Status
func (r FetchWorkerClusterResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r FetchWorkerClusterResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type UpdateWorkerClusterResponse struct {
Body []byte
HTTPResponse *http.Response
JSONDefault *Error
}
// Status returns HTTPResponse.Status
func (r UpdateWorkerClusterResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r UpdateWorkerClusterResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type FetchWorkerClustersResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *WorkerClusterList
}
// Status returns HTTPResponse.Status
func (r FetchWorkerClustersResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r FetchWorkerClustersResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type CreateWorkerClusterResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *WorkerCluster
JSONDefault *Error
}
// Status returns HTTPResponse.Status
func (r CreateWorkerClusterResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r CreateWorkerClusterResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type FetchWorkersResponse struct {
Body []byte
HTTPResponse *http.Response
@ -3880,6 +4376,28 @@ func (r FetchWorkerResponse) StatusCode() int {
return 0
}
type SetWorkerClustersResponse struct {
Body []byte
HTTPResponse *http.Response
JSONDefault *Error
}
// Status returns HTTPResponse.Status
func (r SetWorkerClustersResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r SetWorkerClustersResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type RequestWorkerStatusChangeResponse struct {
Body []byte
HTTPResponse *http.Response
@ -4561,6 +5079,67 @@ func (c *ClientWithResponses) GetVersionWithResponse(ctx context.Context, reqEdi
return ParseGetVersionResponse(rsp)
}
// DeleteWorkerClusterWithResponse request returning *DeleteWorkerClusterResponse
func (c *ClientWithResponses) DeleteWorkerClusterWithResponse(ctx context.Context, clusterId string, reqEditors ...RequestEditorFn) (*DeleteWorkerClusterResponse, error) {
rsp, err := c.DeleteWorkerCluster(ctx, clusterId, reqEditors...)
if err != nil {
return nil, err
}
return ParseDeleteWorkerClusterResponse(rsp)
}
// FetchWorkerClusterWithResponse request returning *FetchWorkerClusterResponse
func (c *ClientWithResponses) FetchWorkerClusterWithResponse(ctx context.Context, clusterId string, reqEditors ...RequestEditorFn) (*FetchWorkerClusterResponse, error) {
rsp, err := c.FetchWorkerCluster(ctx, clusterId, reqEditors...)
if err != nil {
return nil, err
}
return ParseFetchWorkerClusterResponse(rsp)
}
// UpdateWorkerClusterWithBodyWithResponse request with arbitrary body returning *UpdateWorkerClusterResponse
func (c *ClientWithResponses) UpdateWorkerClusterWithBodyWithResponse(ctx context.Context, clusterId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateWorkerClusterResponse, error) {
rsp, err := c.UpdateWorkerClusterWithBody(ctx, clusterId, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseUpdateWorkerClusterResponse(rsp)
}
func (c *ClientWithResponses) UpdateWorkerClusterWithResponse(ctx context.Context, clusterId string, body UpdateWorkerClusterJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateWorkerClusterResponse, error) {
rsp, err := c.UpdateWorkerCluster(ctx, clusterId, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseUpdateWorkerClusterResponse(rsp)
}
// FetchWorkerClustersWithResponse request returning *FetchWorkerClustersResponse
func (c *ClientWithResponses) FetchWorkerClustersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*FetchWorkerClustersResponse, error) {
rsp, err := c.FetchWorkerClusters(ctx, reqEditors...)
if err != nil {
return nil, err
}
return ParseFetchWorkerClustersResponse(rsp)
}
// CreateWorkerClusterWithBodyWithResponse request with arbitrary body returning *CreateWorkerClusterResponse
func (c *ClientWithResponses) CreateWorkerClusterWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateWorkerClusterResponse, error) {
rsp, err := c.CreateWorkerClusterWithBody(ctx, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseCreateWorkerClusterResponse(rsp)
}
func (c *ClientWithResponses) CreateWorkerClusterWithResponse(ctx context.Context, body CreateWorkerClusterJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateWorkerClusterResponse, error) {
rsp, err := c.CreateWorkerCluster(ctx, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseCreateWorkerClusterResponse(rsp)
}
// FetchWorkersWithResponse request returning *FetchWorkersResponse
func (c *ClientWithResponses) FetchWorkersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*FetchWorkersResponse, error) {
rsp, err := c.FetchWorkers(ctx, reqEditors...)
@ -4588,6 +5167,23 @@ func (c *ClientWithResponses) FetchWorkerWithResponse(ctx context.Context, worke
return ParseFetchWorkerResponse(rsp)
}
// SetWorkerClustersWithBodyWithResponse request with arbitrary body returning *SetWorkerClustersResponse
func (c *ClientWithResponses) SetWorkerClustersWithBodyWithResponse(ctx context.Context, workerId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetWorkerClustersResponse, error) {
rsp, err := c.SetWorkerClustersWithBody(ctx, workerId, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseSetWorkerClustersResponse(rsp)
}
func (c *ClientWithResponses) SetWorkerClustersWithResponse(ctx context.Context, workerId string, body SetWorkerClustersJSONRequestBody, reqEditors ...RequestEditorFn) (*SetWorkerClustersResponse, error) {
rsp, err := c.SetWorkerClusters(ctx, workerId, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseSetWorkerClustersResponse(rsp)
}
// RequestWorkerStatusChangeWithBodyWithResponse request with arbitrary body returning *RequestWorkerStatusChangeResponse
func (c *ClientWithResponses) RequestWorkerStatusChangeWithBodyWithResponse(ctx context.Context, workerId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestWorkerStatusChangeResponse, error) {
rsp, err := c.RequestWorkerStatusChangeWithBody(ctx, workerId, contentType, body, reqEditors...)
@ -5714,6 +6310,143 @@ func ParseGetVersionResponse(rsp *http.Response) (*GetVersionResponse, error) {
return response, nil
}
// ParseDeleteWorkerClusterResponse parses an HTTP response from a DeleteWorkerClusterWithResponse call
func ParseDeleteWorkerClusterResponse(rsp *http.Response) (*DeleteWorkerClusterResponse, error) {
bodyBytes, err := ioutil.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &DeleteWorkerClusterResponse{
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
}
// ParseFetchWorkerClusterResponse parses an HTTP response from a FetchWorkerClusterWithResponse call
func ParseFetchWorkerClusterResponse(rsp *http.Response) (*FetchWorkerClusterResponse, error) {
bodyBytes, err := ioutil.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &FetchWorkerClusterResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest WorkerCluster
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParseUpdateWorkerClusterResponse parses an HTTP response from a UpdateWorkerClusterWithResponse call
func ParseUpdateWorkerClusterResponse(rsp *http.Response) (*UpdateWorkerClusterResponse, error) {
bodyBytes, err := ioutil.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &UpdateWorkerClusterResponse{
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
}
// ParseFetchWorkerClustersResponse parses an HTTP response from a FetchWorkerClustersWithResponse call
func ParseFetchWorkerClustersResponse(rsp *http.Response) (*FetchWorkerClustersResponse, error) {
bodyBytes, err := ioutil.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &FetchWorkerClustersResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest WorkerClusterList
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParseCreateWorkerClusterResponse parses an HTTP response from a CreateWorkerClusterWithResponse call
func ParseCreateWorkerClusterResponse(rsp *http.Response) (*CreateWorkerClusterResponse, error) {
bodyBytes, err := ioutil.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &CreateWorkerClusterResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest WorkerCluster
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
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
}
// ParseFetchWorkersResponse parses an HTTP response from a FetchWorkersWithResponse call
func ParseFetchWorkersResponse(rsp *http.Response) (*FetchWorkersResponse, error) {
bodyBytes, err := ioutil.ReadAll(rsp.Body)
@ -5792,6 +6525,32 @@ func ParseFetchWorkerResponse(rsp *http.Response) (*FetchWorkerResponse, error)
return response, nil
}
// ParseSetWorkerClustersResponse parses an HTTP response from a SetWorkerClustersWithResponse call
func ParseSetWorkerClustersResponse(rsp *http.Response) (*SetWorkerClustersResponse, error) {
bodyBytes, err := ioutil.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &SetWorkerClustersResponse{
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
}
// ParseRequestWorkerStatusChangeResponse parses an HTTP response from a RequestWorkerStatusChangeWithResponse call
func ParseRequestWorkerStatusChangeResponse(rsp *http.Response) (*RequestWorkerStatusChangeResponse, error) {
bodyBytes, err := ioutil.ReadAll(rsp.Body)

View File

@ -110,6 +110,21 @@ type ServerInterface interface {
// Get the Flamenco version of this Manager
// (GET /api/v3/version)
GetVersion(ctx echo.Context) error
// Remove this worker cluster. This unassigns all workers from the cluster and removes it.
// (DELETE /api/v3/worker-mgt/cluster/{cluster_id})
DeleteWorkerCluster(ctx echo.Context, clusterId string) error
// Get a single worker cluster.
// (GET /api/v3/worker-mgt/cluster/{cluster_id})
FetchWorkerCluster(ctx echo.Context, clusterId string) error
// Update an existing worker cluster.
// (PUT /api/v3/worker-mgt/cluster/{cluster_id})
UpdateWorkerCluster(ctx echo.Context, clusterId string) error
// Get list of worker clusters.
// (GET /api/v3/worker-mgt/clusters)
FetchWorkerClusters(ctx echo.Context) error
// Create a new worker cluster.
// (POST /api/v3/worker-mgt/clusters)
CreateWorkerCluster(ctx echo.Context) error
// Get list of workers.
// (GET /api/v3/worker-mgt/workers)
FetchWorkers(ctx echo.Context) error
@ -120,6 +135,9 @@ type ServerInterface interface {
// (GET /api/v3/worker-mgt/workers/{worker_id})
FetchWorker(ctx echo.Context, workerId string) error
// (POST /api/v3/worker-mgt/workers/{worker_id}/setclusters)
SetWorkerClusters(ctx echo.Context, workerId string) error
// (POST /api/v3/worker-mgt/workers/{worker_id}/setstatus)
RequestWorkerStatusChange(ctx echo.Context, workerId string) error
@ -646,6 +664,72 @@ func (w *ServerInterfaceWrapper) GetVersion(ctx echo.Context) error {
return err
}
// DeleteWorkerCluster converts echo context to params.
func (w *ServerInterfaceWrapper) DeleteWorkerCluster(ctx echo.Context) error {
var err error
// ------------- Path parameter "cluster_id" -------------
var clusterId string
err = runtime.BindStyledParameterWithLocation("simple", false, "cluster_id", runtime.ParamLocationPath, ctx.Param("cluster_id"), &clusterId)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter cluster_id: %s", err))
}
// Invoke the callback with all the unmarshalled arguments
err = w.Handler.DeleteWorkerCluster(ctx, clusterId)
return err
}
// FetchWorkerCluster converts echo context to params.
func (w *ServerInterfaceWrapper) FetchWorkerCluster(ctx echo.Context) error {
var err error
// ------------- Path parameter "cluster_id" -------------
var clusterId string
err = runtime.BindStyledParameterWithLocation("simple", false, "cluster_id", runtime.ParamLocationPath, ctx.Param("cluster_id"), &clusterId)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter cluster_id: %s", err))
}
// Invoke the callback with all the unmarshalled arguments
err = w.Handler.FetchWorkerCluster(ctx, clusterId)
return err
}
// UpdateWorkerCluster converts echo context to params.
func (w *ServerInterfaceWrapper) UpdateWorkerCluster(ctx echo.Context) error {
var err error
// ------------- Path parameter "cluster_id" -------------
var clusterId string
err = runtime.BindStyledParameterWithLocation("simple", false, "cluster_id", runtime.ParamLocationPath, ctx.Param("cluster_id"), &clusterId)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter cluster_id: %s", err))
}
// Invoke the callback with all the unmarshalled arguments
err = w.Handler.UpdateWorkerCluster(ctx, clusterId)
return err
}
// FetchWorkerClusters converts echo context to params.
func (w *ServerInterfaceWrapper) FetchWorkerClusters(ctx echo.Context) error {
var err error
// Invoke the callback with all the unmarshalled arguments
err = w.Handler.FetchWorkerClusters(ctx)
return err
}
// CreateWorkerCluster converts echo context to params.
func (w *ServerInterfaceWrapper) CreateWorkerCluster(ctx echo.Context) error {
var err error
// Invoke the callback with all the unmarshalled arguments
err = w.Handler.CreateWorkerCluster(ctx)
return err
}
// FetchWorkers converts echo context to params.
func (w *ServerInterfaceWrapper) FetchWorkers(ctx echo.Context) error {
var err error
@ -687,6 +771,22 @@ func (w *ServerInterfaceWrapper) FetchWorker(ctx echo.Context) error {
return err
}
// SetWorkerClusters converts echo context to params.
func (w *ServerInterfaceWrapper) SetWorkerClusters(ctx echo.Context) error {
var err error
// ------------- Path parameter "worker_id" -------------
var workerId string
err = runtime.BindStyledParameterWithLocation("simple", false, "worker_id", runtime.ParamLocationPath, ctx.Param("worker_id"), &workerId)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter worker_id: %s", err))
}
// Invoke the callback with all the unmarshalled arguments
err = w.Handler.SetWorkerClusters(ctx, workerId)
return err
}
// RequestWorkerStatusChange converts echo context to params.
func (w *ServerInterfaceWrapper) RequestWorkerStatusChange(ctx echo.Context) error {
var err error
@ -931,9 +1031,15 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
router.GET(baseURL+"/api/v3/tasks/:task_id/logtail", wrapper.FetchTaskLogTail)
router.POST(baseURL+"/api/v3/tasks/:task_id/setstatus", wrapper.SetTaskStatus)
router.GET(baseURL+"/api/v3/version", wrapper.GetVersion)
router.DELETE(baseURL+"/api/v3/worker-mgt/cluster/:cluster_id", wrapper.DeleteWorkerCluster)
router.GET(baseURL+"/api/v3/worker-mgt/cluster/:cluster_id", wrapper.FetchWorkerCluster)
router.PUT(baseURL+"/api/v3/worker-mgt/cluster/:cluster_id", wrapper.UpdateWorkerCluster)
router.GET(baseURL+"/api/v3/worker-mgt/clusters", wrapper.FetchWorkerClusters)
router.POST(baseURL+"/api/v3/worker-mgt/clusters", wrapper.CreateWorkerCluster)
router.GET(baseURL+"/api/v3/worker-mgt/workers", wrapper.FetchWorkers)
router.DELETE(baseURL+"/api/v3/worker-mgt/workers/:worker_id", wrapper.DeleteWorker)
router.GET(baseURL+"/api/v3/worker-mgt/workers/:worker_id", wrapper.FetchWorker)
router.POST(baseURL+"/api/v3/worker-mgt/workers/:worker_id/setclusters", wrapper.SetWorkerClusters)
router.POST(baseURL+"/api/v3/worker-mgt/workers/:worker_id/setstatus", wrapper.RequestWorkerStatusChange)
router.GET(baseURL+"/api/v3/worker-mgt/workers/:worker_id/sleep-schedule", wrapper.FetchWorkerSleepSchedule)
router.POST(baseURL+"/api/v3/worker-mgt/workers/:worker_id/sleep-schedule", wrapper.SetWorkerSleepSchedule)

View File

@ -18,208 +18,220 @@ import (
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"53C5gh9/+/j/AgAA//9+ZK/OI/YAAA==",
"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",
}
// GetSwagger returns the content of the embedded swagger specification file

View File

@ -648,6 +648,9 @@ type SubmittedJob struct {
// Hash of the job type, copied from the `AvailableJobType.etag` property of the job type. The job will be rejected if this field doesn't match the actual job type on the Manager. This prevents job submission with old settings, after the job compiler script has been updated.
// If this field is ommitted, the check is bypassed.
TypeEtag *string `json:"type_etag,omitempty"`
// Worker Cluster that should execute this job. When a cluster ID is given, only Workers in that cluster will be scheduled to work on it. If empty or ommitted, all workers can work on this job.
WorkerCluster *string `json:"worker_cluster,omitempty"`
}
// The task as it exists in the Manager database, i.e. before variable replacement.
@ -735,6 +738,9 @@ type Worker struct {
// Embedded struct due to allOf(#/components/schemas/WorkerSummary)
WorkerSummary `yaml:",inline"`
// Embedded fields due to inline allOf schema
// Clusters of which this Worker is a member.
Clusters *[]WorkerCluster `json:"clusters,omitempty"`
// IP address of the Worker
IpAddress string `json:"ip_address"`
@ -746,6 +752,25 @@ type Worker struct {
Task *WorkerTask `json:"task,omitempty"`
}
// Cluster of workers. A job can optionally specify which cluster it should be limited to. Workers can be part of multiple clusters simultaneously.
type WorkerCluster struct {
Description *string `json:"description,omitempty"`
// UUID of the cluster. Can be ommitted when creating a new cluster, in which case a random UUID will be assigned.
Id *string `json:"id,omitempty"`
Name string `json:"name"`
}
// Request to change which clusters this Worker is assigned to.
type WorkerClusterChangeRequest struct {
ClusterIds []string `json:"cluster_ids"`
}
// WorkerClusterList defines model for WorkerClusterList.
type WorkerClusterList struct {
Clusters *[]WorkerCluster `json:"clusters,omitempty"`
}
// List of workers.
type WorkerList struct {
Workers []WorkerSummary `json:"workers"`
@ -865,6 +890,15 @@ type ShamanFileStoreParams struct {
// SetTaskStatusJSONBody defines parameters for SetTaskStatus.
type SetTaskStatusJSONBody TaskStatusChange
// UpdateWorkerClusterJSONBody defines parameters for UpdateWorkerCluster.
type UpdateWorkerClusterJSONBody WorkerCluster
// CreateWorkerClusterJSONBody defines parameters for CreateWorkerCluster.
type CreateWorkerClusterJSONBody WorkerCluster
// SetWorkerClustersJSONBody defines parameters for SetWorkerClusters.
type SetWorkerClustersJSONBody WorkerClusterChangeRequest
// RequestWorkerStatusChangeJSONBody defines parameters for RequestWorkerStatusChange.
type RequestWorkerStatusChangeJSONBody WorkerStatusChangeRequest
@ -922,6 +956,15 @@ type ShamanCheckoutRequirementsJSONRequestBody ShamanCheckoutRequirementsJSONBod
// SetTaskStatusJSONRequestBody defines body for SetTaskStatus for application/json ContentType.
type SetTaskStatusJSONRequestBody SetTaskStatusJSONBody
// UpdateWorkerClusterJSONRequestBody defines body for UpdateWorkerCluster for application/json ContentType.
type UpdateWorkerClusterJSONRequestBody UpdateWorkerClusterJSONBody
// CreateWorkerClusterJSONRequestBody defines body for CreateWorkerCluster for application/json ContentType.
type CreateWorkerClusterJSONRequestBody CreateWorkerClusterJSONBody
// SetWorkerClustersJSONRequestBody defines body for SetWorkerClusters for application/json ContentType.
type SetWorkerClustersJSONRequestBody SetWorkerClustersJSONBody
// RequestWorkerStatusChangeJSONRequestBody defines body for RequestWorkerStatusChange for application/json ContentType.
type RequestWorkerStatusChangeJSONRequestBody RequestWorkerStatusChangeJSONBody

View File

@ -7,6 +7,7 @@
*/
const flashAfterCopyDuration = 150;
/**
* Copy the inner text of an element to the clipboard.
*
@ -14,9 +15,24 @@ const flashAfterCopyDuration = 150;
*/
export function copyElementText(clickEvent) {
const sourceElement = clickEvent.target;
copyElementValue(sourceElement, sourceElement.innerText);
}
/**
* Copy the inner text of an element to the clipboard.
*
* @param {Event } clickEvent the click event that triggered this function call.
*/
export function copyElementData(clickEvent) {
const sourceElement = clickEvent.target;
window.sourceElement = sourceElement;
copyElementValue(sourceElement, sourceElement.dataset.clipboard);
}
function copyElementValue(sourceElement, value) {
const inputElement = document.createElement("input");
document.body.appendChild(inputElement);
inputElement.setAttribute("value", sourceElement.innerText);
inputElement.setAttribute("value", value);
inputElement.select();
// Note that the `navigator.clipboard` interface is only available when using
@ -27,7 +43,6 @@ export function copyElementText(clickEvent) {
document.execCommand("copy");
document.body.removeChild(inputElement);
flashElement(sourceElement);
}

View File

@ -1,5 +1,5 @@
<template>
<label>
<label :title="title">
<span class="switch">
<input type="checkbox" :checked="isChecked" @change="$emit('switchToggle')">
<span class="slider round"></span>
@ -9,7 +9,7 @@
</template>
<script setup>
const props = defineProps(['isChecked', 'label']);
const props = defineProps(['isChecked', 'label', 'title']);
</script>
<style scoped>

View File

@ -32,6 +32,14 @@
<dt class="field-name" title="ID">ID</dt>
<dd><span @click="copyElementText" class="click-to-copy">{{ jobData.id }}</span></dd>
<template v-if="workerCluster">
<!-- TODO: fetch cluster name and show that instead, and allow editing of the cluster. -->
<dt class="field-name" title="Worker Cluster">Cluster</dt>
<dd :title="workerCluster.description"><span @click="copyElementData" class="click-to-copy"
:data-clipboard="workerCluster.id">{{
workerCluster.name }}</span></dd>
</template>
<dt class="field-name" title="Name">Name</dt>
<dd>{{ jobData.name }}</dd>
@ -82,7 +90,8 @@ import Blocklist from './Blocklist.vue'
import TabItem from '@/components/TabItem.vue'
import TabsWrapper from '@/components/TabsWrapper.vue'
import PopoverEditableJobPriority from '@/components/PopoverEditableJobPriority.vue'
import { copyElementText } from '@/clipboard';
import { copyElementText, copyElementData } from '@/clipboard';
import { useWorkers } from '@/stores/workers'
export default {
props: [
@ -102,11 +111,13 @@ export default {
return {
datetime: datetime, // So that the template can access it.
copyElementText: copyElementText,
copyElementData: copyElementData,
simpleSettings: null, // Object with filtered job settings, or null if there is no job.
jobsApi: new API.JobsApi(getAPIClient()),
jobType: null, // API.AvailableJobType object for the current job type.
jobTypeSettings: null, // Mapping from setting key to its definition in the job type.
showAllSettings: false,
workers: useWorkers(),
};
},
mounted() {
@ -116,6 +127,12 @@ export default {
if (!objectEmpty(this.jobData)) {
this._refreshJobSettings(this.jobData);
}
this.workers.refreshClusters()
.catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
this.notifs.add(`Error: ${errorMsg}`);
});
},
computed: {
hasJobData() {
@ -139,6 +156,10 @@ export default {
}
return this.jobData.settings;
},
workerCluster() {
if (!this.jobData.worker_cluster) return undefined;
return this.workers.clustersByID[this.jobData.worker_cluster];
},
},
watch: {
jobData(newJobData) {

View File

@ -34,6 +34,23 @@
</dd>
</dl>
<section class="worker-clusters" v-if="workers.clusters && workers.clusters.length">
<h3 class="sub-title">Clusters</h3>
<ul>
<li v-for="cluster in workers.clusters">
<switch-checkbox :isChecked="thisWorkerClusters[cluster.id]" :label="cluster.name" :title="cluster.description"
@switch-toggle="toggleWorkerCluster(cluster.id)">
</switch-checkbox>
</li>
</ul>
<p class="hint" v-if="hasClustersAssigned">
This worker will only pick up jobs assigned to one of its clusters, and clusterless jobs.
</p>
<p class="hint" v-else>
This worker will only pick up clusterless jobs.
</p>
</section>
<section class="sleep-schedule" :class="{ 'is-schedule-active': workerSleepSchedule.is_active }">
<h3 class="sub-title">
<switch-checkbox :isChecked="workerSleepSchedule.is_active" @switch-toggle="toggleWorkerSleepSchedule">
@ -120,9 +137,10 @@
<script>
import { useNotifs } from '@/stores/notifications'
import { useWorkers } from '@/stores/workers'
import * as datetime from "@/datetime";
import { WorkerMgtApi, WorkerSleepSchedule } from '@/manager-api';
import { WorkerMgtApi, WorkerSleepSchedule, WorkerClusterChangeRequest } from '@/manager-api';
import { getAPIClient } from "@/api-client";
import { workerStatus } from "../../statusindicator";
import LinkWorkerTask from '@/components/LinkWorkerTask.vue';
@ -146,11 +164,19 @@ export default {
isScheduleEditing: false,
notifs: useNotifs(),
copyElementText: copyElementText,
workers: useWorkers(),
thisWorkerClusters: {}, // Mapping from UUID to 'isAssigned' boolean.
};
},
mounted() {
// Allow testing from the JS console:
window.workerDetailsVue = this;
this.workers.refreshClusters()
.catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
this.notifs.add(`Error: ${errorMsg}`);
});
},
watch: {
workerData(newData, oldData) {
@ -164,6 +190,8 @@ export default {
if (((oldData && newData) && (oldData.id != newData.id)) || !oldData && newData) {
this.fetchWorkerSleepSchedule();
}
this.updateThisWorkerClusters(newData);
},
},
computed: {
@ -182,6 +210,10 @@ export default {
workerSleepScheduleStatusLabel() {
return this.workerSleepSchedule.is_active ? 'Enabled' : 'Disabled';
},
hasClustersAssigned() {
const clusterIDs = this.getAssignedClusterIDs();
return clusterIDs && clusterIDs.length > 0;
}
},
methods: {
fetchWorkerSleepSchedule() {
@ -230,6 +262,45 @@ export default {
}
this.api.deleteWorker(this.workerData.id);
},
updateThisWorkerClusters(newWorkerData) {
if (!newWorkerData || !newWorkerData.clusters) {
this.thisWorkerClusters = {};
return;
}
const assignedClusters = newWorkerData.clusters.reduce(
(accu, cluster) => { accu[cluster.id] = true; return accu; },
{});
this.thisWorkerClusters = assignedClusters;
},
toggleWorkerCluster(clusterID) {
console.log("Toggled", clusterID);
this.thisWorkerClusters[clusterID] = !this.thisWorkerClusters[clusterID];
console.log("New assignment:", plain(this.thisWorkerClusters))
// Construct cluster change request.
const clusterIDs = this.getAssignedClusterIDs();
const changeRequest = new WorkerClusterChangeRequest(clusterIDs);
// Send to the Manager.
this.api.setWorkerClusters(this.workerData.id, changeRequest)
.then(() => {
this.notifs.add('Cluster assignment updated');
})
.catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
this.notifs.add(`Error: ${errorMsg}`);
});
},
getAssignedClusterIDs() {
const clusterIDs = [];
for (let clusterID in this.thisWorkerClusters) {
// Values can exist and be set to 'false'.
const isAssigned = this.thisWorkerClusters[clusterID];
if (isAssigned) clusterIDs.push(clusterID);
}
return clusterIDs;
}
}
};
</script>
@ -305,4 +376,12 @@ export default {
text-overflow: ellipsis;
white-space: nowrap;
}
.worker-clusters ul {
list-style: none;
}
.worker-clusters ul li {
margin-bottom: 0.25rem;
}
</style>

View File

@ -55,7 +55,7 @@ class ApiClient {
* @default {}
*/
this.defaultHeaders = {
'User-Agent': 'Flamenco/3.2 / webbrowser'
'User-Agent': 'Flamenco/3.3-alpha0 / webbrowser'
};
/**

View File

@ -75,6 +75,9 @@ import TaskUpdate from './model/TaskUpdate';
import TaskWorker from './model/TaskWorker';
import Worker from './model/Worker';
import WorkerAllOf from './model/WorkerAllOf';
import WorkerCluster from './model/WorkerCluster';
import WorkerClusterChangeRequest from './model/WorkerClusterChangeRequest';
import WorkerClusterList from './model/WorkerClusterList';
import WorkerList from './model/WorkerList';
import WorkerRegistration from './model/WorkerRegistration';
import WorkerSignOn from './model/WorkerSignOn';
@ -503,6 +506,24 @@ export {
*/
WorkerAllOf,
/**
* The WorkerCluster model constructor.
* @property {module:model/WorkerCluster}
*/
WorkerCluster,
/**
* The WorkerClusterChangeRequest model constructor.
* @property {module:model/WorkerClusterChangeRequest}
*/
WorkerClusterChangeRequest,
/**
* The WorkerClusterList model constructor.
* @property {module:model/WorkerClusterList}
*/
WorkerClusterList,
/**
* The WorkerList model constructor.
* @property {module:model/WorkerList}

View File

@ -15,6 +15,9 @@
import ApiClient from "../ApiClient";
import Error from '../model/Error';
import Worker from '../model/Worker';
import WorkerCluster from '../model/WorkerCluster';
import WorkerClusterChangeRequest from '../model/WorkerClusterChangeRequest';
import WorkerClusterList from '../model/WorkerClusterList';
import WorkerList from '../model/WorkerList';
import WorkerSleepSchedule from '../model/WorkerSleepSchedule';
import WorkerStatusChangeRequest from '../model/WorkerStatusChangeRequest';
@ -39,6 +42,51 @@ export default class WorkerMgtApi {
/**
* Create a new worker cluster.
* @param {module:model/WorkerCluster} workerCluster The worker cluster.
* @return {Promise} a {@link https://www.promisejs.org/|Promise}, with an object containing data of type {@link module:model/WorkerCluster} and HTTP response
*/
createWorkerClusterWithHttpInfo(workerCluster) {
let postBody = workerCluster;
// verify the required parameter 'workerCluster' is set
if (workerCluster === undefined || workerCluster === null) {
throw new Error("Missing the required parameter 'workerCluster' when calling createWorkerCluster");
}
let pathParams = {
};
let queryParams = {
};
let headerParams = {
};
let formParams = {
};
let authNames = [];
let contentTypes = ['application/json'];
let accepts = ['application/json'];
let returnType = WorkerCluster;
return this.apiClient.callApi(
'/api/v3/worker-mgt/clusters', 'POST',
pathParams, queryParams, headerParams, formParams, postBody,
authNames, contentTypes, accepts, returnType, null
);
}
/**
* Create a new worker cluster.
* @param {module:model/WorkerCluster} workerCluster The worker cluster.
* @return {Promise} a {@link https://www.promisejs.org/|Promise}, with data of type {@link module:model/WorkerCluster}
*/
createWorkerCluster(workerCluster) {
return this.createWorkerClusterWithHttpInfo(workerCluster)
.then(function(response_and_data) {
return response_and_data.data;
});
}
/**
* Remove the given worker. It is recommended to only call this function when the worker is in `offline` state. If the worker is still running, stop it first. Any task still assigned to the worker will be requeued.
* @param {String} workerId
@ -85,6 +133,52 @@ export default class WorkerMgtApi {
}
/**
* Remove this worker cluster. This unassigns all workers from the cluster and removes it.
* @param {String} clusterId
* @return {Promise} a {@link https://www.promisejs.org/|Promise}, with an object containing HTTP response
*/
deleteWorkerClusterWithHttpInfo(clusterId) {
let postBody = null;
// verify the required parameter 'clusterId' is set
if (clusterId === undefined || clusterId === null) {
throw new Error("Missing the required parameter 'clusterId' when calling deleteWorkerCluster");
}
let pathParams = {
'cluster_id': clusterId
};
let queryParams = {
};
let headerParams = {
};
let formParams = {
};
let authNames = [];
let contentTypes = [];
let accepts = ['application/json'];
let returnType = null;
return this.apiClient.callApi(
'/api/v3/worker-mgt/cluster/{cluster_id}', 'DELETE',
pathParams, queryParams, headerParams, formParams, postBody,
authNames, contentTypes, accepts, returnType, null
);
}
/**
* Remove this worker cluster. This unassigns all workers from the cluster and removes it.
* @param {String} clusterId
* @return {Promise} a {@link https://www.promisejs.org/|Promise}
*/
deleteWorkerCluster(clusterId) {
return this.deleteWorkerClusterWithHttpInfo(clusterId)
.then(function(response_and_data) {
return response_and_data.data;
});
}
/**
* Fetch info about the worker.
* @param {String} workerId
@ -131,6 +225,91 @@ export default class WorkerMgtApi {
}
/**
* Get a single worker cluster.
* @param {String} clusterId
* @return {Promise} a {@link https://www.promisejs.org/|Promise}, with an object containing data of type {@link module:model/WorkerCluster} and HTTP response
*/
fetchWorkerClusterWithHttpInfo(clusterId) {
let postBody = null;
// verify the required parameter 'clusterId' is set
if (clusterId === undefined || clusterId === null) {
throw new Error("Missing the required parameter 'clusterId' when calling fetchWorkerCluster");
}
let pathParams = {
'cluster_id': clusterId
};
let queryParams = {
};
let headerParams = {
};
let formParams = {
};
let authNames = [];
let contentTypes = [];
let accepts = ['application/json'];
let returnType = WorkerCluster;
return this.apiClient.callApi(
'/api/v3/worker-mgt/cluster/{cluster_id}', 'GET',
pathParams, queryParams, headerParams, formParams, postBody,
authNames, contentTypes, accepts, returnType, null
);
}
/**
* Get a single worker cluster.
* @param {String} clusterId
* @return {Promise} a {@link https://www.promisejs.org/|Promise}, with data of type {@link module:model/WorkerCluster}
*/
fetchWorkerCluster(clusterId) {
return this.fetchWorkerClusterWithHttpInfo(clusterId)
.then(function(response_and_data) {
return response_and_data.data;
});
}
/**
* Get list of worker clusters.
* @return {Promise} a {@link https://www.promisejs.org/|Promise}, with an object containing data of type {@link module:model/WorkerClusterList} and HTTP response
*/
fetchWorkerClustersWithHttpInfo() {
let postBody = null;
let pathParams = {
};
let queryParams = {
};
let headerParams = {
};
let formParams = {
};
let authNames = [];
let contentTypes = [];
let accepts = ['application/json'];
let returnType = WorkerClusterList;
return this.apiClient.callApi(
'/api/v3/worker-mgt/clusters', 'GET',
pathParams, queryParams, headerParams, formParams, postBody,
authNames, contentTypes, accepts, returnType, null
);
}
/**
* Get list of worker clusters.
* @return {Promise} a {@link https://www.promisejs.org/|Promise}, with data of type {@link module:model/WorkerClusterList}
*/
fetchWorkerClusters() {
return this.fetchWorkerClustersWithHttpInfo()
.then(function(response_and_data) {
return response_and_data.data;
});
}
/**
* @param {String} workerId
* @return {Promise} a {@link https://www.promisejs.org/|Promise}, with an object containing data of type {@link module:model/WorkerSleepSchedule} and HTTP response
@ -264,6 +443,56 @@ export default class WorkerMgtApi {
}
/**
* @param {String} workerId
* @param {module:model/WorkerClusterChangeRequest} workerClusterChangeRequest The list of cluster IDs this worker should be a member of.
* @return {Promise} a {@link https://www.promisejs.org/|Promise}, with an object containing HTTP response
*/
setWorkerClustersWithHttpInfo(workerId, workerClusterChangeRequest) {
let postBody = workerClusterChangeRequest;
// verify the required parameter 'workerId' is set
if (workerId === undefined || workerId === null) {
throw new Error("Missing the required parameter 'workerId' when calling setWorkerClusters");
}
// verify the required parameter 'workerClusterChangeRequest' is set
if (workerClusterChangeRequest === undefined || workerClusterChangeRequest === null) {
throw new Error("Missing the required parameter 'workerClusterChangeRequest' when calling setWorkerClusters");
}
let pathParams = {
'worker_id': workerId
};
let queryParams = {
};
let headerParams = {
};
let formParams = {
};
let authNames = [];
let contentTypes = ['application/json'];
let accepts = ['application/json'];
let returnType = null;
return this.apiClient.callApi(
'/api/v3/worker-mgt/workers/{worker_id}/setclusters', 'POST',
pathParams, queryParams, headerParams, formParams, postBody,
authNames, contentTypes, accepts, returnType, null
);
}
/**
* @param {String} workerId
* @param {module:model/WorkerClusterChangeRequest} workerClusterChangeRequest The list of cluster IDs this worker should be a member of.
* @return {Promise} a {@link https://www.promisejs.org/|Promise}
*/
setWorkerClusters(workerId, workerClusterChangeRequest) {
return this.setWorkerClustersWithHttpInfo(workerId, workerClusterChangeRequest)
.then(function(response_and_data) {
return response_and_data.data;
});
}
/**
* @param {String} workerId
* @param {module:model/WorkerSleepSchedule} workerSleepSchedule The new sleep schedule.
@ -314,4 +543,56 @@ export default class WorkerMgtApi {
}
/**
* Update an existing worker cluster.
* @param {String} clusterId
* @param {module:model/WorkerCluster} workerCluster The updated worker cluster.
* @return {Promise} a {@link https://www.promisejs.org/|Promise}, with an object containing HTTP response
*/
updateWorkerClusterWithHttpInfo(clusterId, workerCluster) {
let postBody = workerCluster;
// verify the required parameter 'clusterId' is set
if (clusterId === undefined || clusterId === null) {
throw new Error("Missing the required parameter 'clusterId' when calling updateWorkerCluster");
}
// verify the required parameter 'workerCluster' is set
if (workerCluster === undefined || workerCluster === null) {
throw new Error("Missing the required parameter 'workerCluster' when calling updateWorkerCluster");
}
let pathParams = {
'cluster_id': clusterId
};
let queryParams = {
};
let headerParams = {
};
let formParams = {
};
let authNames = [];
let contentTypes = ['application/json'];
let accepts = ['application/json'];
let returnType = null;
return this.apiClient.callApi(
'/api/v3/worker-mgt/cluster/{cluster_id}', 'PUT',
pathParams, queryParams, headerParams, formParams, postBody,
authNames, contentTypes, accepts, returnType, null
);
}
/**
* Update an existing worker cluster.
* @param {String} clusterId
* @param {module:model/WorkerCluster} workerCluster The updated worker cluster.
* @return {Promise} a {@link https://www.promisejs.org/|Promise}
*/
updateWorkerCluster(clusterId, workerCluster) {
return this.updateWorkerClusterWithHttpInfo(clusterId, workerCluster)
.then(function(response_and_data) {
return response_and_data.data;
});
}
}

View File

@ -97,6 +97,9 @@ class Job {
if (data.hasOwnProperty('storage')) {
obj['storage'] = JobStorageInfo.constructFromObject(data['storage']);
}
if (data.hasOwnProperty('worker_cluster')) {
obj['worker_cluster'] = ApiClient.convertToType(data['worker_cluster'], 'String');
}
if (data.hasOwnProperty('id')) {
obj['id'] = ApiClient.convertToType(data['id'], 'String');
}
@ -166,6 +169,12 @@ Job.prototype['submitter_platform'] = undefined;
*/
Job.prototype['storage'] = undefined;
/**
* Worker Cluster that should execute this job. When a cluster ID is given, only Workers in that cluster will be scheduled to work on it. If empty or ommitted, all workers can work on this job.
* @member {String} worker_cluster
*/
Job.prototype['worker_cluster'] = undefined;
/**
* UUID of the Job
* @member {String} id
@ -239,6 +248,11 @@ SubmittedJob.prototype['submitter_platform'] = undefined;
* @member {module:model/JobStorageInfo} storage
*/
SubmittedJob.prototype['storage'] = undefined;
/**
* Worker Cluster that should execute this job. When a cluster ID is given, only Workers in that cluster will be scheduled to work on it. If empty or ommitted, all workers can work on this job.
* @member {String} worker_cluster
*/
SubmittedJob.prototype['worker_cluster'] = undefined;
// Implement JobAllOf interface:
/**
* UUID of the Job

View File

@ -81,6 +81,9 @@ class SubmittedJob {
if (data.hasOwnProperty('storage')) {
obj['storage'] = JobStorageInfo.constructFromObject(data['storage']);
}
if (data.hasOwnProperty('worker_cluster')) {
obj['worker_cluster'] = ApiClient.convertToType(data['worker_cluster'], 'String');
}
}
return obj;
}
@ -132,6 +135,12 @@ SubmittedJob.prototype['submitter_platform'] = undefined;
*/
SubmittedJob.prototype['storage'] = undefined;
/**
* Worker Cluster that should execute this job. When a cluster ID is given, only Workers in that cluster will be scheduled to work on it. If empty or ommitted, all workers can work on this job.
* @member {String} worker_cluster
*/
SubmittedJob.prototype['worker_cluster'] = undefined;

View File

@ -13,6 +13,7 @@
import ApiClient from '../ApiClient';
import WorkerAllOf from './WorkerAllOf';
import WorkerCluster from './WorkerCluster';
import WorkerStatus from './WorkerStatus';
import WorkerStatusChangeRequest from './WorkerStatusChangeRequest';
import WorkerSummary from './WorkerSummary';
@ -101,6 +102,9 @@ class Worker {
if (data.hasOwnProperty('task')) {
obj['task'] = WorkerTask.constructFromObject(data['task']);
}
if (data.hasOwnProperty('clusters')) {
obj['clusters'] = ApiClient.convertToType(data['clusters'], [WorkerCluster]);
}
}
return obj;
}
@ -162,6 +166,12 @@ Worker.prototype['supported_task_types'] = undefined;
*/
Worker.prototype['task'] = undefined;
/**
* Clusters of which this Worker is a member.
* @member {Array.<module:model/WorkerCluster>} clusters
*/
Worker.prototype['clusters'] = undefined;
// Implement WorkerSummary interface:
/**
@ -209,6 +219,11 @@ WorkerAllOf.prototype['supported_task_types'] = undefined;
* @member {module:model/WorkerTask} task
*/
WorkerAllOf.prototype['task'] = undefined;
/**
* Clusters of which this Worker is a member.
* @member {Array.<module:model/WorkerCluster>} clusters
*/
WorkerAllOf.prototype['clusters'] = undefined;

View File

@ -12,6 +12,7 @@
*/
import ApiClient from '../ApiClient';
import WorkerCluster from './WorkerCluster';
import WorkerTask from './WorkerTask';
/**
@ -66,6 +67,9 @@ class WorkerAllOf {
if (data.hasOwnProperty('task')) {
obj['task'] = WorkerTask.constructFromObject(data['task']);
}
if (data.hasOwnProperty('clusters')) {
obj['clusters'] = ApiClient.convertToType(data['clusters'], [WorkerCluster]);
}
}
return obj;
}
@ -95,6 +99,12 @@ WorkerAllOf.prototype['supported_task_types'] = undefined;
*/
WorkerAllOf.prototype['task'] = undefined;
/**
* Clusters of which this Worker is a member.
* @member {Array.<module:model/WorkerCluster>} clusters
*/
WorkerAllOf.prototype['clusters'] = undefined;

View File

@ -0,0 +1,91 @@
/**
* 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';
/**
* The WorkerCluster model module.
* @module model/WorkerCluster
* @version 0.0.0
*/
class WorkerCluster {
/**
* Constructs a new <code>WorkerCluster</code>.
* Cluster of workers. A job can optionally specify which cluster it should be limited to. Workers can be part of multiple clusters simultaneously.
* @alias module:model/WorkerCluster
* @param name {String}
*/
constructor(name) {
WorkerCluster.initialize(this, name);
}
/**
* 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, name) {
obj['name'] = name;
}
/**
* Constructs a <code>WorkerCluster</code> from a plain JavaScript object, optionally creating a new instance.
* Copies all relevant properties from <code>data</code> to <code>obj</code> if supplied or a new instance if not.
* @param {Object} data The plain JavaScript object bearing properties of interest.
* @param {module:model/WorkerCluster} obj Optional instance to populate.
* @return {module:model/WorkerCluster} The populated <code>WorkerCluster</code> instance.
*/
static constructFromObject(data, obj) {
if (data) {
obj = obj || new WorkerCluster();
if (data.hasOwnProperty('id')) {
obj['id'] = ApiClient.convertToType(data['id'], 'String');
}
if (data.hasOwnProperty('name')) {
obj['name'] = ApiClient.convertToType(data['name'], 'String');
}
if (data.hasOwnProperty('description')) {
obj['description'] = ApiClient.convertToType(data['description'], 'String');
}
}
return obj;
}
}
/**
* UUID of the cluster. Can be ommitted when creating a new cluster, in which case a random UUID will be assigned.
* @member {String} id
*/
WorkerCluster.prototype['id'] = undefined;
/**
* @member {String} name
*/
WorkerCluster.prototype['name'] = undefined;
/**
* @member {String} description
*/
WorkerCluster.prototype['description'] = undefined;
export default WorkerCluster;

View File

@ -0,0 +1,74 @@
/**
* 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';
/**
* The WorkerClusterChangeRequest model module.
* @module model/WorkerClusterChangeRequest
* @version 0.0.0
*/
class WorkerClusterChangeRequest {
/**
* Constructs a new <code>WorkerClusterChangeRequest</code>.
* Request to change which clusters this Worker is assigned to.
* @alias module:model/WorkerClusterChangeRequest
* @param clusterIds {Array.<String>}
*/
constructor(clusterIds) {
WorkerClusterChangeRequest.initialize(this, clusterIds);
}
/**
* 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, clusterIds) {
obj['cluster_ids'] = clusterIds;
}
/**
* Constructs a <code>WorkerClusterChangeRequest</code> from a plain JavaScript object, optionally creating a new instance.
* Copies all relevant properties from <code>data</code> to <code>obj</code> if supplied or a new instance if not.
* @param {Object} data The plain JavaScript object bearing properties of interest.
* @param {module:model/WorkerClusterChangeRequest} obj Optional instance to populate.
* @return {module:model/WorkerClusterChangeRequest} The populated <code>WorkerClusterChangeRequest</code> instance.
*/
static constructFromObject(data, obj) {
if (data) {
obj = obj || new WorkerClusterChangeRequest();
if (data.hasOwnProperty('cluster_ids')) {
obj['cluster_ids'] = ApiClient.convertToType(data['cluster_ids'], ['String']);
}
}
return obj;
}
}
/**
* @member {Array.<String>} cluster_ids
*/
WorkerClusterChangeRequest.prototype['cluster_ids'] = undefined;
export default WorkerClusterChangeRequest;

View File

@ -0,0 +1,72 @@
/**
* 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 WorkerCluster from './WorkerCluster';
/**
* The WorkerClusterList model module.
* @module model/WorkerClusterList
* @version 0.0.0
*/
class WorkerClusterList {
/**
* Constructs a new <code>WorkerClusterList</code>.
* @alias module:model/WorkerClusterList
*/
constructor() {
WorkerClusterList.initialize(this);
}
/**
* 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) {
}
/**
* Constructs a <code>WorkerClusterList</code> from a plain JavaScript object, optionally creating a new instance.
* Copies all relevant properties from <code>data</code> to <code>obj</code> if supplied or a new instance if not.
* @param {Object} data The plain JavaScript object bearing properties of interest.
* @param {module:model/WorkerClusterList} obj Optional instance to populate.
* @return {module:model/WorkerClusterList} The populated <code>WorkerClusterList</code> instance.
*/
static constructFromObject(data, obj) {
if (data) {
obj = obj || new WorkerClusterList();
if (data.hasOwnProperty('clusters')) {
obj['clusters'] = ApiClient.convertToType(data['clusters'], [WorkerCluster]);
}
}
return obj;
}
}
/**
* @member {Array.<module:model/WorkerCluster>} clusters
*/
WorkerClusterList.prototype['clusters'] = undefined;
export default WorkerClusterList;

View File

@ -1,5 +1,8 @@
import { defineStore } from 'pinia'
import { WorkerMgtApi } from '@/manager-api';
import { getAPIClient } from "@/api-client";
// 'use' prefix is idiomatic for Pinia stores.
// See https://pinia.vuejs.org/core-concepts/
export const useWorkers = defineStore('workers', {
@ -11,6 +14,12 @@ export const useWorkers = defineStore('workers', {
* @type {string}
*/
activeWorkerID: "",
/** @type {API.WorkerCluster[]} */
clusters: [],
/* Mapping from cluster UUID to API.WorkerCluster. */
clustersByID: {},
}),
actions: {
setActiveWorkerID(workerID) {
@ -37,5 +46,23 @@ export const useWorkers = defineStore('workers', {
activeWorkerID: "",
});
},
/**
* Fetch the available worker clusters from the Manager.
*
* @returns a promise.
*/
refreshClusters() {
const api = new WorkerMgtApi(getAPIClient());
return api.fetchWorkerClusters()
.then((resp) => {
this.clusters = resp.clusters;
let clustersByID = {};
for (let cluster of this.clusters) {
clustersByID[cluster.id] = cluster;
}
this.clustersByID = clustersByID;
})
},
},
})

View File

@ -115,3 +115,6 @@ enableRobotsTXT = true
[params.geekdocContentLicense]
name = "CC BY 4.0 - Blender Foundation"
link = "https://creativecommons.org/licenses/by/4.0/"
[params.flamenco]
bugreportURL = "https://projects.blender.org/studio/flamenco/issues/new?template=.gitea%2fissue_template%2fbug.yaml"

View File

@ -9,7 +9,7 @@ Join the community on the [#flamenco channel][chat] of Blender Chat do discuss
development topics. New faces are always welcome!
{{< button size="large" relref="/development/getting-started" >}}Get Started Developing Flamenco{{< /button >}}
{{< button size="large" href="https://projects.blender.org/studio/flamenco/issues/new?template=.gitea%2fissue_template%2fbug.yaml" >}}Report a Bug{{< /button >}}
{{< flamenco/reportBugButton size="large" >}}
If you want to know what kind of work can be done, take a look at the
[workboard][workboard].

View File

@ -40,21 +40,30 @@ file][workercfg].
## Can I change the paths/names of the rendered files?
Where Flamenco places the rendered files is determined by the job type. You can
create [your own custom job type][jobtypes] to change this. With that, you can
create [your own custom job type][jobtypes] or check the existing
[third-party job types][thirdpartyjobs] to change this. With that, you can
even add your own custom job settings like a sequence identifier and use that to
determine the location of rendered files.
## Can I use the Compositor to output multiple EXR files?
## Can I use the Compositor to output multiple EXR files or Passes?
This is possible with Flamenco, but it takes a bit of work. It's not managed by
Flamenco's default job types. You can create [your own custom job
type][jobtypes] for this, though. With that, you have control over the arguments
that get used before and/or after the filename on the CLI.
This is possible with Flamenco, but it takes a bit of work. Although it's not
managed by Flamenco's default job types, you can use a [custom job type][jobtypes]
for this.
If you have this working, please [share your job compiler script with us][getinvolved]!
With that, you have control over the arguments that get used before and/or after
the filename on the CLI.
There are Flamenco jobs out there that support compositor nodes,
multi-platform, and multiple pass outputs. You can check our [third-party jobs
section][thirdpartyjobs].
If you wish to contribute to the project, you're invited to
[get involved with Flamenco][getinvolved]!
[jobtypes]: {{< ref "usage/job-types" >}}
[thirdpartyjobs]: {{< ref "third-party-jobs" >}}
[getinvolved]: {{< ref "development/get-involved" >}}
@ -98,3 +107,22 @@ complex project, and relies on a lot of components
([source](https://www.opencue.io/docs/getting-started/)), whereas Flamenco is
made for simplicity and use in small studios or at home, running on your own
hardware.
## Why do I get an Error Performing BAT Pack Message?
As of yet, we've only encountered the issue below on Windows installations. If
you get this issue, please {{< flamenco/reportBugLink size="small" >}}let us
know{{< /flamenco/reportBugLink >}} so that it can be properly investigated.
```
Error performing BAT pack: [WinError 267] The directory name is invalid:
'C:\\The\\Path\\To\\Your\\Project.blend'
```
This is most likely some sort of incompatibility that occurs in some cases where
you might be using linked assets from an asset library in your project.
To work around this issue, try the following:
* In Blender, use File → External Data → Make Paths Relative.
* Submit your job again.

View File

@ -0,0 +1,4 @@
---
title: Third-Party Jobs
weight: 30
---

View File

@ -0,0 +1,21 @@
---
title: Available Third-Party Jobs
weight: 1
---
This section contains third-party job types for Flamenco. These have been
submitted by the community. If you wish to contribute, consider joining the
[Blender Chat channel][flamencochannel] and chime-in there.
## How can I create my own Job Type?
This is described [Job Types][jobtypes]. It is recommended to use the
[built-in scripts][built-in-scripts] as examples and adjust them from there.
## Third-Party Job Types
{{< flamenco/toc-children >}}
[jobtypes]: {{< ref "usage/job-types" >}}
[built-in-scripts]: https://projects.blender.org/studio/flamenco/src/branch/main/internal/manager/job_compilers/scripts
[flamencochannel]: https://blender.chat/channel/flamenco

View File

@ -0,0 +1,103 @@
---
title: Compositor Nodes
weight: 10
---
*Job type documented and maintained by: [Dylan Blanqué][author].*
[author]: https://projects.blender.org/Dylan-Blanque
{{< hint >}}
This is a community-made job type. It may not reflect the same design as the
rest of Flamenco, as it was made for a specific person to solve a specific need.
{{< /hint >}}
This job type updates Blender's compositor nodes to work with Flamenco.
You'll need to do the following changes to support this workflow:
1. Download the [Flamenco Compositor Script ZIP file][compositorrepo] and extract it somewhere.
2. Copy `startup_script.py` to the configured Blender File Folder in your shared storage.
3. Copy `multi_pass_render.js` to the `scripts` folder in your Flamenco Manager installation folder (create it if it doesn't exist).
4. Add these variables to your `flamenco-manager.yaml` file:
- `storagePath`: Your NAS path, multi-platform variable.
- `jobSubPath`: Where the jobs are stored inside `storagePath`.
- `renderSubpath`: Where the render output is stored inside `storagePath`.
- `deviceType`: Compute Device Type to force. Do not set the variable if you wish to use whatever is available.
5. Submit your job from Blender with the corresponding Multi-Pass Job, it should
whatever compositor nodes you have set and correct the paths where necessary.
[compositorrepo]: https://github.com/dblanque/flamenco-compositor-script/archive/refs/heads/main.zip
{{< hint type=warning >}}
This has only been tested in an environment with [Shaman][shaman] enabled, but it should work without Shaman as well.
[shaman]: {{< ref "/usage/shared-storage/shaman" >}}
{{< /hint >}}
# Example Configuration Flamenco Manager YAML
```yaml
# Configuration file for Flamenco.
#
# For an explanation of the fields,
# refer to the original flamenco-manager-example.yaml
_meta:
version: 3
manager_name: Flamenco Manager
database: flamenco-manager.sqlite
listen: :8080
autodiscoverable: true
local_manager_storage_path: ./flamenco-manager-storage
shared_storage_path: /mnt/storage/project_files
shaman:
enabled: true
garbageCollect:
period: 24h0m0s
maxAge: 744h0m0s
extraCheckoutPaths: []
task_timeout: 10m0s
worker_timeout: 1m0s
blocklist_threshold: 3
task_fail_after_softfail_count: 3
variables:
blender:
values:
- platform: all
value: blender
- platform: linux
value: /usr/local/blender/blender
- platform: windows
value: C:/Program Files/Blender Foundation/Blender 3.4/blender.exe
- platform: darwin
value: /usr/bin/blender
blenderArgs:
values:
- platform: all
value: -b -y
storagePath:
values:
- platform: linux
value: /mnt/storage
- platform: windows
value: "Z:\\"
jobSubPath:
values:
- platform: all
value: project_files
renderSubPath:
values:
- platform: all
value: project_render
deviceType:
values:
- platform: all
value: "CUDA"
# Set the device type to FIRST or remove the variable definition
# to use whatever device type is detected first.
```

View File

@ -25,9 +25,13 @@ The difference with regular variables is that regular variables are one-way:
Two-way variables go both ways, as follows:
- When submitting a job, values are replaced with variables.
- When submitting a **job**, values **in the javascript jobs' command** are replaced
with the corresponding variables as it's executed on the client.
- When sending a task to a worker, variables are replaced with values again.
*(Do keep in mind that if you perform changes to a job, you'll need to re-submit*
*it.)*
This may seem like a lot of unnecessary work. After all, why go through the
trouble of replacing in one direction, when later the opposite is done? The
power lies in the fact that each replacement step can target a different

View File

@ -0,0 +1,19 @@
{{/* This is an adjusted copy of themes/hugo-geekdoc/layouts/shortcodes/button.html */}}
{{- $ref := .Page.Site.Params.Flamenco.bugreportURL -}}
{{- $class := "" }}
{{- $size := default "regular" (.Get "size" | lower) }}
{{- if not (in (slice "regular" "large" "small") $size) }}
{{- $size = "regular" }}
{{- end }}
{{- with .Get "class" }}
{{- $class = . }}
{{- end }}
<span class="gdoc-button gdoc-button--{{ $size }}{{ with $class }}{{ printf " %s" . }}{{ end }}">
<a
class="gdoc-button__link"
{{- with $ref }}{{ printf " href=\"%s\"" . | safeHTMLAttr }}{{ end }}
>Report a Bug</a>
</span>

View File

@ -0,0 +1,11 @@
{{/* This is an adjusted copy of themes/hugo-geekdoc/layouts/shortcodes/button.html */}}
{{- $ref := .Page.Site.Params.Flamenco.bugreportURL -}}
{{- $class := "" }}
{{- with .Get "class" }}
{{- $class = . }}
{{- end }}
<a class="{{ with $class }}{{ printf " %s" . }}{{ end }}"
{{- with $ref }}{{ printf " href=\"%s\"" . | safeHTMLAttr }}{{ end }}
>{{ $.Inner }}</a>

View File

@ -0,0 +1,20 @@
<!-- For more info check the links below -->
<!-- Lists: https://gohugo.io/templates/lists/ -->
<!-- Taxonomy Templates: https://gohugo.io/templates/taxonomy-templates/ -->
<!-- Page Variables: https://gohugo.io/variables/pages/ -->
<!-- Cheers, Dylan -->
<!-- This TOC Excludes the current section index and the current page -->
<div>
{{ $current_page_title := .Page.Title }}
<ul>
{{ range .Page.CurrentSection.Data.Pages.ByWeight }}
{{ if ne .Page.Title $current_page_title }}
<li>
<a href="{{ .Permalink }}">{{ .LinkTitle }}</a>
</li>
{{ end }}
{{ end }}
</ul>
</div>