girder-local-checkout 3.2.19.dev10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- girder_local_checkout-3.2.19.dev10/PKG-INFO +41 -0
- girder_local_checkout-3.2.19.dev10/README.rst +13 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/__init__.py +105 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/models.py +106 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/rest.py +204 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/settings.py +67 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/tasks.py +132 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/utils.py +496 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/main.js +60 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/package.json +21 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/routes.js +12 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/stylesheets/localCheckout.styl +8 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/templates/checkoutDialog.pug +75 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/templates/config.pug +67 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/templates/folderActions.pug +4 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/views/CheckoutDialog.js +99 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout/web_client/views/ConfigView.js +96 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/PKG-INFO +41 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/SOURCES.txt +30 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/dependency_links.txt +1 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/entry_points.txt +2 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/not-zip-safe +1 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/requires.txt +2 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/scm_file_list.json +25 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/scm_version.json +8 -0
- girder_local_checkout-3.2.19.dev10/girder_local_checkout.egg-info/top_level.txt +1 -0
- girder_local_checkout-3.2.19.dev10/plugin.cmake +1 -0
- girder_local_checkout-3.2.19.dev10/plugin_tests/__init__.py +0 -0
- girder_local_checkout-3.2.19.dev10/plugin_tests/local_checkout_test.py +523 -0
- girder_local_checkout-3.2.19.dev10/pyproject.toml +6 -0
- girder_local_checkout-3.2.19.dev10/setup.cfg +4 -0
- girder_local_checkout-3.2.19.dev10/setup.py +65 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: girder-local-checkout
|
|
3
|
+
Version: 3.2.19.dev10
|
|
4
|
+
Summary: Temporarily copy S3 assetstore data to a filesystem assetstore.
|
|
5
|
+
Home-page: https://github.com/girder/girder/tree/3.x-maintenance/plugins/local_checkout
|
|
6
|
+
Author: Kitware, Inc.
|
|
7
|
+
Author-email: kitware@kitware.com
|
|
8
|
+
License: Apache 2.0
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Web Environment
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/x-rst
|
|
16
|
+
Requires-Dist: girder>=3
|
|
17
|
+
Requires-Dist: girder-jobs>=3
|
|
18
|
+
Dynamic: author
|
|
19
|
+
Dynamic: author-email
|
|
20
|
+
Dynamic: classifier
|
|
21
|
+
Dynamic: description
|
|
22
|
+
Dynamic: description-content-type
|
|
23
|
+
Dynamic: home-page
|
|
24
|
+
Dynamic: license
|
|
25
|
+
Dynamic: requires-dist
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
Dynamic: summary
|
|
28
|
+
|
|
29
|
+
local-checkout
|
|
30
|
+
==============
|
|
31
|
+
|
|
32
|
+
A Girder plugin that lets users temporarily "checkout" the contents of a
|
|
33
|
+
folder from an S3 assetstore to a filesystem assetstore.
|
|
34
|
+
|
|
35
|
+
Files on S3 assetstores are copied to a filesystem assetstore for a chosen
|
|
36
|
+
number of days; while checked out, Girder serves the local copy. When a
|
|
37
|
+
checkout expires, or when the filesystem assetstore runs short of space, the
|
|
38
|
+
local copies are removed and the files once again refer to the S3 data they
|
|
39
|
+
came from.
|
|
40
|
+
|
|
41
|
+
See the Girder documentation for details.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
local-checkout
|
|
2
|
+
==============
|
|
3
|
+
|
|
4
|
+
A Girder plugin that lets users temporarily "checkout" the contents of a
|
|
5
|
+
folder from an S3 assetstore to a filesystem assetstore.
|
|
6
|
+
|
|
7
|
+
Files on S3 assetstores are copied to a filesystem assetstore for a chosen
|
|
8
|
+
number of days; while checked out, Girder serves the local copy. When a
|
|
9
|
+
checkout expires, or when the filesystem assetstore runs short of space, the
|
|
10
|
+
local copies are removed and the files once again refer to the S3 data they
|
|
11
|
+
came from.
|
|
12
|
+
|
|
13
|
+
See the Girder documentation for details.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from girder import events, plugin
|
|
2
|
+
from girder.exceptions import ValidationException
|
|
3
|
+
from girder.utility.filesystem_assetstore_adapter import FilesystemAssetstoreAdapter
|
|
4
|
+
from girder.utility.model_importer import ModelImporter
|
|
5
|
+
|
|
6
|
+
from . import rest, tasks, utils
|
|
7
|
+
from .models import Checkout, CheckoutFile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _wrapUploadMethods():
|
|
11
|
+
"""
|
|
12
|
+
Make the filesystem assetstore adapter release local copies rather than
|
|
13
|
+
reject an upload when it is short of space.
|
|
14
|
+
"""
|
|
15
|
+
if getattr(FilesystemAssetstoreAdapter, '_localCheckoutWrapped', False):
|
|
16
|
+
return
|
|
17
|
+
for name in ('initUpload', 'uploadChunk'):
|
|
18
|
+
base = getattr(FilesystemAssetstoreAdapter, name)
|
|
19
|
+
|
|
20
|
+
def wrapper(self, upload, *args, _base=base, **kwargs):
|
|
21
|
+
utils.makeRoomForUpload(self, upload)
|
|
22
|
+
return _base(self, upload, *args, **kwargs)
|
|
23
|
+
|
|
24
|
+
wrapper.__name__ = name
|
|
25
|
+
wrapper.__doc__ = base.__doc__
|
|
26
|
+
setattr(FilesystemAssetstoreAdapter, name, wrapper)
|
|
27
|
+
FilesystemAssetstoreAdapter._localCheckoutWrapped = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _onFileRemove(event):
|
|
31
|
+
"""
|
|
32
|
+
When a checked-out file is deleted, core removes the local copy; we must
|
|
33
|
+
also remove the original data it came from.
|
|
34
|
+
"""
|
|
35
|
+
record = CheckoutFile().findByFile(event.info)
|
|
36
|
+
if record is None:
|
|
37
|
+
return
|
|
38
|
+
utils.deleteOriginal(record)
|
|
39
|
+
CheckoutFile().remove(record)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _onFileReplaced(event):
|
|
43
|
+
"""
|
|
44
|
+
When the contents of a checked-out file are replaced (including being
|
|
45
|
+
moved to another assetstore), core has already discarded the local copy,
|
|
46
|
+
so the original data is no longer needed either.
|
|
47
|
+
"""
|
|
48
|
+
if 'fileId' not in event.info['upload']:
|
|
49
|
+
return
|
|
50
|
+
record = CheckoutFile().findByFile(event.info['file'])
|
|
51
|
+
if record is None:
|
|
52
|
+
return
|
|
53
|
+
utils.deleteOriginal(record)
|
|
54
|
+
CheckoutFile().remove(record)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _onAssetstoreRemove(event):
|
|
58
|
+
"""
|
|
59
|
+
Refuse to delete an assetstore that still holds the original data of a
|
|
60
|
+
checked-out file; core only sees that those files point elsewhere.
|
|
61
|
+
"""
|
|
62
|
+
if CheckoutFile().findOne(
|
|
63
|
+
{'originalAssetstoreId': event.info['_id']}, fields=['_id']) is not None:
|
|
64
|
+
raise ValidationException(
|
|
65
|
+
'You may not delete an assetstore that contains the original data of '
|
|
66
|
+
'files with local copies; release the checkouts first.')
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _onFolderRemove(event):
|
|
70
|
+
checkout = Checkout().findByFolder(event.info)
|
|
71
|
+
if checkout is not None:
|
|
72
|
+
utils.removeCheckout(checkout)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _maybeCleanup(event):
|
|
76
|
+
"""
|
|
77
|
+
Schedule the daily cleanup of expired local copies if it is due. This is
|
|
78
|
+
bound to a frequent event so that it runs even on idle servers.
|
|
79
|
+
"""
|
|
80
|
+
if utils.cleanupIsDue():
|
|
81
|
+
tasks.scheduleCleanup()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class LocalCheckoutPlugin(plugin.GirderPlugin):
|
|
85
|
+
DISPLAY_NAME = 'Local Checkout'
|
|
86
|
+
CLIENT_SOURCE_PATH = 'web_client'
|
|
87
|
+
|
|
88
|
+
def load(self, info):
|
|
89
|
+
plugin.getPlugin('jobs').load(info)
|
|
90
|
+
|
|
91
|
+
ModelImporter.registerModel('localCheckout', Checkout, 'local_checkout')
|
|
92
|
+
ModelImporter.registerModel('localCheckoutFile', CheckoutFile, 'local_checkout')
|
|
93
|
+
|
|
94
|
+
info['apiRoot'].local_checkout = rest.LocalCheckout()
|
|
95
|
+
info['apiRoot'].folder.route('GET', (':id', 'local_checkout'), rest.getFolderCheckout)
|
|
96
|
+
info['apiRoot'].folder.route('PUT', (':id', 'local_checkout'), rest.setFolderCheckout)
|
|
97
|
+
info['apiRoot'].folder.route('DELETE', (':id', 'local_checkout'),
|
|
98
|
+
rest.deleteFolderCheckout)
|
|
99
|
+
|
|
100
|
+
events.bind('model.file.remove', 'local_checkout', _onFileRemove)
|
|
101
|
+
events.bind('model.file.finalizeUpload.before', 'local_checkout', _onFileReplaced)
|
|
102
|
+
events.bind('model.folder.remove', 'local_checkout', _onFolderRemove)
|
|
103
|
+
events.bind('model.assetstore.remove', 'local_checkout', _onAssetstoreRemove)
|
|
104
|
+
events.bind('model.file.download.request', 'local_checkout', _maybeCleanup)
|
|
105
|
+
_wrapUploadMethods()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from bson.objectid import ObjectId
|
|
4
|
+
|
|
5
|
+
from girder.exceptions import ValidationException
|
|
6
|
+
from girder.models.model_base import Model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CheckoutStatus:
|
|
10
|
+
PENDING = 'pending'
|
|
11
|
+
RUNNING = 'running'
|
|
12
|
+
COMPLETE = 'complete'
|
|
13
|
+
# Not all files could be copied, typically because of insufficient space.
|
|
14
|
+
PARTIAL = 'partial'
|
|
15
|
+
ERROR = 'error'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Checkout(Model):
|
|
19
|
+
"""
|
|
20
|
+
A folder-level checkout: a request that all S3 files within a folder be
|
|
21
|
+
made local until a given expiration time.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def initialize(self):
|
|
25
|
+
self.name = 'localCheckout'
|
|
26
|
+
self.ensureIndices(['folderId', 'expires'])
|
|
27
|
+
|
|
28
|
+
def validate(self, doc):
|
|
29
|
+
for key in ('folderId', 'expires', 'days'):
|
|
30
|
+
if key not in doc:
|
|
31
|
+
raise ValidationException('Field missing.', key)
|
|
32
|
+
return doc
|
|
33
|
+
|
|
34
|
+
def createCheckout(self, folder, user, days, assetstore):
|
|
35
|
+
now = datetime.datetime.utcnow()
|
|
36
|
+
return self.save({
|
|
37
|
+
'folderId': folder['_id'],
|
|
38
|
+
'assetstoreId': assetstore['_id'],
|
|
39
|
+
'userId': user['_id'] if user else None,
|
|
40
|
+
'days': days,
|
|
41
|
+
'created': now,
|
|
42
|
+
'updated': now,
|
|
43
|
+
'expires': now + datetime.timedelta(days=days),
|
|
44
|
+
'status': CheckoutStatus.PENDING,
|
|
45
|
+
'fileCount': 0,
|
|
46
|
+
'localCount': 0,
|
|
47
|
+
'bytes': 0,
|
|
48
|
+
'message': '',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
def findByFolder(self, folder):
|
|
52
|
+
return self.findOne({'folderId': folder['_id']})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CheckoutFile(Model):
|
|
56
|
+
"""
|
|
57
|
+
A file that currently has a local copy. This records where the file's
|
|
58
|
+
data originally lived so that the file can be pointed back at it when the
|
|
59
|
+
local copy is removed.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def initialize(self):
|
|
63
|
+
self.name = 'localCheckoutFile'
|
|
64
|
+
self.ensureIndices([
|
|
65
|
+
([('fileId', 1)], {'unique': True}),
|
|
66
|
+
'expires',
|
|
67
|
+
'checkoutIds',
|
|
68
|
+
'original.relpath',
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
def validate(self, doc):
|
|
72
|
+
for key in ('fileId', 'original', 'localAssetstoreId', 'expires'):
|
|
73
|
+
if key not in doc:
|
|
74
|
+
raise ValidationException('Field missing.', key)
|
|
75
|
+
return doc
|
|
76
|
+
|
|
77
|
+
def findByFile(self, file):
|
|
78
|
+
return self.findOne({'fileId': file['_id']})
|
|
79
|
+
|
|
80
|
+
def addCheckout(self, record, checkout):
|
|
81
|
+
"""
|
|
82
|
+
Associate a checkout with a file record, extending the record's
|
|
83
|
+
expiration if the checkout lasts longer.
|
|
84
|
+
"""
|
|
85
|
+
update = {'$addToSet': {'checkoutIds': checkout['_id']}}
|
|
86
|
+
if checkout['expires'] > record['expires']:
|
|
87
|
+
update['$set'] = {'expires': checkout['expires']}
|
|
88
|
+
record['expires'] = checkout['expires']
|
|
89
|
+
if checkout['_id'] not in record['checkoutIds']:
|
|
90
|
+
record['checkoutIds'].append(checkout['_id'])
|
|
91
|
+
self.update({'_id': record['_id']}, update, multi=False)
|
|
92
|
+
return record
|
|
93
|
+
|
|
94
|
+
def computeExpires(self, record):
|
|
95
|
+
"""
|
|
96
|
+
Return the latest expiration of any checkout that still covers the
|
|
97
|
+
record, or None if no checkout covers it any more.
|
|
98
|
+
"""
|
|
99
|
+
ids = [ObjectId(i) for i in record.get('checkoutIds', [])]
|
|
100
|
+
if not ids:
|
|
101
|
+
return None
|
|
102
|
+
latest = None
|
|
103
|
+
for checkout in Checkout().find({'_id': {'$in': ids}}, fields=['expires']):
|
|
104
|
+
if latest is None or checkout['expires'] > latest:
|
|
105
|
+
latest = checkout['expires']
|
|
106
|
+
return latest
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from girder.api import access
|
|
2
|
+
from girder.api.describe import Description, autoDescribeRoute
|
|
3
|
+
from girder.api.rest import Resource, boundHandler
|
|
4
|
+
from girder.constants import AccessType, SortDir, TokenScope
|
|
5
|
+
from girder.exceptions import GirderException, RestException
|
|
6
|
+
from girder.models.assetstore import Assetstore
|
|
7
|
+
from girder.models.folder import Folder
|
|
8
|
+
from girder.models.setting import Setting
|
|
9
|
+
from girder.models.user import User
|
|
10
|
+
from girder.utility import assetstore_utilities
|
|
11
|
+
from girder.utility import path as path_util
|
|
12
|
+
|
|
13
|
+
from . import tasks, utils
|
|
14
|
+
from .models import Checkout, CheckoutFile
|
|
15
|
+
from .settings import PluginSettings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _checkPermission(user):
|
|
19
|
+
if Setting().get(PluginSettings.ADMIN_ONLY) and not user.get('admin'):
|
|
20
|
+
raise RestException('Only administrators may checkout folders.', code=403)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _requireAssetstore(assetstoreId):
|
|
24
|
+
try:
|
|
25
|
+
return utils.resolveCheckoutAssetstore(assetstoreId)
|
|
26
|
+
except GirderException as exc:
|
|
27
|
+
raise RestException(str(exc))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _assetstoreInfo(assetstore):
|
|
31
|
+
capacity = assetstore_utilities.getAssetstoreAdapter(assetstore).capacityInfo()
|
|
32
|
+
return {'_id': assetstore['_id'], 'name': assetstore['name'], 'free': capacity.get('free')}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@access.user(scope=TokenScope.DATA_READ)
|
|
36
|
+
@boundHandler
|
|
37
|
+
@autoDescribeRoute(
|
|
38
|
+
Description('Get the local checkout status of a folder.')
|
|
39
|
+
.modelParam('id', model=Folder, level=AccessType.READ)
|
|
40
|
+
.param('details', 'Whether to count the files in the folder that can be or '
|
|
41
|
+
'have been made local. This can be slow for large folders.',
|
|
42
|
+
dataType='boolean', default=True, required=False)
|
|
43
|
+
.errorResponse('ID was invalid.')
|
|
44
|
+
.errorResponse('Read access was denied for the folder.', 403)
|
|
45
|
+
)
|
|
46
|
+
def getFolderCheckout(self, folder, details):
|
|
47
|
+
assetstore = utils.getTargetAssetstore()
|
|
48
|
+
# When there is no configured or default filesystem assetstore, the user
|
|
49
|
+
# chooses one of these
|
|
50
|
+
choices = [] if assetstore else [
|
|
51
|
+
_assetstoreInfo(choice) for choice in utils.getFilesystemAssetstores()]
|
|
52
|
+
result = {
|
|
53
|
+
'checkout': Checkout().findByFolder(folder),
|
|
54
|
+
'enabled': assetstore is not None or bool(choices),
|
|
55
|
+
'assetstoreName': assetstore['name'] if assetstore else None,
|
|
56
|
+
'assetstores': choices,
|
|
57
|
+
'maxDays': Setting().get(PluginSettings.MAX_DAYS),
|
|
58
|
+
'adminOnly': Setting().get(PluginSettings.ADMIN_ONLY),
|
|
59
|
+
}
|
|
60
|
+
if details:
|
|
61
|
+
result.update(utils.folderStatus(folder))
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@access.user(scope=TokenScope.DATA_WRITE)
|
|
66
|
+
@boundHandler
|
|
67
|
+
@autoDescribeRoute(
|
|
68
|
+
Description('Checkout a folder, making local copies of its S3 files for a number of days.')
|
|
69
|
+
.notes('If the folder is already checked out, its duration is changed. A '
|
|
70
|
+
'duration of 0 days releases the checkout. Copying is done in a '
|
|
71
|
+
'background job.')
|
|
72
|
+
.modelParam('id', model=Folder, level=AccessType.WRITE)
|
|
73
|
+
.param('days', 'The number of days to keep local copies.', dataType='integer')
|
|
74
|
+
.param('assetstoreId', 'The filesystem assetstore to copy files to. This is only '
|
|
75
|
+
'used when no assetstore is configured for local checkouts and the default '
|
|
76
|
+
'assetstore is not a filesystem assetstore; it may be omitted if there is only '
|
|
77
|
+
'one filesystem assetstore.', required=False)
|
|
78
|
+
.param('progress', 'Whether to record progress on the copy.', dataType='boolean',
|
|
79
|
+
default=True, required=False)
|
|
80
|
+
.errorResponse('ID was invalid.')
|
|
81
|
+
.errorResponse('Write access was denied for the folder.', 403)
|
|
82
|
+
)
|
|
83
|
+
def setFolderCheckout(self, folder, days, assetstoreId, progress):
|
|
84
|
+
user = self.getCurrentUser()
|
|
85
|
+
_checkPermission(user)
|
|
86
|
+
if days <= 0:
|
|
87
|
+
return _release(user, folder, progress)
|
|
88
|
+
maxDays = Setting().get(PluginSettings.MAX_DAYS)
|
|
89
|
+
if days > maxDays:
|
|
90
|
+
raise RestException('The checkout duration may not exceed %d days.' % maxDays)
|
|
91
|
+
checkout = Checkout().findByFolder(folder)
|
|
92
|
+
if not assetstoreId and checkout is not None:
|
|
93
|
+
assetstoreId = checkout.get('assetstoreId')
|
|
94
|
+
assetstore = _requireAssetstore(assetstoreId)
|
|
95
|
+
if checkout is None:
|
|
96
|
+
checkout = Checkout().createCheckout(folder, user, days, assetstore)
|
|
97
|
+
else:
|
|
98
|
+
checkout = utils.updateCheckoutExpiry(checkout, days, assetstore)
|
|
99
|
+
tasks.scheduleCheckout(checkout, folder, user, progress)
|
|
100
|
+
return checkout
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@access.user(scope=TokenScope.DATA_WRITE)
|
|
104
|
+
@boundHandler
|
|
105
|
+
@autoDescribeRoute(
|
|
106
|
+
Description('Release the local checkout of a folder.')
|
|
107
|
+
.notes('Local copies that no other checkout covers are removed in a background job.')
|
|
108
|
+
.modelParam('id', model=Folder, level=AccessType.WRITE)
|
|
109
|
+
.param('progress', 'Whether to record progress on the release.', dataType='boolean',
|
|
110
|
+
default=True, required=False)
|
|
111
|
+
.errorResponse('ID was invalid.')
|
|
112
|
+
.errorResponse('Write access was denied for the folder.', 403)
|
|
113
|
+
)
|
|
114
|
+
def deleteFolderCheckout(self, folder, progress):
|
|
115
|
+
user = self.getCurrentUser()
|
|
116
|
+
_checkPermission(user)
|
|
117
|
+
return _release(user, folder, progress)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _release(user, folder, progress):
|
|
121
|
+
checkout = Checkout().findByFolder(folder)
|
|
122
|
+
if checkout is None:
|
|
123
|
+
return {'released': False}
|
|
124
|
+
Checkout().remove(checkout)
|
|
125
|
+
tasks.scheduleDetach(checkout, folder, user, progress)
|
|
126
|
+
return {'released': True}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class LocalCheckout(Resource):
|
|
130
|
+
"""Administrative endpoints for local checkouts."""
|
|
131
|
+
|
|
132
|
+
def __init__(self):
|
|
133
|
+
super().__init__()
|
|
134
|
+
self.resourceName = 'local_checkout'
|
|
135
|
+
self.route('GET', (), self.listCheckouts)
|
|
136
|
+
self.route('GET', ('settings',), self.getSettings)
|
|
137
|
+
self.route('POST', ('cleanup',), self.cleanup)
|
|
138
|
+
|
|
139
|
+
@access.public
|
|
140
|
+
@autoDescribeRoute(
|
|
141
|
+
Description('Get the settings that affect whether a user may checkout folders.')
|
|
142
|
+
)
|
|
143
|
+
def getSettings(self):
|
|
144
|
+
return {
|
|
145
|
+
'adminOnly': Setting().get(PluginSettings.ADMIN_ONLY),
|
|
146
|
+
'maxDays': Setting().get(PluginSettings.MAX_DAYS),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@access.admin(scope=TokenScope.DATA_READ)
|
|
150
|
+
@autoDescribeRoute(
|
|
151
|
+
Description('List all local checkouts and summarize the space they use.')
|
|
152
|
+
.pagingParams(defaultSort='expires', defaultSortDir=SortDir.ASCENDING)
|
|
153
|
+
)
|
|
154
|
+
def listCheckouts(self, limit, offset, sort):
|
|
155
|
+
user = self.getCurrentUser()
|
|
156
|
+
checkouts = list(Checkout().find({}, limit=limit, offset=offset, sort=sort))
|
|
157
|
+
for checkout in checkouts:
|
|
158
|
+
folder = Folder().load(checkout['folderId'], force=True)
|
|
159
|
+
checkout['folderPath'] = (
|
|
160
|
+
path_util.getResourcePath('folder', folder, user=user, force=True)
|
|
161
|
+
if folder else None)
|
|
162
|
+
owner = User().load(checkout['userId'], force=True) if checkout.get('userId') else None
|
|
163
|
+
checkout['userLogin'] = owner['login'] if owner else None
|
|
164
|
+
assetstore = (Assetstore().load(checkout['assetstoreId'])
|
|
165
|
+
if checkout.get('assetstoreId') else None)
|
|
166
|
+
checkout['assetstoreName'] = assetstore['name'] if assetstore else None
|
|
167
|
+
|
|
168
|
+
perAssetstore = {
|
|
169
|
+
entry['_id']: entry for entry in CheckoutFile().collection.aggregate([
|
|
170
|
+
{'$group': {'_id': '$localAssetstoreId', 'count': {'$sum': 1},
|
|
171
|
+
'bytes': {'$sum': '$size'}}}
|
|
172
|
+
])}
|
|
173
|
+
# Summarize the assetstores that hold local copies or can receive them
|
|
174
|
+
target = utils.getTargetAssetstore()
|
|
175
|
+
candidates = [target] if target else utils.getFilesystemAssetstores()
|
|
176
|
+
candidateIds = {assetstore['_id'] for assetstore in candidates}
|
|
177
|
+
candidates += [assetstore for assetstore in (
|
|
178
|
+
Assetstore().load(assetstoreId) for assetstoreId in perAssetstore
|
|
179
|
+
if assetstoreId not in candidateIds) if assetstore is not None]
|
|
180
|
+
assetstores = []
|
|
181
|
+
for assetstore in candidates:
|
|
182
|
+
totals = perAssetstore.get(assetstore['_id'], {'count': 0, 'bytes': 0})
|
|
183
|
+
assetstores.append({
|
|
184
|
+
'id': assetstore['_id'],
|
|
185
|
+
'name': assetstore['name'],
|
|
186
|
+
'capacity': assetstore_utilities.getAssetstoreAdapter(
|
|
187
|
+
assetstore).capacityInfo(),
|
|
188
|
+
'localFileCount': totals['count'],
|
|
189
|
+
'localBytes': totals['bytes'],
|
|
190
|
+
})
|
|
191
|
+
return {
|
|
192
|
+
'checkouts': checkouts,
|
|
193
|
+
'localFileCount': sum(entry['count'] for entry in perAssetstore.values()),
|
|
194
|
+
'localBytes': sum(entry['bytes'] for entry in perAssetstore.values()),
|
|
195
|
+
'assetstores': assetstores,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@access.admin(scope=TokenScope.DATA_WRITE)
|
|
199
|
+
@autoDescribeRoute(
|
|
200
|
+
Description('Remove expired checkouts and release their local copies now.')
|
|
201
|
+
.notes('This is otherwise done daily and before each checkout.')
|
|
202
|
+
)
|
|
203
|
+
def cleanup(self):
|
|
204
|
+
return {'released': utils.cleanupExpired()}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from bson.objectid import ObjectId
|
|
2
|
+
|
|
3
|
+
from girder.constants import AssetstoreType
|
|
4
|
+
from girder.exceptions import ValidationException
|
|
5
|
+
from girder.models.assetstore import Assetstore
|
|
6
|
+
from girder.utility import setting_utilities
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PluginSettings:
|
|
10
|
+
# The ID of the filesystem assetstore that local copies are placed in. If
|
|
11
|
+
# empty, the current default assetstore is used when it is a filesystem
|
|
12
|
+
# assetstore.
|
|
13
|
+
ASSETSTORE_ID = 'local_checkout.assetstore_id'
|
|
14
|
+
# The longest checkout duration, in days, that may be requested.
|
|
15
|
+
MAX_DAYS = 'local_checkout.max_days'
|
|
16
|
+
# If true, only site administrators may checkout folders.
|
|
17
|
+
ADMIN_ONLY = 'local_checkout.admin_only'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@setting_utilities.default(PluginSettings.ASSETSTORE_ID)
|
|
21
|
+
def _defaultAssetstoreId():
|
|
22
|
+
return ''
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@setting_utilities.default(PluginSettings.MAX_DAYS)
|
|
26
|
+
def _defaultMaxDays():
|
|
27
|
+
return 90
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@setting_utilities.default(PluginSettings.ADMIN_ONLY)
|
|
31
|
+
def _defaultAdminOnly():
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@setting_utilities.validator(PluginSettings.ASSETSTORE_ID)
|
|
36
|
+
def _validateAssetstoreId(doc):
|
|
37
|
+
value = doc['value']
|
|
38
|
+
if value in (None, ''):
|
|
39
|
+
doc['value'] = ''
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
assetstore = Assetstore().load(ObjectId(str(value)))
|
|
43
|
+
except Exception:
|
|
44
|
+
assetstore = None
|
|
45
|
+
if assetstore is None:
|
|
46
|
+
raise ValidationException('Assetstore ID must refer to an existing assetstore.', 'value')
|
|
47
|
+
if assetstore['type'] != AssetstoreType.FILESYSTEM:
|
|
48
|
+
raise ValidationException('The checkout assetstore must be a filesystem assetstore.',
|
|
49
|
+
'value')
|
|
50
|
+
doc['value'] = str(assetstore['_id'])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@setting_utilities.validator(PluginSettings.MAX_DAYS)
|
|
54
|
+
def _validateMaxDays(doc):
|
|
55
|
+
try:
|
|
56
|
+
value = int(doc['value'])
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
raise ValidationException('Maximum checkout days must be an integer.', 'value')
|
|
59
|
+
if value < 1:
|
|
60
|
+
raise ValidationException('Maximum checkout days must be at least 1.', 'value')
|
|
61
|
+
doc['value'] = value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@setting_utilities.validator(PluginSettings.ADMIN_ONLY)
|
|
65
|
+
def _validateAdminOnly(doc):
|
|
66
|
+
if not isinstance(doc['value'], bool):
|
|
67
|
+
raise ValidationException('Admin-only setting must be true or false.', 'value')
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import traceback
|
|
4
|
+
|
|
5
|
+
from girder import events
|
|
6
|
+
from girder.models.folder import Folder
|
|
7
|
+
from girder.models.user import User
|
|
8
|
+
from girder.utility.progress import ProgressContext
|
|
9
|
+
from girder_jobs.constants import JobStatus
|
|
10
|
+
from girder_jobs.models.job import Job
|
|
11
|
+
|
|
12
|
+
from . import utils
|
|
13
|
+
from .models import Checkout
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _runInThread(target, name):
|
|
19
|
+
"""
|
|
20
|
+
Run a callable in a new daemon thread. Tests replace this to run the
|
|
21
|
+
work synchronously.
|
|
22
|
+
"""
|
|
23
|
+
threading.Thread(target=target, name=name, daemon=True).start()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _scheduleJob(title, jobType, function, user, kwargs):
|
|
27
|
+
"""
|
|
28
|
+
Create a local job that runs a function of this module and schedule it in
|
|
29
|
+
a thread of its own. The Girder events daemon is not used because it
|
|
30
|
+
handles one event at a time, and copying a large folder would hold up
|
|
31
|
+
everything else that runs on it.
|
|
32
|
+
"""
|
|
33
|
+
job = Job().createLocalJob(
|
|
34
|
+
title=title, type=jobType, user=user, public=False,
|
|
35
|
+
module=__name__, function=function, kwargs=kwargs)
|
|
36
|
+
_runInThread(lambda: Job().scheduleJob(job), 'local_checkout-%s' % job['_id'])
|
|
37
|
+
return job
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _runJob(job, work):
|
|
41
|
+
"""
|
|
42
|
+
Run the work of a local job, recording its outcome on the job document.
|
|
43
|
+
Failures are logged rather than raised, since the job runs in a thread
|
|
44
|
+
where nothing could handle them.
|
|
45
|
+
"""
|
|
46
|
+
Job().updateJob(job, status=JobStatus.RUNNING)
|
|
47
|
+
try:
|
|
48
|
+
result = work(**job['kwargs'])
|
|
49
|
+
except Exception:
|
|
50
|
+
logger.exception('Local checkout job %s failed', job['_id'])
|
|
51
|
+
Job().updateJob(job, status=JobStatus.ERROR, log=traceback.format_exc())
|
|
52
|
+
return None
|
|
53
|
+
Job().updateJob(job, status=JobStatus.SUCCESS,
|
|
54
|
+
log='' if result is None else str(result))
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def scheduleCheckout(checkout, folder, user, progress):
|
|
59
|
+
"""
|
|
60
|
+
Schedule the copying of a checkout's files.
|
|
61
|
+
"""
|
|
62
|
+
return _scheduleJob(
|
|
63
|
+
'Making folder "%s" local' % folder['name'], 'local_checkout.checkout',
|
|
64
|
+
'checkoutFolderTask', user, {
|
|
65
|
+
'checkoutId': str(checkout['_id']),
|
|
66
|
+
'userId': str(user['_id']),
|
|
67
|
+
'progress': progress,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def scheduleDetach(checkout, folder, user, progress):
|
|
72
|
+
"""
|
|
73
|
+
Schedule the release of the local copies a checkout no longer covers.
|
|
74
|
+
"""
|
|
75
|
+
return _scheduleJob(
|
|
76
|
+
'Releasing local copies of folder "%s"' % folder['name'],
|
|
77
|
+
'local_checkout.release', 'detachCheckoutTask', user, {
|
|
78
|
+
'checkoutId': str(checkout['_id']),
|
|
79
|
+
'userId': str(user['_id']),
|
|
80
|
+
'progress': progress,
|
|
81
|
+
'folderName': folder['name'],
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def scheduleCleanup():
|
|
86
|
+
"""
|
|
87
|
+
Run the cleanup of expired local copies on the events daemon.
|
|
88
|
+
"""
|
|
89
|
+
events.daemon.trigger(callback=cleanupTask)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _checkoutFolder(checkoutId, userId, progress):
|
|
93
|
+
user = User().load(userId, force=True)
|
|
94
|
+
checkout = Checkout().load(checkoutId)
|
|
95
|
+
if checkout is None:
|
|
96
|
+
return 'The checkout was released before it started.'
|
|
97
|
+
folder = Folder().load(checkout['folderId'], force=True)
|
|
98
|
+
title = 'Making folder "%s" local' % (folder['name'] if folder else checkout['folderId'])
|
|
99
|
+
with ProgressContext(progress, user=user, title=title) as ctx:
|
|
100
|
+
utils.runCheckout(checkout, ctx)
|
|
101
|
+
checkout = Checkout().load(checkoutId)
|
|
102
|
+
if checkout is None:
|
|
103
|
+
return 'The checkout was released while it was running.'
|
|
104
|
+
summary = '%d of %d files are local; status: %s.' % (
|
|
105
|
+
checkout['localCount'], checkout['fileCount'], checkout['status'])
|
|
106
|
+
if checkout.get('message'):
|
|
107
|
+
summary += ' ' + checkout['message']
|
|
108
|
+
return summary
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _detachCheckout(checkoutId, userId, progress, folderName):
|
|
112
|
+
user = User().load(userId, force=True)
|
|
113
|
+
with ProgressContext(progress, user=user,
|
|
114
|
+
title='Releasing local copies of folder "%s"' % folderName) as ctx:
|
|
115
|
+
released = utils.detachCheckout(checkoutId)
|
|
116
|
+
ctx.update(message='Done', force=True)
|
|
117
|
+
return 'Released %d local %s.' % (released, 'copy' if released == 1 else 'copies')
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def checkoutFolderTask(job):
|
|
121
|
+
return _runJob(job, _checkoutFolder)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def detachCheckoutTask(job):
|
|
125
|
+
return _runJob(job, _detachCheckout)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cleanupTask(event=None):
|
|
129
|
+
try:
|
|
130
|
+
return utils.cleanupExpired()
|
|
131
|
+
except Exception:
|
|
132
|
+
logger.exception('Failed to clean up expired local checkouts')
|