Skip to content

Commit 65c56d5

Browse files
committed
Merge remote-tracking branch 'origin/static-rest-endpoint' into girder-5
2 parents edb54e1 + 75c8a70 commit 65c56d5

File tree

6 files changed

+288
-17
lines changed

6 files changed

+288
-17
lines changed

README.rst

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ To use Girder Worker:
3333

3434
.. code-block:: bash
3535
36-
pip install girder-slicer-cli-web[worker]
36+
pip install 'girder-slicer-cli-web[worker]'
3737
GW_DIRECT_PATHS=true girder_worker -l info -Ofair --prefetch-multiplier=1
3838
3939
The first time you start Girder, you'll need to configure it with at least one user and one assetstore (see the Girder_ documentation). Additionally, it is recommended that you install some dockerized tasks, such as the HistomicsTK_ algorithms. This can be done going to the Admin Console, Plugins, Slicer CLI Web settings. Set a default task upload folder, then import the ``dsarchive/histomicstk:latest`` docker image.
@@ -44,9 +44,24 @@ Girder Plugin
4444
Importing Docker Images
4545
=======================
4646

47-
When installed in Girder, an admin user can go to the Admin Console -> Plugins -> Slicer CLI Web to add Docker images. Select a Docker image and an existing folder and then select Import Image. Slicer CLI Web will pull the Docker image if it is not available on the Girder machine.
47+
Once a docker image has been created and pushed to Docker Hub, you can register the image's CLI as a set of tasks on the server. To do so,
48+
use the client upload script bundled with this tool. To install it, run:
49+
50+
.. code-block:: bash
51+
52+
pip install 'girder-slicer-cli-web[client]'
53+
54+
Create an API key with the "Manage Slicer CLI tasks" scope, and set it in your environment and run a command like this example:
55+
56+
.. code-block:: bash
57+
58+
GIRDER_API_KEY=my_key_vale upload-slicer-cli-task https://my-girder-host.com/api/v1 641b8578cdcf8f129805524b my-slicer-cli-image:latest
59+
60+
The first argument of this command is the API URL of the server, the second is a Girder folder ID where the tasks will live, and the
61+
last argument is the docker image identifier. (If the image does not exist locally it will be pulled.) If you just want to create a
62+
single CLI task rather than all tasks from ``--list_cli``, you can pass ``--cli=CliName``. If you wish to replace the existing tasks
63+
with the latest specifications, also pass the ``--replace`` flag to the command.
4864

49-
For each docker image that is imported, a folder is created with the image tag. Within this folder, a subfolder is created with the image version. The subfolder will have one item per CLI that the Docker image reports. These items can be moved after they have been imported, just like standard Girder items.
5065

5166
Running CLIs
5267
============

setup.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,17 @@ def prerelease_local_scheme(version):
6363
],
6464
extras_require={
6565
'girder': [
66-
'docker>=2.6.0',
6766
'girder>=3.0.4',
6867
'girder-jobs>=3.0.3',
6968
'girder-worker[girder]>=0.6.0',
7069
],
7170
'worker': [
7271
'docker>=2.6.0',
7372
'girder-worker[worker]>=0.6.0',
73+
],
74+
'client': [
75+
'click',
76+
'girder-client',
7477
]
7578
},
7679
entry_points={
@@ -79,6 +82,9 @@ def prerelease_local_scheme(version):
7982
],
8083
'girder_worker_plugins': [
8184
'slicer_cli_web = slicer_cli_web.girder_worker_plugin:SlicerCLIWebWorkerPlugin'
85+
],
86+
'console_scripts': [
87+
'upload-slicer-cli-task = slicer_cli_web.upload_slicer_cli_task:upload_slicer_cli_task'
8288
]
8389
},
8490
python_requires='>=3.8',

slicer_cli_web/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,6 @@
3434
# package is not installed
3535
pass
3636

37-
3837
__license__ = 'Apache 2.0'
38+
39+
TOKEN_SCOPE_MANAGE_TASKS = 'slicer_cli_web.manage_tasks'

slicer_cli_web/docker_resource.py

Lines changed: 200 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,32 @@
1414
# limitations under the License.
1515
#############################################################################
1616

17-
17+
import copy
1818
import json
1919
import os
2020
import re
21+
import time
22+
from base64 import b64decode
2123

2224
import pymongo
2325
from girder.api import access
2426
from girder.api.describe import Description, autoDescribeRoute, describeRoute
25-
from girder.api.rest import setRawResponse, setResponseHeader
27+
from girder.api.rest import filtermodel, setRawResponse, setResponseHeader
2628
from girder.api.v1.resource import Resource, RestException
2729
from girder.constants import AccessType, SortDir
2830
from girder.exceptions import AccessException
31+
from girder.models.folder import Folder
2932
from girder.models.item import Item
33+
from girder.models.token import Token
3034
from girder.utility import path as path_util
3135
from girder.utility.model_importer import ModelImporter
3236
from girder_jobs.constants import JobStatus
3337
from girder_jobs.models.job import Job
3438

39+
from . import TOKEN_SCOPE_MANAGE_TASKS, rest_slicer_cli
40+
from .cli_utils import as_model, get_cli_parameters
3541
from .config import PluginSettings
36-
from .models import CLIItem, DockerImageItem, DockerImageNotFoundError
37-
from .rest_slicer_cli import genRESTEndPointsForSlicerCLIsForItem
42+
from .models import CLIItem, DockerImageItem, DockerImageNotFoundError, parser
3843

3944

4045
class DockerResource(Resource):
@@ -51,6 +56,7 @@ def __init__(self, name):
5156
self.resourceName = name
5257
self.jobType = 'slicer_cli_web_job'
5358
self.route('PUT', ('docker_image',), self.setImages)
59+
self.route('POST', ('cli',), self.createOrReplaceCli)
5460
self.route('DELETE', ('docker_image',), self.deleteImage)
5561
self.route('GET', ('docker_image',), self.getDockerImages)
5662

@@ -62,7 +68,63 @@ def __init__(self, name):
6268

6369
self.route('GET', ('path_match', ), self.getMatchingResource)
6470

65-
self._generateAllItemEndPoints()
71+
if os.environ.get('GIRDER_STATIC_REST_ONLY'):
72+
self.route('POST', ('cli', ':id', 'run'), self.runCli)
73+
self.route('POST', ('cli', ':id', 'rerun'), self.rerunCli)
74+
self.route('POST', ('cli', ':id', 'datalist', ':key'), self.datalistHandler)
75+
else:
76+
self._generateAllItemEndPoints()
77+
78+
@access.user
79+
@autoDescribeRoute(
80+
Description('Run a Slicer CLI job.')
81+
.modelParam('id', 'The slicer CLI task item', Item, level=AccessType.READ)
82+
)
83+
def runCli(self, item, params):
84+
user = self.getCurrentUser()
85+
token = Token().createToken(user=user)
86+
return cliSubHandler(CLIItem(item), params, user, token).job
87+
88+
@access.user
89+
@autoDescribeRoute(
90+
Description('Re-run a Slicer CLI job.')
91+
.modelParam('id', 'The slicer CLI item task', Item, level=AccessType.READ)
92+
.modelParam('jobId', 'The job to re-run', Job, level=AccessType.READ)
93+
)
94+
def rerunCli(self, item, job, params):
95+
user = self.getCurrentUser()
96+
newParams = job.get('_original_params', {})
97+
newParams.update(params)
98+
99+
token = Token().createToken(user=user)
100+
return cliSubHandler(CLIItem(item), newParams, user, token).job
101+
102+
@access.user
103+
@describeRoute(
104+
Description('Lookup a datalist parameter on a CLI task')
105+
.modelParam('id', 'The slicer CLI item task', Item, level=AccessType.READ)
106+
.param('key', 'The parameter name to look up')
107+
.deprecated()
108+
)
109+
def datalistHandler(self, item, key, params):
110+
# TODO we should change any client that is using this to instead poll the job rather than
111+
# waiting for it to finish in the request thread.
112+
user = self.getCurrentUser()
113+
114+
currentItem = CLIItem(item)
115+
token = Token().createToken(user=user)
116+
job = cliSubHandler(currentItem, params, user, token, key).job
117+
delay = 0.01
118+
while job['status'] not in {JobStatus.SUCCESS, JobStatus.ERROR, JobStatus.CANCELED}:
119+
time.sleep(delay)
120+
delay = min(delay * 1.5, 1.0)
121+
job = Job().load(id=job['_id'], force=True, includeLog=True)
122+
result = ''.join(job['log']) if 'log' in job else ''
123+
if '<element' in result:
124+
result = result[result.index('<element'):]
125+
if '</element>' in result:
126+
result = result[:result.rindex('</element>') + 10]
127+
return result
66128

67129
@access.public
68130
@describeRoute(
@@ -183,7 +245,7 @@ def parseImageNameList(self, param):
183245
raise RestException('Image %s does not have a tag or digest' % img)
184246
return nameList
185247

186-
@access.admin
248+
@access.admin(scope=TOKEN_SCOPE_MANAGE_TASKS)
187249
@describeRoute(
188250
Description('Add one or a list of images')
189251
.notes('Must be a system administrator to call this.')
@@ -210,6 +272,37 @@ def setImages(self, params):
210272
raise RestException('no upload folder given or defined by default')
211273
return self._createPutImageJob(nameList, folder, params.get('pull', None))
212274

275+
@access.admin(scope=TOKEN_SCOPE_MANAGE_TASKS)
276+
@filtermodel(Item)
277+
@autoDescribeRoute(
278+
Description('Add or replace an item task.')
279+
.notes('Must be a system administrator to call this.')
280+
.modelParam('folder', 'The folder ID to upload the task to.', paramType='formData',
281+
model=Folder, level=AccessType.WRITE)
282+
.param('image', 'The docker image identifier.')
283+
.param('name', 'The name of the item to create or replace.')
284+
.param('replace', 'Whether to replace an existing item with this name.', dataType='boolean')
285+
.param('spec', 'Base64-encoded XML spec of the CLI.')
286+
.errorResponse('You are not a system administrator.', 403)
287+
)
288+
def createOrReplaceCli(self, folder: dict, image: str, name: str, replace: bool, spec: str):
289+
try:
290+
spec = b64decode(spec).decode()
291+
except ValueError:
292+
raise RestException('The CLI spec must be base64-encoded UTF-8.')
293+
294+
item = Item().createItem(
295+
name, creator=self.getCurrentUser(), folder=folder, reuseExisting=replace
296+
)
297+
metadata = dict(
298+
slicerCLIType='task',
299+
type='Unknown', # TODO does "type" matter behaviorally? If so get it from the client
300+
digest=None, # TODO should we support this?
301+
image=image,
302+
**parser._parse_xml_desc(item, self.getCurrentUser(), spec)
303+
)
304+
return Item().setMetadata(item, metadata)
305+
213306
def _createPutImageJob(self, nameList, baseFolder, pull=False):
214307
job = Job().createLocalJob(
215308
module='slicer_cli_web.image_job',
@@ -261,7 +354,9 @@ def _generateAllItemEndPoints(self):
261354
seen = set()
262355
for item in items:
263356
# default if not seen yet
264-
genRESTEndPointsForSlicerCLIsForItem(self, item, item.restPath not in seen)
357+
rest_slicer_cli.genRESTEndPointsForSlicerCLIsForItem(
358+
self, item, item.restPath not in seen
359+
)
265360
seen.add(item.restPath)
266361

267362
def addRestEndpoints(self, event):
@@ -390,3 +485,101 @@ def getMatchingResource(self, name, path, type, relative_path, base_id, base_typ
390485
except pymongo.errors.ExecutionTimeout:
391486
return None
392487
return None
488+
489+
490+
def cliSubHandler(cliItem, params, user, token, datalistKey=None):
491+
"""
492+
Create a job for a Slicer CLI item and schedule it.
493+
494+
:param currentItem: a CLIItem model.
495+
:param params: parameter dictionary passed to the endpoint.
496+
:param user: user model for the current user.
497+
:param token: allocated token for the job.
498+
:param datalistKey: if not None, a param name for this CLI that has a datalist.
499+
"""
500+
from .girder_worker_plugin.direct_docker_run import run
501+
502+
clim = as_model(cliItem.xml)
503+
cliTitle = clim.title
504+
505+
original_params = copy.deepcopy(params)
506+
index_params, opt_params, simple_out_params = get_cli_parameters(clim)
507+
508+
datalistSpec = {
509+
param.name: json.loads(param.datalist)
510+
for param in index_params + opt_params
511+
if param.channel != 'output' and param.datalist
512+
}
513+
514+
container_args = [cliItem.name]
515+
reference = {'slicer_cli_web': {
516+
'title': cliTitle,
517+
'image': cliItem.image,
518+
'name': cliItem.name,
519+
}}
520+
now = time.localtime()
521+
templateParams = {
522+
'title': cliTitle, # e.g., "Detects Nuclei"
523+
'task': cliItem.name, # e.g., "NucleiDetection"
524+
'image': cliItem.image, # e.g., "dsarchive/histomicstk:latest"
525+
'now': time.strftime('%Y%m%d-%H%M%S', now),
526+
'yyyy': time.strftime('%Y', now),
527+
'mm': time.strftime('%m', now),
528+
'dd': time.strftime('%d', now),
529+
'HH': time.strftime('%H', now),
530+
'MM': time.strftime('%M', now),
531+
'SS': time.strftime('%S', now),
532+
}
533+
534+
has_simple_return_file = bool(simple_out_params)
535+
sub_index_params, sub_opt_params = index_params, opt_params
536+
537+
if datalistKey:
538+
datalist = datalistSpec[datalistKey]
539+
params = params.copy()
540+
params.update(datalist)
541+
sub_index_params = [
542+
param if param.name not in datalist or not rest_slicer_cli.is_on_girder(param)
543+
else rest_slicer_cli.stringifyParam(param)
544+
for param in index_params
545+
if (param.name not in datalist or datalist.get(param.name) is not None) and
546+
param.name not in {k + rest_slicer_cli.FOLDER_SUFFIX for k in datalist}
547+
]
548+
sub_opt_params = [
549+
param if param.name not in datalist or not rest_slicer_cli.is_on_girder(param)
550+
else rest_slicer_cli.stringifyParam(param)
551+
for param in opt_params
552+
if param.channel != 'output' and (
553+
param.name not in datalist or datalist.get(param.name) is not None) and
554+
param.name not in {k + rest_slicer_cli.FOLDER_SUFFIX for k in datalist}
555+
]
556+
557+
args, result_hooks, primary_input_name = rest_slicer_cli.prepare_task(
558+
params, user, token, sub_index_params, sub_opt_params,
559+
has_simple_return_file, reference, templateParams=templateParams)
560+
container_args.extend(args)
561+
562+
jobType = '%s#%s' % (cliItem.image, cliItem.name)
563+
564+
if primary_input_name:
565+
jobTitle = '%s on %s' % (cliTitle, primary_input_name)
566+
else:
567+
jobTitle = cliTitle
568+
569+
job_kwargs = cliItem.item.get('meta', {}).get('docker-params', {})
570+
job = run.delay(
571+
girder_user=user,
572+
girder_job_type=jobType,
573+
girder_job_title=jobTitle,
574+
girder_result_hooks=result_hooks,
575+
image=cliItem.digest,
576+
pull_image='if-not-present',
577+
container_args=container_args,
578+
**job_kwargs
579+
)
580+
jobRecord = Job().load(job.job['_id'], force=True)
581+
job.job['_original_params'] = jobRecord['_original_params'] = original_params
582+
job.job['_original_name'] = jobRecord['_original_name'] = cliItem.name
583+
job.job['_original_path'] = jobRecord['_original_path'] = cliItem.restBasePath
584+
Job().save(jobRecord)
585+
return job

slicer_cli_web/girder_plugin.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@
1616

1717
import datetime
1818
import json
19+
import os
1920

2021
from girder import events, logger
21-
from girder.constants import AccessType
22+
from girder.constants import AccessType, TokenScope
2223
from girder.plugin import GirderPlugin, getPlugin
2324
from girder_jobs.constants import JobStatus
2425
from girder_jobs.models.job import Job
2526

26-
from . import worker_tools
27+
from . import TOKEN_SCOPE_MANAGE_TASKS
2728
from .docker_resource import DockerResource
2829
from .models import DockerImageItem
2930

@@ -55,6 +56,10 @@ def load(self, info):
5556
except Exception:
5657
logger.info('Girder working is unavailable')
5758

59+
TokenScope.describeScope(
60+
TOKEN_SCOPE_MANAGE_TASKS, name='Manage Slicer CLI tasks',
61+
description='Create / edit Slicer CLI docker tasks', admin=True)
62+
5863
DockerImageItem.prepare()
5964

6065
# resource name must match the attribute added to info[apiroot]
@@ -63,8 +68,9 @@ def load(self, info):
6368

6469
Job().exposeFields(level=AccessType.READ, fields={'slicerCLIBindings'})
6570

66-
events.bind('jobs.job.update.after', resource.resourceName,
67-
resource.addRestEndpoints)
71+
if not os.environ.get('GIRDER_STATIC_REST_ONLY'):
72+
events.bind('jobs.job.update.after', resource.resourceName,
73+
resource.addRestEndpoints)
6874
events.bind('data.process', 'slicer_cli_web', _onUpload)
6975

7076
count = 0
@@ -84,4 +90,3 @@ def load(self, info):
8490
pass
8591
if count:
8692
logger.info('Marking %d old job(s) as cancelled' % count)
87-
worker_tools.start()

0 commit comments

Comments
 (0)