1
|
|
# Python
|
2
|
3
|
import re
|
3
|
3
|
import cgi
|
4
|
3
|
import dateutil
|
5
|
3
|
import dateutil.relativedelta
|
6
|
3
|
import datetime
|
7
|
3
|
import logging
|
8
|
3
|
import tzcron
|
9
|
3
|
import pytz
|
10
|
3
|
from base64 import b64encode
|
11
|
3
|
from collections import OrderedDict
|
12
|
|
|
13
|
|
# Django
|
14
|
3
|
from django.conf import settings as dsettings
|
15
|
3
|
from django.core.exceptions import FieldError
|
16
|
3
|
from django.db import IntegrityError, transaction
|
17
|
3
|
from django.utils.safestring import mark_safe
|
18
|
3
|
from django.utils.timezone import now
|
19
|
3
|
from django.views.decorators.csrf import csrf_exempt
|
20
|
3
|
from django.template.loader import render_to_string
|
21
|
3
|
from django.http import HttpResponse
|
22
|
3
|
from django.utils.translation import ugettext_lazy as _
|
23
|
|
|
24
|
|
# Django REST Framework
|
25
|
3
|
from rest_framework.exceptions import PermissionDenied, ParseError
|
26
|
3
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
27
|
3
|
from rest_framework.response import Response
|
28
|
3
|
from rest_framework.settings import api_settings
|
29
|
3
|
from rest_framework.views import exception_handler
|
30
|
3
|
from rest_framework import status, renderers
|
31
|
|
|
32
|
|
# CyBorgBackup
|
33
|
3
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
34
|
|
|
35
|
3
|
from cyborgbackup.api.generics import (APIView, GenericAPIView, ListAPIView,
|
36
|
|
ListCreateAPIView, SubListAPIView, RetrieveAPIView,
|
37
|
|
RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView,
|
38
|
|
get_view_name)
|
39
|
3
|
from cyborgbackup.main.models import reverse
|
40
|
3
|
from cyborgbackup.main.models.events import JobEvent
|
41
|
3
|
from cyborgbackup.main.models.catalogs import Catalog
|
42
|
3
|
from cyborgbackup.main.models.clients import Client
|
43
|
3
|
from cyborgbackup.main.models.schedules import Schedule
|
44
|
3
|
from cyborgbackup.main.models.repositories import Repository
|
45
|
3
|
from cyborgbackup.main.models.policies import Policy
|
46
|
3
|
from cyborgbackup.main.models.jobs import Job
|
47
|
3
|
from cyborgbackup.main.models.users import User
|
48
|
3
|
from cyborgbackup.main.models.settings import Setting
|
49
|
3
|
from cyborgbackup.main.utils.common import get_module_provider, camelcase_to_underscore, get_cyborgbackup_version
|
50
|
3
|
from cyborgbackup.main.utils.callbacks import CallbackQueueDispatcher
|
51
|
3
|
from cyborgbackup.main.modules import Querier
|
52
|
3
|
from cyborgbackup.api.renderers import (BrowsableAPIRenderer, PlainTextRenderer,
|
53
|
|
DownloadTextRenderer, AnsiDownloadRenderer, AnsiTextRenderer)
|
54
|
3
|
from cyborgbackup.api.serializers import (EmptySerializer, UserSerializer,
|
55
|
|
JobSerializer, JobStdoutSerializer, JobCancelSerializer,
|
56
|
|
JobRelaunchSerializer, JobListSerializer, JobEventSerializer,
|
57
|
|
SettingSerializer, SettingListSerializer,
|
58
|
|
ClientSerializer, ClientListSerializer, ScheduleSerializer,
|
59
|
|
ScheduleListSerializer, RepositorySerializer, RepositoryListSerializer,
|
60
|
|
PolicySerializer, PolicyLaunchSerializer, PolicyModuleSerializer,
|
61
|
|
PolicyCalendarSerializer, PolicyVMModuleSerializer,
|
62
|
|
CatalogSerializer, CatalogListSerializer, StatsSerializer,
|
63
|
|
CyborgTokenObtainPairSerializer, RestoreLaunchSerializer)
|
64
|
3
|
from cyborgbackup.main.constants import ACTIVE_STATES
|
65
|
3
|
from cyborgbackup.api.permissions import UserPermission
|
66
|
|
|
67
|
3
|
import ansiconv
|
68
|
3
|
from wsgiref.util import FileWrapper
|
69
|
|
|
70
|
3
|
logger = logging.getLogger('cyborgbackup.api.views')
|
71
|
|
|
72
|
|
|
73
|
3
|
def api_exception_handler(exc, context):
|
74
|
|
'''
|
75
|
|
Override default API exception handler to catch IntegrityError exceptions.
|
76
|
|
'''
|
77
|
3
|
if isinstance(exc, IntegrityError):
|
78
|
0
|
exc = ParseError(exc.args[0])
|
79
|
3
|
if isinstance(exc, FieldError):
|
80
|
0
|
exc = ParseError(exc.args[0])
|
81
|
3
|
return exception_handler(exc, context)
|
82
|
|
|
83
|
|
|
84
|
3
|
class JobDeletionMixin(object):
|
85
|
|
'''
|
86
|
|
Special handling when deleting a running job object.
|
87
|
|
'''
|
88
|
3
|
def destroy(self, request, *args, **kwargs):
|
89
|
0
|
obj = self.get_object()
|
90
|
0
|
try:
|
91
|
0
|
if obj.job_node.workflow_job.status in ACTIVE_STATES:
|
92
|
0
|
raise PermissionDenied(detail=_('Cannot delete job resource when associated workflow job is running.'))
|
93
|
0
|
except self.model.job_node.RelatedObjectDoesNotExist:
|
94
|
0
|
pass
|
95
|
|
# Still allow deletion of new status, because these can be manually created
|
96
|
0
|
if obj.status in ACTIVE_STATES and obj.status != 'new':
|
97
|
0
|
raise PermissionDenied(detail=_("Cannot delete running job resource."))
|
98
|
0
|
elif not obj.event_processing_finished:
|
99
|
|
# Prohibit deletion if job events are still coming in
|
100
|
0
|
if obj.finished and now() < obj.finished + dateutil.relativedelta.relativedelta(minutes=1):
|
101
|
|
# less than 1 minute has passed since job finished and events are not in
|
102
|
0
|
return Response({"error": _("Job has not finished processing events.")},
|
103
|
|
status=status.HTTP_400_BAD_REQUEST)
|
104
|
|
else:
|
105
|
|
# if it has been > 1 minute, events are probably lost
|
106
|
0
|
logger.warning('Allowing deletion of {} through the API without all events '
|
107
|
|
'processed.'.format(obj.log_format))
|
108
|
0
|
obj.delete()
|
109
|
0
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
110
|
|
|
111
|
|
|
112
|
3
|
class ApiRootView(APIView):
|
113
|
|
|
114
|
3
|
permission_classes = (AllowAny,)
|
115
|
3
|
view_name = _('REST API')
|
116
|
3
|
versioning_class = None
|
117
|
3
|
swagger_topic = 'Versioning'
|
118
|
|
|
119
|
3
|
def get(self, request, format=None):
|
120
|
|
''' List supported API versions '''
|
121
|
|
|
122
|
3
|
v1 = reverse('api:api_v1_root_view', kwargs={'version': 'v1'})
|
123
|
3
|
data = dict(
|
124
|
|
description=_('CyBorgBackup API Rest'),
|
125
|
|
current_version=v1,
|
126
|
|
available_versions=dict(v1=v1),
|
127
|
|
)
|
128
|
3
|
return Response(data)
|
129
|
|
|
130
|
|
|
131
|
3
|
class ApiVersionRootView(APIView):
|
132
|
|
|
133
|
3
|
permission_classes = (AllowAny,)
|
134
|
3
|
swagger_topic = 'Versioning'
|
135
|
|
|
136
|
3
|
def get(self, request, format=None):
|
137
|
|
''' List top level resources '''
|
138
|
3
|
data = OrderedDict()
|
139
|
3
|
data['ping'] = reverse('api:api_v1_ping_view', request=request)
|
140
|
3
|
data['config'] = reverse('api:api_v1_config_view', request=request)
|
141
|
3
|
data['me'] = reverse('api:user_me_list', request=request)
|
142
|
3
|
data['users'] = reverse('api:user_list', request=request)
|
143
|
3
|
data['jobs'] = reverse('api:job_list', request=request)
|
144
|
3
|
data['job_events'] = reverse('api:job_event_list', request=request)
|
145
|
3
|
data['settings'] = reverse('api:setting_list', request=request)
|
146
|
3
|
data['clients'] = reverse('api:client_list', request=request)
|
147
|
3
|
data['schedules'] = reverse('api:schedule_list', request=request)
|
148
|
3
|
data['repositories'] = reverse('api:repository_list', request=request)
|
149
|
3
|
data['policies'] = reverse('api:policy_list', request=request)
|
150
|
3
|
data['restore'] = reverse('api:restore_launch', request=request)
|
151
|
3
|
data['catalogs'] = reverse('api:catalog_list', request=request)
|
152
|
3
|
data['stats'] = reverse('api:stats', request=request)
|
153
|
3
|
data['escatalogs'] = reverse('api:escatalog_list', request=request)
|
154
|
3
|
return Response(data)
|
155
|
|
|
156
|
|
|
157
|
3
|
class ApiV1RootView(ApiVersionRootView):
|
158
|
3
|
view_name = _('Version 1')
|
159
|
|
|
160
|
|
|
161
|
3
|
class ApiV1PingView(APIView):
|
162
|
|
"""A simple view that reports very basic information about this
|
163
|
|
instance, which is acceptable to be public information.
|
164
|
|
"""
|
165
|
3
|
permission_classes = (AllowAny,)
|
166
|
3
|
authentication_classes = ()
|
167
|
3
|
view_name = _('Ping')
|
168
|
3
|
swagger_topic = 'System Configuration'
|
169
|
|
|
170
|
3
|
def get(self, request, format=None):
|
171
|
|
"""Return some basic information about this instance
|
172
|
|
|
173
|
|
Everything returned here should be considered public / insecure, as
|
174
|
|
this requires no auth and is intended for use by the installer process.
|
175
|
|
"""
|
176
|
3
|
response = {
|
177
|
|
'version': get_cyborgbackup_version(),
|
178
|
|
}
|
179
|
|
|
180
|
3
|
response['ping'] = "pong"
|
181
|
3
|
return Response(response)
|
182
|
|
|
183
|
|
|
184
|
3
|
class ApiV1ConfigView(APIView):
|
185
|
|
|
186
|
3
|
permission_classes = (IsAuthenticated,)
|
187
|
3
|
view_name = _('Configuration')
|
188
|
3
|
swagger_topic = 'System Configuration'
|
189
|
|
|
190
|
3
|
def check_permissions(self, request):
|
191
|
3
|
super(ApiV1ConfigView, self).check_permissions(request)
|
192
|
3
|
if not request.user.is_superuser and request.method.lower() not in {'options', 'head', 'get'}:
|
193
|
0
|
self.permission_denied(request) # Raises PermissionDenied exception.
|
194
|
|
|
195
|
3
|
def get(self, request, format=None):
|
196
|
|
'''Return various sitewide configuration settings'''
|
197
|
|
|
198
|
3
|
data = dict(
|
199
|
|
time_zone=dsettings.TIME_ZONE,
|
200
|
|
debug=dsettings.DEBUG,
|
201
|
|
sql_debug=dsettings.SQL_DEBUG,
|
202
|
|
allowed_hosts=dsettings.ALLOWED_HOSTS,
|
203
|
|
# beat_schedule=dsettings.CELERY_BEAT_SCHEDULE,
|
204
|
|
version=get_cyborgbackup_version(),
|
205
|
|
)
|
206
|
|
|
207
|
3
|
return Response(data)
|
208
|
|
|
209
|
|
|
210
|
3
|
class AuthView(APIView):
|
211
|
|
''' List enabled single-sign-on endpoints '''
|
212
|
|
|
213
|
3
|
authentication_classes = []
|
214
|
3
|
permission_classes = (AllowAny,)
|
215
|
3
|
swagger_topic = 'System Configuration'
|
216
|
|
|
217
|
3
|
def get(self, request):
|
218
|
0
|
data = OrderedDict()
|
219
|
0
|
err_backend, err_message = request.session.get('social_auth_error', (None, None))
|
220
|
0
|
return Response(data)
|
221
|
|
|
222
|
|
|
223
|
3
|
class UserList(ListCreateAPIView):
|
224
|
|
|
225
|
3
|
model = User
|
226
|
3
|
serializer_class = UserSerializer
|
227
|
3
|
permission_classes = (UserPermission,)
|
228
|
|
|
229
|
3
|
def post(self, request, *args, **kwargs):
|
230
|
0
|
ret = super(UserList, self).post(request, *args, **kwargs)
|
231
|
0
|
return ret
|
232
|
|
|
233
|
|
|
234
|
3
|
class UserMeList(ListAPIView):
|
235
|
|
|
236
|
3
|
model = User
|
237
|
3
|
serializer_class = UserSerializer
|
238
|
3
|
view_name = _('Me')
|
239
|
|
|
240
|
3
|
def get_queryset(self):
|
241
|
3
|
return self.model.objects.filter(pk=self.request.user.pk)
|
242
|
|
|
243
|
|
|
244
|
3
|
class UserDetail(RetrieveUpdateDestroyAPIView):
|
245
|
|
|
246
|
3
|
model = User
|
247
|
3
|
serializer_class = UserSerializer
|
248
|
|
|
249
|
3
|
def update_filter(self, request, *args, **kwargs):
|
250
|
|
''' make sure non-read-only fields that can only be edited by admins, are only edited by admins '''
|
251
|
0
|
obj = self.get_object()
|
252
|
|
|
253
|
0
|
su_only_edit_fields = ('is_superuser')
|
254
|
|
# admin_only_edit_fields = ('username', 'is_active')
|
255
|
|
|
256
|
0
|
fields_to_check = ()
|
257
|
0
|
if not request.user.is_superuser:
|
258
|
0
|
fields_to_check += su_only_edit_fields
|
259
|
|
|
260
|
0
|
bad_changes = {}
|
261
|
0
|
for field in fields_to_check:
|
262
|
0
|
left = getattr(obj, field, None)
|
263
|
0
|
right = request.data.get(field, None)
|
264
|
0
|
if left is not None and right is not None and left != right:
|
265
|
0
|
bad_changes[field] = (left, right)
|
266
|
0
|
if bad_changes:
|
267
|
0
|
raise PermissionDenied(_('Cannot change %s.') % ', '.join(bad_changes.keys()))
|
268
|
|
|
269
|
|
|
270
|
3
|
class StdoutANSIFilter(object):
|
271
|
|
|
272
|
3
|
def __init__(self, fileobj):
|
273
|
0
|
self.fileobj = fileobj
|
274
|
0
|
self.extra_data = ''
|
275
|
0
|
if hasattr(fileobj, 'close'):
|
276
|
0
|
self.close = fileobj.close
|
277
|
|
|
278
|
3
|
def read(self, size=-1):
|
279
|
0
|
data = self.extra_data
|
280
|
0
|
while size > 0 and len(data) < size:
|
281
|
0
|
line = self.fileobj.readline(size)
|
282
|
0
|
if not line:
|
283
|
0
|
break
|
284
|
|
# Remove ANSI escape sequences used to embed event data.
|
285
|
0
|
line = re.sub(r'\x1b\[K(?:[A-Za-z0-9+/=]+\x1b\[\d+D)+\x1b\[K', '', line)
|
286
|
|
# Remove ANSI color escape sequences.
|
287
|
0
|
line = re.sub(r'\x1b[^m]*m', '', line)
|
288
|
0
|
data += line
|
289
|
0
|
if size > 0 and len(data) > size:
|
290
|
0
|
self.extra_data = data[size:]
|
291
|
0
|
data = data[:size]
|
292
|
|
else:
|
293
|
0
|
self.extra_data = ''
|
294
|
0
|
return data
|
295
|
|
|
296
|
|
|
297
|
3
|
class JobList(ListCreateAPIView):
|
298
|
|
|
299
|
3
|
model = Job
|
300
|
3
|
serializer_class = JobListSerializer
|
301
|
|
|
302
|
3
|
@property
|
303
|
|
def allowed_methods(self):
|
304
|
3
|
methods = super(JobList, self).allowed_methods
|
305
|
3
|
return methods
|
306
|
|
|
307
|
|
# NOTE: Remove in 3.3, switch ListCreateAPIView to ListAPIView
|
308
|
3
|
def post(self, request, *args, **kwargs):
|
309
|
0
|
return super(JobList, self).post(request, *args, **kwargs)
|
310
|
|
|
311
|
|
|
312
|
3
|
class JobDetail(JobDeletionMixin, RetrieveUpdateDestroyAPIView):
|
313
|
|
|
314
|
3
|
model = Job
|
315
|
3
|
serializer_class = JobSerializer
|
316
|
|
|
317
|
3
|
def update(self, request, *args, **kwargs):
|
318
|
0
|
obj = self.get_object()
|
319
|
|
# Only allow changes (PUT/PATCH) when job status is "new".
|
320
|
0
|
if obj.status != 'new':
|
321
|
0
|
return self.http_method_not_allowed(request, *args, **kwargs)
|
322
|
0
|
return super(JobDetail, self).update(request, *args, **kwargs)
|
323
|
|
|
324
|
|
|
325
|
3
|
class StdoutMaxBytesExceeded(Exception):
|
326
|
|
|
327
|
3
|
def __init__(self, total, supported):
|
328
|
0
|
self.total = total
|
329
|
0
|
self.supported = supported
|
330
|
|
|
331
|
|
|
332
|
3
|
class JobStdout(RetrieveAPIView):
|
333
|
|
|
334
|
3
|
model = Job
|
335
|
3
|
authentication_classes = api_settings.DEFAULT_AUTHENTICATION_CLASSES
|
336
|
3
|
serializer_class = JobStdoutSerializer
|
337
|
3
|
renderer_classes = [BrowsableAPIRenderer, renderers.StaticHTMLRenderer,
|
338
|
|
PlainTextRenderer, AnsiTextRenderer,
|
339
|
|
renderers.JSONRenderer, DownloadTextRenderer, AnsiDownloadRenderer]
|
340
|
3
|
filter_backends = ()
|
341
|
|
|
342
|
3
|
def retrieve(self, request, *args, **kwargs):
|
343
|
0
|
job = self.get_object()
|
344
|
0
|
try:
|
345
|
0
|
target_format = request.accepted_renderer.format
|
346
|
0
|
if target_format in ('html', 'api', 'json'):
|
347
|
0
|
content_format = request.query_params.get('content_format', 'html')
|
348
|
0
|
content_encoding = request.query_params.get('content_encoding', None)
|
349
|
0
|
start_line = request.query_params.get('start_line', 0)
|
350
|
0
|
end_line = request.query_params.get('end_line', None)
|
351
|
0
|
dark_val = request.query_params.get('dark', '')
|
352
|
0
|
dark = bool(dark_val and dark_val[0].lower() in ('1', 't', 'y'))
|
353
|
0
|
content_only = bool(target_format in ('api', 'json'))
|
354
|
0
|
dark_bg = (content_only and dark) or (not content_only and (dark or not dark_val))
|
355
|
0
|
content, start, end, absolute_end = job.result_stdout_raw_limited(start_line, end_line)
|
356
|
|
|
357
|
|
# Remove any ANSI escape sequences containing job event data.
|
358
|
0
|
content = re.sub(r'\x1b\[K(?:[A-Za-z0-9+/=]+\x1b\[\d+D)+\x1b\[K', '', content)
|
359
|
|
|
360
|
0
|
body = ansiconv.to_html(cgi.escape(content))
|
361
|
|
|
362
|
0
|
context = {
|
363
|
|
'title': get_view_name(self.__class__),
|
364
|
|
'body': mark_safe(body),
|
365
|
|
'dark': dark_bg,
|
366
|
|
'content_only': content_only,
|
367
|
|
}
|
368
|
0
|
data = render_to_string('api/stdout.html', context).strip()
|
369
|
|
|
370
|
0
|
if target_format == 'api':
|
371
|
0
|
return Response(mark_safe(data))
|
372
|
0
|
if target_format == 'json':
|
373
|
0
|
if content_encoding == 'base64' and content_format == 'ansi':
|
374
|
0
|
return Response({'range': {'start': start, 'end': end, 'absolute_end': absolute_end},
|
375
|
|
'content': b64encode(content.encode('utf-8'))})
|
376
|
0
|
elif content_format == 'html':
|
377
|
0
|
return Response({'range': {'start': start, 'end': end, 'absolute_end': absolute_end},
|
378
|
|
'content': body})
|
379
|
0
|
return Response(data)
|
380
|
0
|
elif target_format == 'txt':
|
381
|
0
|
return Response(job.result_stdout)
|
382
|
0
|
elif target_format == 'ansi':
|
383
|
0
|
return Response(job.result_stdout_raw)
|
384
|
0
|
elif target_format in {'txt_download', 'ansi_download'}:
|
385
|
0
|
filename = '{type}_{pk}{suffix}.txt'.format(
|
386
|
|
type=camelcase_to_underscore(job.__class__.__name__),
|
387
|
|
pk=job.id,
|
388
|
|
suffix='.ansi' if target_format == 'ansi_download' else ''
|
389
|
|
)
|
390
|
0
|
content_fd = job.result_stdout_raw_handle(enforce_max_bytes=False)
|
391
|
0
|
if target_format == 'txt_download':
|
392
|
0
|
content_fd = StdoutANSIFilter(content_fd)
|
393
|
0
|
response = HttpResponse(FileWrapper(content_fd), content_type='text/plain')
|
394
|
0
|
response["Content-Disposition"] = 'attachment; filename="{}"'.format(filename)
|
395
|
0
|
return response
|
396
|
|
else:
|
397
|
0
|
return super(JobStdout, self).retrieve(request, *args, **kwargs)
|
398
|
0
|
except StdoutMaxBytesExceeded as e:
|
399
|
0
|
response_message = _(
|
400
|
|
"Standard Output too large to display ({text_size} bytes), "
|
401
|
|
"only download supported for sizes over {supported_size} bytes.").format(
|
402
|
|
text_size=e.total, supported_size=e.supported
|
403
|
|
)
|
404
|
0
|
if request.accepted_renderer.format == 'json':
|
405
|
0
|
return Response({'range': {'start': 0, 'end': 1, 'absolute_end': 1}, 'content': response_message})
|
406
|
|
else:
|
407
|
0
|
return Response(response_message)
|
408
|
|
|
409
|
|
|
410
|
3
|
class JobStart(GenericAPIView):
|
411
|
|
|
412
|
3
|
model = Job
|
413
|
3
|
obj_permission_type = 'start'
|
414
|
3
|
serializer_class = EmptySerializer
|
415
|
3
|
deprecated = True
|
416
|
|
|
417
|
3
|
def get(self, request, *args, **kwargs):
|
418
|
0
|
obj = self.get_object()
|
419
|
0
|
data = dict(
|
420
|
|
can_start=obj.can_start,
|
421
|
|
)
|
422
|
0
|
return Response(data)
|
423
|
|
|
424
|
3
|
def post(self, request, *args, **kwargs):
|
425
|
0
|
obj = self.get_object()
|
426
|
0
|
if obj.can_start:
|
427
|
0
|
result = obj.signal_start(**request.data)
|
428
|
0
|
if not result:
|
429
|
0
|
return Response(request.data, status=status.HTTP_400_BAD_REQUEST)
|
430
|
|
else:
|
431
|
0
|
return Response(status=status.HTTP_202_ACCEPTED)
|
432
|
|
else:
|
433
|
0
|
return self.http_method_not_allowed(request, *args, **kwargs)
|
434
|
|
|
435
|
|
|
436
|
3
|
class JobCancel(RetrieveAPIView):
|
437
|
|
|
438
|
3
|
model = Job
|
439
|
3
|
obj_permission_type = 'cancel'
|
440
|
3
|
serializer_class = JobCancelSerializer
|
441
|
|
|
442
|
3
|
def post(self, request, *args, **kwargs):
|
443
|
0
|
obj = self.get_object()
|
444
|
0
|
if obj.can_cancel:
|
445
|
0
|
obj.cancel()
|
446
|
0
|
return Response(status=status.HTTP_202_ACCEPTED)
|
447
|
|
else:
|
448
|
0
|
return self.http_method_not_allowed(request, *args, **kwargs)
|
449
|
|
|
450
|
|
|
451
|
3
|
class JobRelaunch(RetrieveAPIView):
|
452
|
|
|
453
|
3
|
model = Job
|
454
|
3
|
obj_permission_type = 'start'
|
455
|
3
|
serializer_class = JobRelaunchSerializer
|
456
|
|
|
457
|
3
|
def update_raw_data(self, data):
|
458
|
0
|
data = super(JobRelaunch, self).update_raw_data(data)
|
459
|
0
|
try:
|
460
|
0
|
self.get_object()
|
461
|
0
|
except PermissionDenied:
|
462
|
0
|
return data
|
463
|
0
|
return data
|
464
|
|
|
465
|
3
|
@csrf_exempt
|
466
|
3
|
@transaction.non_atomic_requests
|
467
|
|
def dispatch(self, *args, **kwargs):
|
468
|
0
|
return super(JobRelaunch, self).dispatch(*args, **kwargs)
|
469
|
|
|
470
|
3
|
def check_object_permissions(self, request, obj):
|
471
|
0
|
return super(JobRelaunch, self).check_object_permissions(request, obj)
|
472
|
|
|
473
|
3
|
def post(self, request, *args, **kwargs):
|
474
|
0
|
obj = self.get_object()
|
475
|
0
|
context = self.get_serializer_context()
|
476
|
|
|
477
|
0
|
modified_data = request.data.copy()
|
478
|
0
|
serializer = self.serializer_class(data=modified_data, context=context, instance=obj)
|
479
|
0
|
if not serializer.is_valid():
|
480
|
0
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
481
|
|
|
482
|
0
|
jobs = Job.objects.filter(client=obj.client_id)
|
483
|
0
|
if jobs.exists():
|
484
|
0
|
for job in jobs:
|
485
|
0
|
if job.status in ['waiting', 'pending', 'running']:
|
486
|
0
|
return Response({'detail': 'Backup job already running for this client.'},
|
487
|
|
status=status.HTTP_400_BAD_REQUEST)
|
488
|
|
|
489
|
0
|
copy_kwargs = {}
|
490
|
|
|
491
|
0
|
new_job = obj.copy_job(**copy_kwargs)
|
492
|
0
|
result = new_job.signal_start()
|
493
|
0
|
if not result:
|
494
|
0
|
data = dict(msg=_('Error starting job!'))
|
495
|
0
|
new_job.delete()
|
496
|
0
|
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
497
|
|
else:
|
498
|
0
|
data = JobSerializer(new_job, context=context).data
|
499
|
|
# Add job key to match what old relaunch returned.
|
500
|
0
|
data['job'] = new_job.id
|
501
|
0
|
headers = {'Location': new_job.get_absolute_url(request=request)}
|
502
|
0
|
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
|
503
|
|
|
504
|
|
|
505
|
3
|
class JobEventList(ListAPIView):
|
506
|
|
|
507
|
3
|
model = JobEvent
|
508
|
3
|
serializer_class = JobEventSerializer
|
509
|
|
|
510
|
|
|
511
|
3
|
class JobEventDetail(RetrieveAPIView):
|
512
|
|
|
513
|
3
|
model = JobEvent
|
514
|
3
|
serializer_class = JobEventSerializer
|
515
|
|
|
516
|
|
|
517
|
3
|
class BaseJobEventsList(SubListAPIView):
|
518
|
|
|
519
|
3
|
model = JobEvent
|
520
|
3
|
serializer_class = JobEventSerializer
|
521
|
3
|
parent_model = None # Subclasses must define this attribute.
|
522
|
3
|
relationship = 'job_events'
|
523
|
3
|
view_name = _('Job Events List')
|
524
|
3
|
search_fields = ('stdout',)
|
525
|
|
|
526
|
3
|
def finalize_response(self, request, response, *args, **kwargs):
|
527
|
0
|
response['X-UI-Max-Events'] = 4000
|
528
|
0
|
return super(BaseJobEventsList, self).finalize_response(request, response, *args, **kwargs)
|
529
|
|
|
530
|
|
|
531
|
3
|
class JobJobEventsList(BaseJobEventsList):
|
532
|
|
|
533
|
3
|
parent_model = Job
|
534
|
|
|
535
|
3
|
def get_queryset(self):
|
536
|
0
|
job = self.get_parent_object()
|
537
|
0
|
self.check_parent_access(job)
|
538
|
0
|
qs = job.job_events
|
539
|
0
|
return qs.all()
|
540
|
|
|
541
|
|
|
542
|
3
|
class SettingList(ListAPIView):
|
543
|
|
|
544
|
3
|
model = Setting
|
545
|
3
|
serializer_class = SettingListSerializer
|
546
|
|
|
547
|
3
|
@property
|
548
|
|
def allowed_methods(self):
|
549
|
3
|
methods = super(SettingList, self).allowed_methods
|
550
|
3
|
return methods
|
551
|
|
|
552
|
|
|
553
|
3
|
class SettingDetail(RetrieveUpdateAPIView):
|
554
|
|
|
555
|
3
|
model = Setting
|
556
|
3
|
serializer_class = SettingSerializer
|
557
|
|
|
558
|
|
|
559
|
3
|
class ClientList(ListCreateAPIView):
|
560
|
|
|
561
|
3
|
model = Client
|
562
|
3
|
serializer_class = ClientListSerializer
|
563
|
|
|
564
|
3
|
@property
|
565
|
|
def allowed_methods(self):
|
566
|
3
|
methods = super(ClientList, self).allowed_methods
|
567
|
3
|
return methods
|
568
|
|
|
569
|
|
|
570
|
3
|
class ClientDetail(RetrieveUpdateDestroyAPIView):
|
571
|
|
|
572
|
3
|
model = Client
|
573
|
3
|
serializer_class = ClientSerializer
|
574
|
|
|
575
|
3
|
def patch(self, request, *args, **kwargs):
|
576
|
3
|
obj = self.get_object()
|
577
|
3
|
logger.debug(request.data)
|
578
|
|
|
579
|
3
|
serializer = self.serializer_class(obj, data=request.data, partial=True)
|
580
|
3
|
if not serializer.is_valid():
|
581
|
0
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
582
|
|
|
583
|
3
|
policies = Policy.objects.all()
|
584
|
3
|
if policies.exists() and 'policies' in request.data.keys():
|
585
|
0
|
for pol in policies:
|
586
|
0
|
if pol.id in request.data['policies'] and len([x for x in pol.clients.all() if x.id == obj.id]) == 0:
|
587
|
0
|
logger.debug('Add client to policy {}'.format(pol.name))
|
588
|
0
|
pol.clients
|
589
|
0
|
if len([x for x in pol.clients.all() if x.id == obj.id]) > 0 and pol.id not in request.data['policies']:
|
590
|
0
|
logger.debug('Remove client from policy {}'.format(pol.name))
|
591
|
|
|
592
|
3
|
return super(ClientDetail, self).patch(request, *args, **kwargs)
|
593
|
|
|
594
|
|
|
595
|
3
|
class ScheduleList(ListCreateAPIView):
|
596
|
|
|
597
|
3
|
model = Schedule
|
598
|
3
|
serializer_class = ScheduleListSerializer
|
599
|
|
|
600
|
3
|
@property
|
601
|
|
def allowed_methods(self):
|
602
|
3
|
methods = super(ScheduleList, self).allowed_methods
|
603
|
3
|
return methods
|
604
|
|
|
605
|
|
|
606
|
3
|
class ScheduleDetail(RetrieveUpdateDestroyAPIView):
|
607
|
|
|
608
|
3
|
model = Schedule
|
609
|
3
|
serializer_class = ScheduleSerializer
|
610
|
|
|
611
|
|
|
612
|
3
|
class RepositoryList(ListCreateAPIView):
|
613
|
|
|
614
|
3
|
model = Repository
|
615
|
3
|
serializer_class = RepositoryListSerializer
|
616
|
|
|
617
|
3
|
@property
|
618
|
|
def allowed_methods(self):
|
619
|
3
|
methods = super(RepositoryList, self).allowed_methods
|
620
|
3
|
return methods
|
621
|
|
|
622
|
|
|
623
|
3
|
class RepositoryDetail(RetrieveUpdateDestroyAPIView):
|
624
|
|
|
625
|
3
|
model = Repository
|
626
|
3
|
serializer_class = RepositorySerializer
|
627
|
|
|
628
|
|
|
629
|
3
|
class PolicyList(ListCreateAPIView):
|
630
|
|
|
631
|
3
|
model = Policy
|
632
|
3
|
serializer_class = PolicySerializer
|
633
|
|
|
634
|
|
|
635
|
3
|
class PolicyDetail(RetrieveUpdateDestroyAPIView):
|
636
|
|
|
637
|
3
|
model = Policy
|
638
|
3
|
serializer_class = PolicySerializer
|
639
|
|
|
640
|
|
|
641
|
3
|
class PolicyVMModule(ListAPIView):
|
642
|
|
|
643
|
3
|
model = Policy
|
644
|
3
|
serializer_class = PolicyVMModuleSerializer
|
645
|
|
|
646
|
3
|
def list(self, request, *args, **kwargs):
|
647
|
3
|
data = get_module_provider()
|
648
|
3
|
return Response(data)
|
649
|
|
|
650
|
|
|
651
|
3
|
class PolicyModule(ListCreateAPIView):
|
652
|
|
|
653
|
3
|
model = Policy
|
654
|
3
|
serializer_class = PolicyModuleSerializer
|
655
|
|
|
656
|
3
|
def callModule(self, request, args, kwargs):
|
657
|
0
|
module = kwargs['module']
|
658
|
0
|
client_id = kwargs['client']
|
659
|
0
|
data = {}
|
660
|
0
|
if module == 'vm':
|
661
|
0
|
data = get_module_provider()
|
662
|
|
else:
|
663
|
0
|
client = Client.objects.get(pk=client_id)
|
664
|
0
|
if client:
|
665
|
0
|
q = Querier()
|
666
|
0
|
params = {**request.query_params.dict(), **request.data}
|
667
|
0
|
data = q.querier(module, client, params)
|
668
|
0
|
if data == -1:
|
669
|
0
|
return Response({}, status=status.HTTP_204_NO_CONTENT)
|
670
|
0
|
return Response(data)
|
671
|
|
|
672
|
3
|
def list(self, request, *args, **kwargs):
|
673
|
0
|
return self.callModule(request, args, kwargs)
|
674
|
|
|
675
|
3
|
def post(self, request, *args, **kwargs):
|
676
|
0
|
return self.callModule(request, args, kwargs)
|
677
|
|
|
678
|
|
|
679
|
3
|
class PolicyCalendar(ListAPIView):
|
680
|
|
|
681
|
3
|
model = Policy
|
682
|
3
|
serializer_class = PolicyCalendarSerializer
|
683
|
|
|
684
|
3
|
def list(self, request, *args, **kwargs):
|
685
|
3
|
obj = self.get_object()
|
686
|
3
|
now = datetime.datetime.now(pytz.utc)
|
687
|
3
|
start_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
688
|
3
|
year = now.year
|
689
|
3
|
if start_month.month == 12:
|
690
|
3
|
year += 1
|
691
|
3
|
relative_month = dateutil.relativedelta.relativedelta(months=1)
|
692
|
3
|
end_month = datetime.datetime(year, (start_month + relative_month).month, 1) - datetime.timedelta(days=1)
|
693
|
3
|
end_month = end_month.replace(hour=23, minute=59, second=50, tzinfo=pytz.utc)
|
694
|
3
|
schedule = tzcron.Schedule(obj.schedule.crontab, pytz.utc, start_month, end_month)
|
695
|
3
|
return Response([s.isoformat() for s in schedule])
|
696
|
|
|
697
|
|
|
698
|
3
|
class PolicyLaunch(RetrieveAPIView):
|
699
|
|
|
700
|
3
|
model = Policy
|
701
|
3
|
serializer_class = PolicyLaunchSerializer
|
702
|
|
|
703
|
3
|
def update_raw_data(self, data):
|
704
|
0
|
obj = self.get_object()
|
705
|
0
|
extra_vars = data.pop('extra_vars', None) or {}
|
706
|
0
|
if obj:
|
707
|
0
|
data['extra_vars'] = extra_vars
|
708
|
0
|
return data
|
709
|
|
|
710
|
3
|
def post(self, request, *args, **kwargs):
|
711
|
0
|
obj = self.get_object()
|
712
|
|
|
713
|
0
|
serializer = self.serializer_class(data=request.data, context={'job': obj})
|
714
|
0
|
if not serializer.is_valid():
|
715
|
0
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
716
|
|
|
717
|
0
|
for client in obj.clients.all():
|
718
|
0
|
jobs = Job.objects.filter(client=client.pk)
|
719
|
0
|
if jobs.exists():
|
720
|
0
|
for job in jobs:
|
721
|
0
|
if job.status in ['waiting', 'pending', 'running']:
|
722
|
0
|
return Response({'detail': 'Backup job already running for theses clients.'},
|
723
|
|
status=status.HTTP_400_BAD_REQUEST)
|
724
|
|
|
725
|
0
|
new_job = obj.create_job(**serializer.validated_data)
|
726
|
0
|
result = new_job.signal_start()
|
727
|
|
|
728
|
0
|
if not result:
|
729
|
0
|
data = OrderedDict()
|
730
|
0
|
new_job.delete()
|
731
|
0
|
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
732
|
|
else:
|
733
|
0
|
data = OrderedDict()
|
734
|
0
|
data['job'] = new_job.id
|
735
|
0
|
data.update(JobSerializer(new_job, context=self.get_serializer_context()).to_representation(new_job))
|
736
|
0
|
return Response(data, status=status.HTTP_201_CREATED)
|
737
|
|
|
738
|
3
|
def sanitize_for_response(self, data):
|
739
|
|
'''
|
740
|
|
Model objects cannot be serialized by DRF,
|
741
|
|
this replaces objects with their ids for inclusion in response
|
742
|
|
'''
|
743
|
|
|
744
|
0
|
def display_value(val):
|
745
|
0
|
if hasattr(val, 'id'):
|
746
|
0
|
return val.id
|
747
|
|
else:
|
748
|
0
|
return val
|
749
|
|
|
750
|
0
|
sanitized_data = {}
|
751
|
0
|
for field_name, value in data.items():
|
752
|
0
|
if isinstance(value, (set, list)):
|
753
|
0
|
sanitized_data[field_name] = []
|
754
|
0
|
for sub_value in value:
|
755
|
0
|
sanitized_data[field_name].append(display_value(sub_value))
|
756
|
|
else:
|
757
|
0
|
sanitized_data[field_name] = display_value(value)
|
758
|
|
|
759
|
0
|
return sanitized_data
|
760
|
|
|
761
|
|
|
762
|
3
|
class RestoreLaunch(ListCreateAPIView):
|
763
|
|
|
764
|
3
|
model = Job
|
765
|
3
|
serializer_class = RestoreLaunchSerializer
|
766
|
|
|
767
|
3
|
def list(self, request, *args, **kwargs):
|
768
|
0
|
data = []
|
769
|
0
|
return Response(data)
|
770
|
|
|
771
|
3
|
def create(self, request, *args, **kwargs):
|
772
|
0
|
result = None
|
773
|
0
|
serializer = self.serializer_class(data=request.data)
|
774
|
0
|
if not serializer.is_valid():
|
775
|
0
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
776
|
|
|
777
|
0
|
obj = serializer.validated_data
|
778
|
0
|
jobs = Job.objects.filter(archive_name=obj['archive_name'])
|
779
|
0
|
if jobs.exists():
|
780
|
0
|
job = jobs[0]
|
781
|
0
|
client = job.client
|
782
|
|
|
783
|
0
|
jobs_client = Job.objects.filter(client=client.pk)
|
784
|
0
|
if jobs_client.exists():
|
785
|
0
|
for job_client in jobs_client:
|
786
|
0
|
if job_client.status in ['waiting', 'pending', 'running']:
|
787
|
0
|
return Response({'detail': 'Backup job already running for this client.'},
|
788
|
|
status=status.HTTP_400_BAD_REQUEST)
|
789
|
|
|
790
|
0
|
extra_vars = {
|
791
|
|
'item': serializer.validated_data['item'],
|
792
|
|
'dest': serializer.validated_data['destination'],
|
793
|
|
'dry_run': serializer.validated_data['dry_run'],
|
794
|
|
'dest_folder': serializer.validated_data['dest_folder']
|
795
|
|
}
|
796
|
|
|
797
|
0
|
new_job = job.policy.create_restore_job(source_job=job, extra_vars=extra_vars)
|
798
|
|
|
799
|
0
|
result = new_job.signal_start()
|
800
|
|
|
801
|
0
|
if not result:
|
802
|
0
|
data = OrderedDict()
|
803
|
0
|
new_job.delete()
|
804
|
0
|
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
805
|
|
else:
|
806
|
0
|
data = OrderedDict()
|
807
|
0
|
data['job'] = new_job.id
|
808
|
0
|
data.update(JobSerializer(new_job, context=self.get_serializer_context()).to_representation(new_job))
|
809
|
0
|
return Response(data, status=status.HTTP_201_CREATED)
|
810
|
|
|
811
|
|
|
812
|
3
|
class CatalogList(ListCreateAPIView):
|
813
|
|
|
814
|
3
|
model = Catalog
|
815
|
3
|
serializer_class = CatalogListSerializer
|
816
|
|
|
817
|
3
|
def create(self, request, *args, **kwargs):
|
818
|
0
|
data = request.data
|
819
|
0
|
if set(data.keys()).intersection(['archive_name', 'job', 'event', 'catalog']):
|
820
|
0
|
callback = CallbackQueueDispatcher()
|
821
|
0
|
callback.dispatch(data)
|
822
|
0
|
return Response(OrderedDict(), status=status.HTTP_201_CREATED)
|
823
|
|
|
824
|
0
|
return Response(OrderedDict(), status=status.HTTP_400_BAD_REQUEST)
|
825
|
|
|
826
|
|
|
827
|
3
|
class CatalogDetail(RetrieveUpdateDestroyAPIView):
|
828
|
|
|
829
|
3
|
model = Catalog
|
830
|
3
|
serializer_class = CatalogSerializer
|
831
|
|
|
832
|
|
|
833
|
3
|
class Stats(ListAPIView):
|
834
|
|
|
835
|
3
|
model = Job
|
836
|
3
|
serializer_class = StatsSerializer
|
837
|
|
|
838
|
3
|
def list(self, request, *args, **kwargs):
|
839
|
3
|
data = []
|
840
|
3
|
now = datetime.datetime.now(pytz.utc)
|
841
|
3
|
last_30_days = now - datetime.timedelta(days=30)
|
842
|
3
|
jobs = Job.objects.filter(job_type='job', started__gte=last_30_days).order_by('started')
|
843
|
3
|
if jobs.exists():
|
844
|
0
|
for job in jobs:
|
845
|
0
|
stats_dates = [stat['date'] for stat in data]
|
846
|
0
|
if job.started.date() not in stats_dates:
|
847
|
0
|
data.append({'date': job.started.date(), 'size': 0, 'dedup': 0, 'success': 0, 'failed': 0})
|
848
|
0
|
for stat in data:
|
849
|
0
|
if stat['date'] == job.started.date():
|
850
|
0
|
stat['size'] += job.original_size
|
851
|
0
|
stat['dedup'] += job.deduplicated_size
|
852
|
0
|
if job.status == 'successful':
|
853
|
0
|
stat['success'] += 1
|
854
|
0
|
if job.status == 'failed':
|
855
|
0
|
stat['failed'] += 1
|
856
|
3
|
return Response(data)
|
857
|
|
|
858
|
|
|
859
|
3
|
class CyborgTokenObtainPairView(TokenObtainPairView):
|
860
|
3
|
serializer_class = CyborgTokenObtainPairSerializer
|