Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
singlestore-labs
GitHub Repository: singlestore-labs/singlestoredb-python
Path: blob/main/singlestoredb/management/workspace.py
801 views
1
#!/usr/bin/env python
2
"""SingleStoreDB Workspace Management."""
3
from __future__ import annotations
4
5
import datetime
6
import glob
7
import io
8
import os
9
import re
10
import time
11
from collections.abc import Mapping
12
from typing import Any
13
from typing import cast
14
from typing import Dict
15
from typing import List
16
from typing import Literal
17
from typing import Optional
18
from typing import overload
19
from typing import Union
20
21
from .. import config
22
from .. import connection
23
from ..exceptions import ManagementError
24
from .billing_usage import BillingUsageItem
25
from .files import FileLocation
26
from .files import FilesObject
27
from .files import FilesObjectBytesReader
28
from .files import FilesObjectBytesWriter
29
from .files import FilesObjectTextReader
30
from .files import FilesObjectTextWriter
31
from .manager import Manager
32
from .organization import Organization
33
from .region import Region
34
from .utils import camel_to_snake_dict
35
from .utils import from_datetime
36
from .utils import NamedList
37
from .utils import PathLike
38
from .utils import snake_to_camel
39
from .utils import snake_to_camel_dict
40
from .utils import to_datetime
41
from .utils import ttl_property
42
from .utils import vars_to_str
43
44
45
def get_organization() -> Organization:
46
"""Get the organization."""
47
return manage_workspaces().organization
48
49
50
def get_secret(name: str) -> Optional[str]:
51
"""Get a secret from the organization."""
52
return get_organization().get_secret(name).value
53
54
55
def get_workspace_group(
56
workspace_group: Optional[Union[WorkspaceGroup, str]] = None,
57
) -> WorkspaceGroup:
58
"""Get the stage for the workspace group."""
59
if isinstance(workspace_group, WorkspaceGroup):
60
return workspace_group
61
elif workspace_group:
62
return manage_workspaces().workspace_groups[workspace_group]
63
elif 'SINGLESTOREDB_WORKSPACE_GROUP' in os.environ:
64
return manage_workspaces().workspace_groups[
65
os.environ['SINGLESTOREDB_WORKSPACE_GROUP']
66
]
67
raise RuntimeError('no workspace group specified')
68
69
70
def get_stage(
71
workspace_group: Optional[Union[WorkspaceGroup, str]] = None,
72
) -> Stage:
73
"""Get the stage for the workspace group."""
74
return get_workspace_group(workspace_group).stage
75
76
77
def get_workspace(
78
workspace_group: Optional[Union[WorkspaceGroup, str]] = None,
79
workspace: Optional[Union[Workspace, str]] = None,
80
) -> Workspace:
81
"""Get the workspaces for a workspace_group."""
82
if isinstance(workspace, Workspace):
83
return workspace
84
wg = get_workspace_group(workspace_group)
85
if workspace:
86
return wg.workspaces[workspace]
87
elif 'SINGLESTOREDB_WORKSPACE' in os.environ:
88
return wg.workspaces[
89
os.environ['SINGLESTOREDB_WORKSPACE']
90
]
91
raise RuntimeError('no workspace group specified')
92
93
94
class Stage(FileLocation):
95
"""
96
Stage manager.
97
98
This object is not instantiated directly.
99
It is returned by ``WorkspaceGroup.stage`` or ``StarterWorkspace.stage``.
100
101
"""
102
103
def __init__(self, deployment_id: str, manager: WorkspaceManager):
104
self._deployment_id = deployment_id
105
self._manager = manager
106
107
def open(
108
self,
109
stage_path: PathLike,
110
mode: str = 'r',
111
encoding: Optional[str] = None,
112
) -> Union[io.StringIO, io.BytesIO]:
113
"""
114
Open a Stage path for reading or writing.
115
116
Parameters
117
----------
118
stage_path : Path or str
119
The stage path to read / write
120
mode : str, optional
121
The read / write mode. The following modes are supported:
122
* 'r' open for reading (default)
123
* 'w' open for writing, truncating the file first
124
* 'x' create a new file and open it for writing
125
The data type can be specified by adding one of the following:
126
* 'b' binary mode
127
* 't' text mode (default)
128
encoding : str, optional
129
The string encoding to use for text
130
131
Returns
132
-------
133
FilesObjectBytesReader - 'rb' or 'b' mode
134
FilesObjectBytesWriter - 'wb' or 'xb' mode
135
FilesObjectTextReader - 'r' or 'rt' mode
136
FilesObjectTextWriter - 'w', 'x', 'wt' or 'xt' mode
137
138
"""
139
if '+' in mode or 'a' in mode:
140
raise ValueError('modifying an existing stage file is not supported')
141
142
if 'w' in mode or 'x' in mode:
143
exists = self.exists(stage_path)
144
if exists:
145
if 'x' in mode:
146
raise FileExistsError(f'stage path already exists: {stage_path}')
147
self.remove(stage_path)
148
if 'b' in mode:
149
return FilesObjectBytesWriter(b'', self, stage_path)
150
return FilesObjectTextWriter('', self, stage_path)
151
152
if 'r' in mode:
153
content = self.download_file(stage_path)
154
if isinstance(content, bytes):
155
if 'b' in mode:
156
return FilesObjectBytesReader(content)
157
encoding = 'utf-8' if encoding is None else encoding
158
return FilesObjectTextReader(content.decode(encoding))
159
160
if isinstance(content, str):
161
return FilesObjectTextReader(content)
162
163
raise ValueError(f'unrecognized file content type: {type(content)}')
164
165
raise ValueError(f'must have one of create/read/write mode specified: {mode}')
166
167
def upload_file(
168
self,
169
local_path: Union[PathLike, io.IOBase],
170
stage_path: PathLike,
171
*,
172
overwrite: bool = False,
173
) -> FilesObject:
174
"""
175
Upload a local file.
176
177
Parameters
178
----------
179
local_path : Path or str or file-like
180
Path to the local file or an open file object
181
stage_path : Path or str
182
Path to the stage file
183
overwrite : bool, optional
184
Should the ``stage_path`` be overwritten if it exists already?
185
186
"""
187
if isinstance(local_path, io.IOBase):
188
pass
189
elif not os.path.isfile(local_path):
190
raise IsADirectoryError(f'local path is not a file: {local_path}')
191
192
if self.exists(stage_path):
193
if not overwrite:
194
raise OSError(f'stage path already exists: {stage_path}')
195
196
self.remove(stage_path)
197
198
if isinstance(local_path, io.IOBase):
199
return self._upload(local_path, stage_path, overwrite=overwrite)
200
201
return self._upload(open(local_path, 'rb'), stage_path, overwrite=overwrite)
202
203
def upload_folder(
204
self,
205
local_path: PathLike,
206
stage_path: PathLike,
207
*,
208
overwrite: bool = False,
209
recursive: bool = True,
210
include_root: bool = False,
211
ignore: Optional[Union[PathLike, List[PathLike]]] = None,
212
) -> FilesObject:
213
"""
214
Upload a folder recursively.
215
216
Only the contents of the folder are uploaded. To include the
217
folder name itself in the target path use ``include_root=True``.
218
219
Parameters
220
----------
221
local_path : Path or str
222
Local directory to upload
223
stage_path : Path or str
224
Path of stage folder to upload to
225
overwrite : bool, optional
226
If a file already exists, should it be overwritten?
227
recursive : bool, optional
228
Should nested folders be uploaded?
229
include_root : bool, optional
230
Should the local root folder itself be uploaded as the top folder?
231
ignore : Path or str or List[Path] or List[str], optional
232
Glob patterns of files to ignore, for example, ``**/*.pyc`` will
233
ignore all ``*.pyc`` files in the directory tree
234
235
"""
236
if not os.path.isdir(local_path):
237
raise NotADirectoryError(f'local path is not a directory: {local_path}')
238
if self.exists(stage_path) and not self.is_dir(stage_path):
239
raise NotADirectoryError(f'stage path is not a directory: {stage_path}')
240
241
ignore_files = set()
242
if ignore:
243
if isinstance(ignore, list):
244
for item in ignore:
245
ignore_files.update(glob.glob(str(item), recursive=recursive))
246
else:
247
ignore_files.update(glob.glob(str(ignore), recursive=recursive))
248
249
parent_dir = os.path.basename(os.getcwd())
250
251
files = glob.glob(os.path.join(local_path, '**'), recursive=recursive)
252
253
for src in files:
254
if ignore_files and src in ignore_files:
255
continue
256
target = os.path.join(parent_dir, src) if include_root else src
257
self.upload_file(src, target, overwrite=overwrite)
258
259
return self.info(stage_path)
260
261
def _upload(
262
self,
263
content: Union[str, bytes, io.IOBase],
264
stage_path: PathLike,
265
*,
266
overwrite: bool = False,
267
) -> FilesObject:
268
"""
269
Upload content to a stage file.
270
271
Parameters
272
----------
273
content : str or bytes or file-like
274
Content to upload to stage
275
stage_path : Path or str
276
Path to the stage file
277
overwrite : bool, optional
278
Should the ``stage_path`` be overwritten if it exists already?
279
280
"""
281
if self.exists(stage_path):
282
if not overwrite:
283
raise OSError(f'stage path already exists: {stage_path}')
284
self.remove(stage_path)
285
286
self._manager._put(
287
f'stage/{self._deployment_id}/fs/{stage_path}',
288
files={'file': content},
289
headers={'Content-Type': None},
290
)
291
292
return self.info(stage_path)
293
294
def mkdir(self, stage_path: PathLike, overwrite: bool = False) -> FilesObject:
295
"""
296
Make a directory in the stage.
297
298
Parameters
299
----------
300
stage_path : Path or str
301
Path of the folder to create
302
overwrite : bool, optional
303
Should the stage path be overwritten if it exists already?
304
305
Returns
306
-------
307
FilesObject
308
309
"""
310
stage_path = re.sub(r'/*$', r'', str(stage_path)) + '/'
311
312
if self.exists(stage_path):
313
if not overwrite:
314
return self.info(stage_path)
315
316
self.remove(stage_path)
317
318
self._manager._put(
319
f'stage/{self._deployment_id}/fs/{stage_path}?isFile=false',
320
)
321
322
return self.info(stage_path)
323
324
mkdirs = mkdir
325
326
def rename(
327
self,
328
old_path: PathLike,
329
new_path: PathLike,
330
*,
331
overwrite: bool = False,
332
) -> FilesObject:
333
"""
334
Move the stage file to a new location.
335
336
Paraemeters
337
-----------
338
old_path : Path or str
339
Original location of the path
340
new_path : Path or str
341
New location of the path
342
overwrite : bool, optional
343
Should the ``new_path`` be overwritten if it exists already?
344
345
"""
346
if not self.exists(old_path):
347
raise OSError(f'stage path does not exist: {old_path}')
348
349
if self.exists(new_path):
350
if not overwrite:
351
raise OSError(f'stage path already exists: {new_path}')
352
353
if str(old_path).endswith('/') and not str(new_path).endswith('/'):
354
raise OSError('original and new paths are not the same type')
355
356
if str(new_path).endswith('/'):
357
self.removedirs(new_path)
358
else:
359
self.remove(new_path)
360
361
self._manager._patch(
362
f'stage/{self._deployment_id}/fs/{old_path}',
363
json=dict(newPath=new_path),
364
)
365
366
return self.info(new_path)
367
368
def info(self, stage_path: PathLike) -> FilesObject:
369
"""
370
Return information about a stage location.
371
372
Parameters
373
----------
374
stage_path : Path or str
375
Path to the stage location
376
377
Returns
378
-------
379
FilesObject
380
381
"""
382
res = self._manager._get(
383
re.sub(r'/+$', r'/', f'stage/{self._deployment_id}/fs/{stage_path}'),
384
params=dict(metadata=1),
385
).json()
386
387
return FilesObject.from_dict(res, self)
388
389
def exists(self, stage_path: PathLike) -> bool:
390
"""
391
Does the given stage path exist?
392
393
Parameters
394
----------
395
stage_path : Path or str
396
Path to stage object
397
398
Returns
399
-------
400
bool
401
402
"""
403
try:
404
self.info(stage_path)
405
return True
406
except ManagementError as exc:
407
if exc.errno == 404:
408
return False
409
raise
410
411
def is_dir(self, stage_path: PathLike) -> bool:
412
"""
413
Is the given stage path a directory?
414
415
Parameters
416
----------
417
stage_path : Path or str
418
Path to stage object
419
420
Returns
421
-------
422
bool
423
424
"""
425
try:
426
return self.info(stage_path).type == 'directory'
427
except ManagementError as exc:
428
if exc.errno == 404:
429
return False
430
raise
431
432
def is_file(self, stage_path: PathLike) -> bool:
433
"""
434
Is the given stage path a file?
435
436
Parameters
437
----------
438
stage_path : Path or str
439
Path to stage object
440
441
Returns
442
-------
443
bool
444
445
"""
446
try:
447
return self.info(stage_path).type != 'directory'
448
except ManagementError as exc:
449
if exc.errno == 404:
450
return False
451
raise
452
453
def _listdir(
454
self, stage_path: PathLike, *,
455
recursive: bool = False,
456
return_objects: bool = False,
457
) -> List[Union[str, 'FilesObject']]:
458
"""
459
Return the names (or FilesObject instances) of files in a directory.
460
461
Parameters
462
----------
463
stage_path : Path or str
464
Path to the folder in Stage
465
recursive : bool, optional
466
Should folders be listed recursively?
467
return_objects : bool, optional
468
If True, return list of FilesObject instances. Otherwise just paths.
469
470
"""
471
from .files import FilesObject
472
res = self._manager._get(
473
re.sub(r'/+$', r'/', f'stage/{self._deployment_id}/fs/{stage_path}'),
474
).json()
475
if recursive:
476
out: List[Union[str, FilesObject]] = []
477
for item in res['content'] or []:
478
if return_objects:
479
out.append(FilesObject.from_dict(item, self))
480
else:
481
out.append(item['path'])
482
if item['type'] == 'directory':
483
out.extend(
484
self._listdir(
485
item['path'],
486
recursive=recursive,
487
return_objects=return_objects,
488
),
489
)
490
return out
491
if return_objects:
492
return [
493
FilesObject.from_dict(x, self)
494
for x in res['content'] or []
495
]
496
return [x['path'] for x in res['content'] or []]
497
498
@overload
499
def listdir(
500
self,
501
stage_path: PathLike = '/',
502
*,
503
recursive: bool = False,
504
return_objects: Literal[True],
505
) -> List['FilesObject']:
506
...
507
508
@overload
509
def listdir(
510
self,
511
stage_path: PathLike = '/',
512
*,
513
recursive: bool = False,
514
return_objects: Literal[False] = False,
515
) -> List[str]:
516
...
517
518
def listdir(
519
self,
520
stage_path: PathLike = '/',
521
*,
522
recursive: bool = False,
523
return_objects: bool = False,
524
) -> Union[List[str], List['FilesObject']]:
525
"""
526
List the files / folders at the given path.
527
528
Parameters
529
----------
530
stage_path : Path or str, optional
531
Path to the stage location
532
recursive : bool, optional
533
If True, recursively list all files and folders
534
return_objects : bool, optional
535
If True, return list of FilesObject instances. Otherwise just paths.
536
537
Returns
538
-------
539
List[str] or List[FilesObject]
540
541
"""
542
from .files import FilesObject
543
stage_path = re.sub(r'^(\./|/)+', r'', str(stage_path))
544
stage_path = re.sub(r'/+$', r'', stage_path) + '/'
545
546
if self.is_dir(stage_path):
547
out = self._listdir(
548
stage_path,
549
recursive=recursive,
550
return_objects=return_objects,
551
)
552
if stage_path != '/':
553
stage_path_n = len(stage_path.split('/')) - 1
554
if return_objects:
555
result: List[FilesObject] = []
556
for item in out:
557
if isinstance(item, FilesObject):
558
rel = '/'.join(item.path.split('/')[stage_path_n:])
559
item.path = rel
560
result.append(item)
561
return result
562
out = ['/'.join(str(x).split('/')[stage_path_n:]) for x in out]
563
if return_objects:
564
return cast(List[FilesObject], out)
565
return cast(List[str], out)
566
567
raise NotADirectoryError(f'stage path is not a directory: {stage_path}')
568
569
def download_file(
570
self,
571
stage_path: PathLike,
572
local_path: Optional[PathLike] = None,
573
*,
574
overwrite: bool = False,
575
encoding: Optional[str] = None,
576
) -> Optional[Union[bytes, str]]:
577
"""
578
Download the content of a stage path.
579
580
Parameters
581
----------
582
stage_path : Path or str
583
Path to the stage file
584
local_path : Path or str
585
Path to local file target location
586
overwrite : bool, optional
587
Should an existing file be overwritten if it exists?
588
encoding : str, optional
589
Encoding used to convert the resulting data
590
591
Returns
592
-------
593
bytes or str - ``local_path`` is None
594
None - ``local_path`` is a Path or str
595
596
"""
597
if local_path is not None and not overwrite and os.path.exists(local_path):
598
raise OSError('target file already exists; use overwrite=True to replace')
599
if self.is_dir(stage_path):
600
raise IsADirectoryError(f'stage path is a directory: {stage_path}')
601
602
out = self._manager._get(
603
f'stage/{self._deployment_id}/fs/{stage_path}',
604
).content
605
606
if local_path is not None:
607
with open(local_path, 'wb') as outfile:
608
outfile.write(out)
609
return None
610
611
if encoding:
612
return out.decode(encoding)
613
614
return out
615
616
def download_folder(
617
self,
618
stage_path: PathLike,
619
local_path: PathLike = '.',
620
*,
621
overwrite: bool = False,
622
) -> None:
623
"""
624
Download a Stage folder to a local directory.
625
626
Parameters
627
----------
628
stage_path : Path or str
629
Path to the stage file
630
local_path : Path or str
631
Path to local directory target location
632
overwrite : bool, optional
633
Should an existing directory / files be overwritten if they exist?
634
635
"""
636
if local_path is not None and not overwrite and os.path.exists(local_path):
637
raise OSError(
638
'target directory already exists; '
639
'use overwrite=True to replace',
640
)
641
if not self.is_dir(stage_path):
642
raise NotADirectoryError(f'stage path is not a directory: {stage_path}')
643
644
for f in self.listdir(stage_path, recursive=True, return_objects=False):
645
if self.is_dir(f):
646
continue
647
target = os.path.normpath(os.path.join(local_path, f))
648
os.makedirs(os.path.dirname(target), exist_ok=True)
649
self.download_file(f, target, overwrite=overwrite)
650
651
def remove(self, stage_path: PathLike) -> None:
652
"""
653
Delete a stage location.
654
655
Parameters
656
----------
657
stage_path : Path or str
658
Path to the stage location
659
660
"""
661
if self.is_dir(stage_path):
662
raise IsADirectoryError(
663
'stage path is a directory, '
664
f'use rmdir or removedirs: {stage_path}',
665
)
666
667
self._manager._delete(f'stage/{self._deployment_id}/fs/{stage_path}')
668
669
def removedirs(self, stage_path: PathLike) -> None:
670
"""
671
Delete a stage folder recursively.
672
673
Parameters
674
----------
675
stage_path : Path or str
676
Path to the stage location
677
678
"""
679
stage_path = re.sub(r'/*$', r'', str(stage_path)) + '/'
680
self._manager._delete(f'stage/{self._deployment_id}/fs/{stage_path}')
681
682
def rmdir(self, stage_path: PathLike) -> None:
683
"""
684
Delete a stage folder.
685
686
Parameters
687
----------
688
stage_path : Path or str
689
Path to the stage location
690
691
"""
692
stage_path = re.sub(r'/*$', r'', str(stage_path)) + '/'
693
694
if self.listdir(stage_path):
695
raise OSError(f'stage folder is not empty, use removedirs: {stage_path}')
696
697
self._manager._delete(f'stage/{self._deployment_id}/fs/{stage_path}')
698
699
def __str__(self) -> str:
700
"""Return string representation."""
701
return vars_to_str(self)
702
703
def __repr__(self) -> str:
704
"""Return string representation."""
705
return str(self)
706
707
708
StageObject = FilesObject # alias for backward compatibility
709
710
711
class Workspace(object):
712
"""
713
SingleStoreDB workspace definition.
714
715
This object is not instantiated directly. It is used in the results
716
of API calls on the :class:`WorkspaceManager`. Workspaces are created using
717
:meth:`WorkspaceManager.create_workspace`, or existing workspaces are
718
accessed by either :attr:`WorkspaceManager.workspaces` or by calling
719
:meth:`WorkspaceManager.get_workspace`.
720
721
See Also
722
--------
723
:meth:`WorkspaceManager.create_workspace`
724
:meth:`WorkspaceManager.get_workspace`
725
:attr:`WorkspaceManager.workspaces`
726
727
"""
728
729
name: str
730
id: str
731
group_id: str
732
size: str
733
state: str
734
created_at: Optional[datetime.datetime]
735
terminated_at: Optional[datetime.datetime]
736
endpoint: Optional[str]
737
auto_suspend: Optional[Dict[str, Any]]
738
cache_config: Optional[int]
739
deployment_type: Optional[str]
740
resume_attachments: Optional[List[Dict[str, Any]]]
741
scaling_progress: Optional[int]
742
last_resumed_at: Optional[datetime.datetime]
743
744
def __init__(
745
self,
746
name: str,
747
workspace_id: str,
748
workspace_group: Union[str, 'WorkspaceGroup'],
749
size: str,
750
state: str,
751
created_at: Union[str, datetime.datetime],
752
terminated_at: Optional[Union[str, datetime.datetime]] = None,
753
endpoint: Optional[str] = None,
754
auto_suspend: Optional[Dict[str, Any]] = None,
755
cache_config: Optional[int] = None,
756
deployment_type: Optional[str] = None,
757
resume_attachments: Optional[List[Dict[str, Any]]] = None,
758
scaling_progress: Optional[int] = None,
759
last_resumed_at: Optional[Union[str, datetime.datetime]] = None,
760
):
761
#: Name of the workspace
762
self.name = name
763
764
#: Unique ID of the workspace
765
self.id = workspace_id
766
767
#: Unique ID of the workspace group
768
if isinstance(workspace_group, WorkspaceGroup):
769
self.group_id = workspace_group.id
770
else:
771
self.group_id = workspace_group
772
773
#: Size of the workspace in workspace size notation (S-00, S-1, etc.)
774
self.size = size
775
776
#: State of the workspace: PendingCreation, Transitioning, Active,
777
#: Terminated, Suspended, Resuming, Failed
778
self.state = state.strip()
779
780
#: Timestamp of when the workspace was created
781
self.created_at = to_datetime(created_at)
782
783
#: Timestamp of when the workspace was terminated
784
self.terminated_at = to_datetime(terminated_at)
785
786
#: Hostname (or IP address) of the workspace database server
787
self.endpoint = endpoint
788
789
#: Current auto-suspend settings
790
self.auto_suspend = camel_to_snake_dict(auto_suspend)
791
792
#: Multiplier for the persistent cache
793
self.cache_config = cache_config
794
795
#: Deployment type of the workspace
796
self.deployment_type = deployment_type
797
798
#: Database attachments
799
self.resume_attachments = [
800
camel_to_snake_dict(x) # type: ignore
801
for x in resume_attachments or []
802
if x is not None
803
]
804
805
#: Current progress percentage for scaling the workspace
806
self.scaling_progress = scaling_progress
807
808
#: Timestamp when workspace was last resumed
809
self.last_resumed_at = to_datetime(last_resumed_at)
810
811
self._manager: Optional[WorkspaceManager] = None
812
813
def __str__(self) -> str:
814
"""Return string representation."""
815
return vars_to_str(self)
816
817
def __repr__(self) -> str:
818
"""Return string representation."""
819
return str(self)
820
821
@classmethod
822
def from_dict(cls, obj: Dict[str, Any], manager: 'WorkspaceManager') -> 'Workspace':
823
"""
824
Construct a Workspace from a dictionary of values.
825
826
Parameters
827
----------
828
obj : dict
829
Dictionary of values
830
manager : WorkspaceManager, optional
831
The WorkspaceManager the Workspace belongs to
832
833
Returns
834
-------
835
:class:`Workspace`
836
837
"""
838
out = cls(
839
name=obj['name'],
840
workspace_id=obj['workspaceID'],
841
workspace_group=obj['workspaceGroupID'],
842
size=obj.get('size', 'Unknown'),
843
state=obj['state'],
844
created_at=obj['createdAt'],
845
terminated_at=obj.get('terminatedAt'),
846
endpoint=obj.get('endpoint'),
847
auto_suspend=obj.get('autoSuspend'),
848
cache_config=obj.get('cacheConfig'),
849
deployment_type=obj.get('deploymentType'),
850
last_resumed_at=obj.get('lastResumedAt'),
851
resume_attachments=obj.get('resumeAttachments'),
852
scaling_progress=obj.get('scalingProgress'),
853
)
854
out._manager = manager
855
return out
856
857
def update(
858
self,
859
auto_suspend: Optional[Dict[str, Any]] = None,
860
cache_config: Optional[int] = None,
861
deployment_type: Optional[str] = None,
862
size: Optional[str] = None,
863
) -> None:
864
"""
865
Update the workspace definition.
866
867
Parameters
868
----------
869
auto_suspend : Dict[str, Any], optional
870
Auto-suspend mode for the workspace: IDLE, SCHEDULED, DISABLED
871
cache_config : int, optional
872
Specifies the multiplier for the persistent cache associated
873
with the workspace. If specified, it enables the cache configuration
874
multiplier. It can have one of the following values: 1, 2, or 4.
875
deployment_type : str, optional
876
The deployment type that will be applied to all the workspaces
877
within the group
878
size : str, optional
879
Size of the workspace (in workspace size notation), such as "S-1".
880
881
"""
882
if self._manager is None:
883
raise ManagementError(
884
msg='No workspace manager is associated with this object.',
885
)
886
data = {
887
k: v for k, v in dict(
888
autoSuspend=snake_to_camel_dict(auto_suspend),
889
cacheConfig=cache_config,
890
deploymentType=deployment_type,
891
size=size,
892
).items() if v is not None
893
}
894
self._manager._patch(f'workspaces/{self.id}', json=data)
895
self.refresh()
896
897
def refresh(self) -> Workspace:
898
"""Update the object to the current state."""
899
if self._manager is None:
900
raise ManagementError(
901
msg='No workspace manager is associated with this object.',
902
)
903
new_obj = self._manager.get_workspace(self.id)
904
for name, value in vars(new_obj).items():
905
if isinstance(value, Mapping):
906
setattr(self, name, snake_to_camel_dict(value))
907
else:
908
setattr(self, name, value)
909
return self
910
911
def terminate(
912
self,
913
wait_on_terminated: bool = False,
914
wait_interval: int = 10,
915
wait_timeout: int = 600,
916
force: bool = False,
917
) -> None:
918
"""
919
Terminate the workspace.
920
921
Parameters
922
----------
923
wait_on_terminated : bool, optional
924
Wait for the workspace to go into 'Terminated' mode before returning
925
wait_interval : int, optional
926
Number of seconds between each server check
927
wait_timeout : int, optional
928
Total number of seconds to check server before giving up
929
force : bool, optional
930
Should the workspace group be terminated even if it has workspaces?
931
932
Raises
933
------
934
ManagementError
935
If timeout is reached
936
937
"""
938
if self._manager is None:
939
raise ManagementError(
940
msg='No workspace manager is associated with this object.',
941
)
942
force_str = 'true' if force else 'false'
943
self._manager._delete(f'workspaces/{self.id}?force={force_str}')
944
if wait_on_terminated:
945
self._manager._wait_on_state(
946
self._manager.get_workspace(self.id),
947
'Terminated', interval=wait_interval, timeout=wait_timeout,
948
)
949
self.refresh()
950
951
def connect(self, **kwargs: Any) -> connection.Connection:
952
"""
953
Create a connection to the database server for this workspace.
954
955
Parameters
956
----------
957
**kwargs : keyword-arguments, optional
958
Parameters to the SingleStoreDB `connect` function except host
959
and port which are supplied by the workspace object
960
961
Returns
962
-------
963
:class:`Connection`
964
965
"""
966
if not self.endpoint:
967
raise ManagementError(
968
msg='An endpoint has not been set in this workspace configuration',
969
)
970
kwargs['host'] = self.endpoint
971
return connection.connect(**kwargs)
972
973
def suspend(
974
self,
975
wait_on_suspended: bool = False,
976
wait_interval: int = 20,
977
wait_timeout: int = 600,
978
) -> None:
979
"""
980
Suspend the workspace.
981
982
Parameters
983
----------
984
wait_on_suspended : bool, optional
985
Wait for the workspace to go into 'Suspended' mode before returning
986
wait_interval : int, optional
987
Number of seconds between each server check
988
wait_timeout : int, optional
989
Total number of seconds to check server before giving up
990
991
Raises
992
------
993
ManagementError
994
If timeout is reached
995
996
"""
997
if self._manager is None:
998
raise ManagementError(
999
msg='No workspace manager is associated with this object.',
1000
)
1001
self._manager._post(f'workspaces/{self.id}/suspend')
1002
if wait_on_suspended:
1003
self._manager._wait_on_state(
1004
self._manager.get_workspace(self.id),
1005
'Suspended', interval=wait_interval, timeout=wait_timeout,
1006
)
1007
self.refresh()
1008
1009
def resume(
1010
self,
1011
disable_auto_suspend: bool = False,
1012
wait_on_resumed: bool = False,
1013
wait_interval: int = 20,
1014
wait_timeout: int = 600,
1015
) -> None:
1016
"""
1017
Resume the workspace.
1018
1019
Parameters
1020
----------
1021
disable_auto_suspend : bool, optional
1022
Should auto-suspend be disabled?
1023
wait_on_resumed : bool, optional
1024
Wait for the workspace to go into 'Resumed' or 'Active' mode before returning
1025
wait_interval : int, optional
1026
Number of seconds between each server check
1027
wait_timeout : int, optional
1028
Total number of seconds to check server before giving up
1029
1030
Raises
1031
------
1032
ManagementError
1033
If timeout is reached
1034
1035
"""
1036
if self._manager is None:
1037
raise ManagementError(
1038
msg='No workspace manager is associated with this object.',
1039
)
1040
self._manager._post(
1041
f'workspaces/{self.id}/resume',
1042
json=dict(disableAutoSuspend=disable_auto_suspend),
1043
)
1044
if wait_on_resumed:
1045
self._manager._wait_on_state(
1046
self._manager.get_workspace(self.id),
1047
['Resumed', 'Active'], interval=wait_interval, timeout=wait_timeout,
1048
)
1049
self.refresh()
1050
1051
1052
class WorkspaceGroup(object):
1053
"""
1054
SingleStoreDB workspace group definition.
1055
1056
This object is not instantiated directly. It is used in the results
1057
of API calls on the :class:`WorkspaceManager`. Workspace groups are created using
1058
:meth:`WorkspaceManager.create_workspace_group`, or existing workspace groups are
1059
accessed by either :attr:`WorkspaceManager.workspace_groups` or by calling
1060
:meth:`WorkspaceManager.get_workspace_group`.
1061
1062
See Also
1063
--------
1064
:meth:`WorkspaceManager.create_workspace_group`
1065
:meth:`WorkspaceManager.get_workspace_group`
1066
:attr:`WorkspaceManager.workspace_groups`
1067
1068
"""
1069
1070
name: str
1071
id: str
1072
created_at: Optional[datetime.datetime]
1073
region: Optional[Region]
1074
firewall_ranges: List[str]
1075
terminated_at: Optional[datetime.datetime]
1076
allow_all_traffic: bool
1077
1078
def __init__(
1079
self,
1080
name: str,
1081
id: str,
1082
created_at: Union[str, datetime.datetime],
1083
region: Optional[Region],
1084
firewall_ranges: List[str],
1085
terminated_at: Optional[Union[str, datetime.datetime]],
1086
allow_all_traffic: Optional[bool],
1087
):
1088
#: Name of the workspace group
1089
self.name = name
1090
1091
#: Unique ID of the workspace group
1092
self.id = id
1093
1094
#: Timestamp of when the workspace group was created
1095
self.created_at = to_datetime(created_at)
1096
1097
#: Region of the workspace group (see :class:`Region`)
1098
self.region = region
1099
1100
#: List of allowed incoming IP addresses / ranges
1101
self.firewall_ranges = firewall_ranges
1102
1103
#: Timestamp of when the workspace group was terminated
1104
self.terminated_at = to_datetime(terminated_at)
1105
1106
#: Should all traffic be allowed?
1107
self.allow_all_traffic = allow_all_traffic or False
1108
1109
self._manager: Optional[WorkspaceManager] = None
1110
1111
def __str__(self) -> str:
1112
"""Return string representation."""
1113
return vars_to_str(self)
1114
1115
def __repr__(self) -> str:
1116
"""Return string representation."""
1117
return str(self)
1118
1119
@classmethod
1120
def from_dict(
1121
cls, obj: Dict[str, Any], manager: 'WorkspaceManager',
1122
) -> 'WorkspaceGroup':
1123
"""
1124
Construct a WorkspaceGroup from a dictionary of values.
1125
1126
Parameters
1127
----------
1128
obj : dict
1129
Dictionary of values
1130
manager : WorkspaceManager, optional
1131
The WorkspaceManager the WorkspaceGroup belongs to
1132
1133
Returns
1134
-------
1135
:class:`WorkspaceGroup`
1136
1137
"""
1138
try:
1139
region = [x for x in manager.regions if x.id == obj['regionID']][0]
1140
except IndexError:
1141
region = Region('<unknown>', '<unknown>', obj.get('regionID', '<unknown>'))
1142
out = cls(
1143
name=obj['name'],
1144
id=obj['workspaceGroupID'],
1145
created_at=obj['createdAt'],
1146
region=region,
1147
firewall_ranges=obj.get('firewallRanges', []),
1148
terminated_at=obj.get('terminatedAt'),
1149
allow_all_traffic=obj.get('allowAllTraffic'),
1150
)
1151
out._manager = manager
1152
return out
1153
1154
@property
1155
def organization(self) -> Organization:
1156
if self._manager is None:
1157
raise ManagementError(
1158
msg='No workspace manager is associated with this object.',
1159
)
1160
return self._manager.organization
1161
1162
@property
1163
def stage(self) -> Stage:
1164
"""Stage manager."""
1165
if self._manager is None:
1166
raise ManagementError(
1167
msg='No workspace manager is associated with this object.',
1168
)
1169
return Stage(self.id, self._manager)
1170
1171
stages = stage
1172
1173
def refresh(self) -> 'WorkspaceGroup':
1174
"""Update the object to the current state."""
1175
if self._manager is None:
1176
raise ManagementError(
1177
msg='No workspace manager is associated with this object.',
1178
)
1179
new_obj = self._manager.get_workspace_group(self.id)
1180
for name, value in vars(new_obj).items():
1181
if isinstance(value, Mapping):
1182
setattr(self, name, camel_to_snake_dict(value))
1183
else:
1184
setattr(self, name, value)
1185
return self
1186
1187
def update(
1188
self,
1189
name: Optional[str] = None,
1190
firewall_ranges: Optional[List[str]] = None,
1191
admin_password: Optional[str] = None,
1192
expires_at: Optional[str] = None,
1193
allow_all_traffic: Optional[bool] = None,
1194
update_window: Optional[Dict[str, int]] = None,
1195
) -> None:
1196
"""
1197
Update the workspace group definition.
1198
1199
Parameters
1200
----------
1201
name : str, optional
1202
Name of the workspace group
1203
firewall_ranges : list[str], optional
1204
List of allowed CIDR ranges. An empty list indicates that all
1205
inbound requests are allowed.
1206
admin_password : str, optional
1207
Admin password for the workspace group. If no password is supplied,
1208
a password will be generated and retured in the response.
1209
expires_at : str, optional
1210
The timestamp of when the workspace group will expire.
1211
If the expiration time is not specified,
1212
the workspace group will have no expiration time.
1213
At expiration, the workspace group is terminated and all the data is lost.
1214
Expiration time can be specified as a timestamp or duration.
1215
Example: "2021-01-02T15:04:05Z07:00", "2021-01-02", "3h30m"
1216
allow_all_traffic : bool, optional
1217
Allow all traffic to the workspace group
1218
update_window : Dict[str, int], optional
1219
Specify the day and hour of an update window: dict(day=0-6, hour=0-23)
1220
1221
"""
1222
if self._manager is None:
1223
raise ManagementError(
1224
msg='No workspace manager is associated with this object.',
1225
)
1226
data = {
1227
k: v for k, v in dict(
1228
name=name,
1229
firewallRanges=firewall_ranges,
1230
adminPassword=admin_password,
1231
expiresAt=expires_at,
1232
allowAllTraffic=allow_all_traffic,
1233
updateWindow=snake_to_camel_dict(update_window),
1234
).items() if v is not None
1235
}
1236
self._manager._patch(f'workspaceGroups/{self.id}', json=data)
1237
self.refresh()
1238
1239
def terminate(
1240
self, force: bool = False,
1241
wait_on_terminated: bool = False,
1242
wait_interval: int = 10,
1243
wait_timeout: int = 600,
1244
) -> None:
1245
"""
1246
Terminate the workspace group.
1247
1248
Parameters
1249
----------
1250
force : bool, optional
1251
Terminate a workspace group even if it has active workspaces
1252
wait_on_terminated : bool, optional
1253
Wait for the workspace group to go into 'Terminated' mode before returning
1254
wait_interval : int, optional
1255
Number of seconds between each server check
1256
wait_timeout : int, optional
1257
Total number of seconds to check server before giving up
1258
1259
Raises
1260
------
1261
ManagementError
1262
If timeout is reached
1263
1264
"""
1265
if self._manager is None:
1266
raise ManagementError(
1267
msg='No workspace manager is associated with this object.',
1268
)
1269
self._manager._delete(f'workspaceGroups/{self.id}', params=dict(force=force))
1270
if wait_on_terminated:
1271
while True:
1272
self.refresh()
1273
if self.terminated_at is not None:
1274
break
1275
if wait_timeout <= 0:
1276
raise ManagementError(
1277
msg='Exceeded waiting time for WorkspaceGroup to terminate',
1278
)
1279
time.sleep(wait_interval)
1280
wait_timeout -= wait_interval
1281
1282
def create_workspace(
1283
self,
1284
name: str,
1285
size: Optional[str] = None,
1286
auto_suspend: Optional[Dict[str, Any]] = None,
1287
cache_config: Optional[int] = None,
1288
enable_kai: Optional[bool] = None,
1289
wait_on_active: bool = False,
1290
wait_interval: int = 10,
1291
wait_timeout: int = 600,
1292
) -> Workspace:
1293
"""
1294
Create a new workspace.
1295
1296
Parameters
1297
----------
1298
name : str
1299
Name of the workspace
1300
size : str, optional
1301
Workspace size in workspace size notation (S-00, S-1, etc.)
1302
auto_suspend : Dict[str, Any], optional
1303
Auto suspend settings for the workspace. If this field is not
1304
provided, no settings will be enabled.
1305
cache_config : int, optional
1306
Specifies the multiplier for the persistent cache associated
1307
with the workspace. If specified, it enables the cache configuration
1308
multiplier. It can have one of the following values: 1, 2, or 4.
1309
enable_kai : bool, optional
1310
Whether to create a SingleStore Kai-enabled workspace
1311
wait_on_active : bool, optional
1312
Wait for the workspace to be active before returning
1313
wait_timeout : int, optional
1314
Maximum number of seconds to wait before raising an exception
1315
if wait=True
1316
wait_interval : int, optional
1317
Number of seconds between each polling interval
1318
1319
Returns
1320
-------
1321
:class:`Workspace`
1322
1323
"""
1324
if self._manager is None:
1325
raise ManagementError(
1326
msg='No workspace manager is associated with this object.',
1327
)
1328
1329
out = self._manager.create_workspace(
1330
name=name,
1331
workspace_group=self,
1332
size=size,
1333
auto_suspend=snake_to_camel_dict(auto_suspend),
1334
cache_config=cache_config,
1335
enable_kai=enable_kai,
1336
wait_on_active=wait_on_active,
1337
wait_interval=wait_interval,
1338
wait_timeout=wait_timeout,
1339
)
1340
1341
return out
1342
1343
@property
1344
def workspaces(self) -> NamedList[Workspace]:
1345
"""Return a list of available workspaces."""
1346
if self._manager is None:
1347
raise ManagementError(
1348
msg='No workspace manager is associated with this object.',
1349
)
1350
res = self._manager._get('workspaces', params=dict(workspaceGroupID=self.id))
1351
return NamedList(
1352
[Workspace.from_dict(item, self._manager) for item in res.json()],
1353
)
1354
1355
1356
class StarterWorkspace(object):
1357
"""
1358
SingleStoreDB starter workspace definition.
1359
1360
This object is not instantiated directly. It is used in the results
1361
of API calls on the :class:`WorkspaceManager`. Existing starter workspaces are
1362
accessed by either :attr:`WorkspaceManager.starter_workspaces` or by calling
1363
:meth:`WorkspaceManager.get_starter_workspace`.
1364
1365
See Also
1366
--------
1367
:meth:`WorkspaceManager.get_starter_workspace`
1368
:meth:`WorkspaceManager.create_starter_workspace`
1369
:meth:`WorkspaceManager.terminate_starter_workspace`
1370
:meth:`WorkspaceManager.create_starter_workspace_user`
1371
:attr:`WorkspaceManager.starter_workspaces`
1372
1373
"""
1374
1375
name: str
1376
id: str
1377
database_name: str
1378
endpoint: Optional[str]
1379
1380
def __init__(
1381
self,
1382
name: str,
1383
id: str,
1384
database_name: str,
1385
endpoint: Optional[str] = None,
1386
):
1387
#: Name of the starter workspace
1388
self.name = name
1389
1390
#: Unique ID of the starter workspace
1391
self.id = id
1392
1393
#: Name of the database associated with the starter workspace
1394
self.database_name = database_name
1395
1396
#: Endpoint to connect to the starter workspace. The endpoint is in the form
1397
#: of ``hostname:port``
1398
self.endpoint = endpoint
1399
1400
self._manager: Optional[WorkspaceManager] = None
1401
1402
def __str__(self) -> str:
1403
"""Return string representation."""
1404
return vars_to_str(self)
1405
1406
def __repr__(self) -> str:
1407
"""Return string representation."""
1408
return str(self)
1409
1410
@classmethod
1411
def from_dict(
1412
cls, obj: Dict[str, Any], manager: 'WorkspaceManager',
1413
) -> 'StarterWorkspace':
1414
"""
1415
Construct a StarterWorkspace from a dictionary of values.
1416
1417
Parameters
1418
----------
1419
obj : dict
1420
Dictionary of values
1421
manager : WorkspaceManager, optional
1422
The WorkspaceManager the StarterWorkspace belongs to
1423
1424
Returns
1425
-------
1426
:class:`StarterWorkspace`
1427
1428
"""
1429
out = cls(
1430
name=obj['name'],
1431
id=obj['virtualWorkspaceID'],
1432
database_name=obj['databaseName'],
1433
endpoint=obj.get('endpoint'),
1434
)
1435
out._manager = manager
1436
return out
1437
1438
def connect(self, **kwargs: Any) -> connection.Connection:
1439
"""
1440
Create a connection to the database server for this starter workspace.
1441
1442
Parameters
1443
----------
1444
**kwargs : keyword-arguments, optional
1445
Parameters to the SingleStoreDB `connect` function except host
1446
and port which are supplied by the starter workspace object
1447
1448
Returns
1449
-------
1450
:class:`Connection`
1451
1452
"""
1453
if not self.endpoint:
1454
raise ManagementError(
1455
msg='An endpoint has not been set in this '
1456
'starter workspace configuration',
1457
)
1458
1459
kwargs['host'] = self.endpoint
1460
kwargs['database'] = self.database_name
1461
1462
return connection.connect(**kwargs)
1463
1464
def terminate(self) -> None:
1465
"""Terminate the starter workspace."""
1466
if self._manager is None:
1467
raise ManagementError(
1468
msg='No workspace manager is associated with this object.',
1469
)
1470
self._manager._delete(f'sharedtier/virtualWorkspaces/{self.id}')
1471
1472
def refresh(self) -> StarterWorkspace:
1473
"""Update the object to the current state."""
1474
if self._manager is None:
1475
raise ManagementError(
1476
msg='No workspace manager is associated with this object.',
1477
)
1478
new_obj = self._manager.get_starter_workspace(self.id)
1479
for name, value in vars(new_obj).items():
1480
if isinstance(value, Mapping):
1481
setattr(self, name, snake_to_camel_dict(value))
1482
else:
1483
setattr(self, name, value)
1484
return self
1485
1486
@property
1487
def organization(self) -> Organization:
1488
if self._manager is None:
1489
raise ManagementError(
1490
msg='No workspace manager is associated with this object.',
1491
)
1492
return self._manager.organization
1493
1494
@property
1495
def stage(self) -> Stage:
1496
"""Stage manager."""
1497
if self._manager is None:
1498
raise ManagementError(
1499
msg='No workspace manager is associated with this object.',
1500
)
1501
return Stage(self.id, self._manager)
1502
1503
stages = stage
1504
1505
@property
1506
def starter_workspaces(self) -> NamedList['StarterWorkspace']:
1507
"""Return a list of available starter workspaces."""
1508
if self._manager is None:
1509
raise ManagementError(
1510
msg='No workspace manager is associated with this object.',
1511
)
1512
res = self._manager._get('sharedtier/virtualWorkspaces')
1513
return NamedList(
1514
[StarterWorkspace.from_dict(item, self._manager) for item in res.json()],
1515
)
1516
1517
def create_user(
1518
self,
1519
username: str,
1520
password: Optional[str] = None,
1521
) -> Dict[str, str]:
1522
"""
1523
Create a new user for this starter workspace.
1524
1525
Parameters
1526
----------
1527
username : str
1528
The starter workspace user name to connect the new user to the database
1529
password : str, optional
1530
Password for the new user. If not provided, a password will be
1531
auto-generated by the system.
1532
1533
Returns
1534
-------
1535
Dict[str, str]
1536
Dictionary containing 'userID' and 'password' of the created user
1537
1538
Raises
1539
------
1540
ManagementError
1541
If no workspace manager is associated with this object.
1542
"""
1543
if self._manager is None:
1544
raise ManagementError(
1545
msg='No workspace manager is associated with this object.',
1546
)
1547
1548
payload = {
1549
'userName': username,
1550
}
1551
if password is not None:
1552
payload['password'] = password
1553
1554
res = self._manager._post(
1555
f'sharedtier/virtualWorkspaces/{self.id}/users',
1556
json=payload,
1557
)
1558
1559
response_data = res.json()
1560
user_id = response_data.get('userID')
1561
if not user_id:
1562
raise ManagementError(msg='No userID returned from API')
1563
1564
# Return the password provided by user or generated by API
1565
returned_password = password if password is not None \
1566
else response_data.get('password')
1567
if not returned_password:
1568
raise ManagementError(msg='No password available from API response')
1569
1570
return {
1571
'user_id': user_id,
1572
'password': returned_password,
1573
}
1574
1575
1576
class Billing(object):
1577
"""Billing information."""
1578
1579
COMPUTE_CREDIT = 'compute_credit'
1580
STORAGE_AVG_BYTE = 'storage_avg_byte'
1581
1582
HOUR = 'hour'
1583
DAY = 'day'
1584
MONTH = 'month'
1585
1586
def __init__(self, manager: Manager):
1587
self._manager = manager
1588
1589
def usage(
1590
self,
1591
start_time: datetime.datetime,
1592
end_time: datetime.datetime,
1593
metric: Optional[str] = None,
1594
aggregate_by: Optional[str] = None,
1595
) -> List[BillingUsageItem]:
1596
"""
1597
Get usage information.
1598
1599
Parameters
1600
----------
1601
start_time : datetime.datetime
1602
Start time for usage interval
1603
end_time : datetime.datetime
1604
End time for usage interval
1605
metric : str, optional
1606
Possible metrics are ``mgr.billing.COMPUTE_CREDIT`` and
1607
``mgr.billing.STORAGE_AVG_BYTE`` (default is all)
1608
aggregate_by : str, optional
1609
Aggregate type used to group usage: ``mgr.billing.HOUR``,
1610
``mgr.billing.DAY``, or ``mgr.billing.MONTH``
1611
1612
Returns
1613
-------
1614
List[BillingUsage]
1615
1616
"""
1617
res = self._manager._get(
1618
'billing/usage',
1619
params={
1620
k: v for k, v in dict(
1621
metric=snake_to_camel(metric),
1622
startTime=from_datetime(start_time),
1623
endTime=from_datetime(end_time),
1624
aggregate_by=aggregate_by.lower() if aggregate_by else None,
1625
).items() if v is not None
1626
},
1627
)
1628
return [
1629
BillingUsageItem.from_dict(x, self._manager)
1630
for x in res.json()['billingUsage']
1631
]
1632
1633
1634
class Organizations(object):
1635
"""Organizations."""
1636
1637
def __init__(self, manager: Manager):
1638
self._manager = manager
1639
1640
@property
1641
def current(self) -> Organization:
1642
"""Get current organization."""
1643
res = self._manager._get('organizations/current').json()
1644
return Organization.from_dict(res, self._manager)
1645
1646
1647
class WorkspaceManager(Manager):
1648
"""
1649
SingleStoreDB workspace manager.
1650
1651
This class should be instantiated using :func:`singlestoredb.manage_workspaces`.
1652
1653
Parameters
1654
----------
1655
access_token : str, optional
1656
The API key or other access token for the workspace management API
1657
version : str, optional
1658
Version of the API to use
1659
base_url : str, optional
1660
Base URL of the workspace management API
1661
1662
See Also
1663
--------
1664
:func:`singlestoredb.manage_workspaces`
1665
1666
"""
1667
1668
#: Workspace management API version if none is specified.
1669
default_version = config.get_option('management.version') or 'v1'
1670
1671
#: Base URL if none is specified.
1672
default_base_url = config.get_option('management.base_url') \
1673
or 'https://api.singlestore.com'
1674
1675
#: Object type
1676
obj_type = 'workspace'
1677
1678
@property
1679
def workspace_groups(self) -> NamedList[WorkspaceGroup]:
1680
"""Return a list of available workspace groups."""
1681
res = self._get('workspaceGroups')
1682
return NamedList([WorkspaceGroup.from_dict(item, self) for item in res.json()])
1683
1684
@property
1685
def starter_workspaces(self) -> NamedList[StarterWorkspace]:
1686
"""Return a list of available starter workspaces."""
1687
res = self._get('sharedtier/virtualWorkspaces')
1688
return NamedList([StarterWorkspace.from_dict(item, self) for item in res.json()])
1689
1690
@property
1691
def organizations(self) -> Organizations:
1692
"""Return the organizations."""
1693
return Organizations(self)
1694
1695
@property
1696
def organization(self) -> Organization:
1697
""" Return the current organization."""
1698
return self.organizations.current
1699
1700
@property
1701
def billing(self) -> Billing:
1702
"""Return the current billing information."""
1703
return Billing(self)
1704
1705
@ttl_property(datetime.timedelta(hours=1))
1706
def regions(self) -> NamedList[Region]:
1707
"""Return a list of available regions."""
1708
res = self._get('regions')
1709
return NamedList([Region.from_dict(item, self) for item in res.json()])
1710
1711
@ttl_property(datetime.timedelta(hours=1))
1712
def shared_tier_regions(self) -> NamedList[Region]:
1713
"""Return a list of regions that support shared tier workspaces."""
1714
res = self._get('regions/sharedtier')
1715
return NamedList(
1716
[Region.from_dict(item, self) for item in res.json()],
1717
)
1718
1719
def create_workspace_group(
1720
self,
1721
name: str,
1722
region: Union[str, Region],
1723
firewall_ranges: List[str],
1724
admin_password: Optional[str] = None,
1725
backup_bucket_kms_key_id: Optional[str] = None,
1726
data_bucket_kms_key_id: Optional[str] = None,
1727
expires_at: Optional[str] = None,
1728
smart_dr: Optional[bool] = None,
1729
allow_all_traffic: Optional[bool] = None,
1730
update_window: Optional[Dict[str, int]] = None,
1731
) -> WorkspaceGroup:
1732
"""
1733
Create a new workspace group.
1734
1735
Parameters
1736
----------
1737
name : str
1738
Name of the workspace group
1739
region : str or Region
1740
ID of the region where the workspace group should be created
1741
firewall_ranges : list[str]
1742
List of allowed CIDR ranges. An empty list indicates that all
1743
inbound requests are allowed.
1744
admin_password : str, optional
1745
Admin password for the workspace group. If no password is supplied,
1746
a password will be generated and retured in the response.
1747
backup_bucket_kms_key_id : str, optional
1748
Specifies the KMS key ID associated with the backup bucket.
1749
If specified, enables Customer-Managed Encryption Keys (CMEK)
1750
encryption for the backup bucket of the workspace group.
1751
This feature is only supported in workspace groups deployed in AWS.
1752
data_bucket_kms_key_id : str, optional
1753
Specifies the KMS key ID associated with the data bucket.
1754
If specified, enables Customer-Managed Encryption Keys (CMEK)
1755
encryption for the data bucket and Amazon Elastic Block Store
1756
(EBS) volumes of the workspace group. This feature is only supported
1757
in workspace groups deployed in AWS.
1758
expires_at : str, optional
1759
The timestamp of when the workspace group will expire.
1760
If the expiration time is not specified,
1761
the workspace group will have no expiration time.
1762
At expiration, the workspace group is terminated and all the data is lost.
1763
Expiration time can be specified as a timestamp or duration.
1764
Example: "2021-01-02T15:04:05Z07:00", "2021-01-02", "3h30m"
1765
smart_dr : bool, optional
1766
Enables Smart Disaster Recovery (SmartDR) for the workspace group.
1767
SmartDR is a disaster recovery solution that ensures seamless and
1768
continuous replication of data from the primary region to a secondary region
1769
allow_all_traffic : bool, optional
1770
Allow all traffic to the workspace group
1771
update_window : Dict[str, int], optional
1772
Specify the day and hour of an update window: dict(day=0-6, hour=0-23)
1773
1774
Returns
1775
-------
1776
:class:`WorkspaceGroup`
1777
1778
"""
1779
if isinstance(region, Region) and region.id:
1780
region = region.id
1781
res = self._post(
1782
'workspaceGroups', json=dict(
1783
name=name, regionID=region,
1784
adminPassword=admin_password,
1785
backupBucketKMSKeyID=backup_bucket_kms_key_id,
1786
dataBucketKMSKeyID=data_bucket_kms_key_id,
1787
firewallRanges=firewall_ranges or [],
1788
expiresAt=expires_at,
1789
smartDR=smart_dr,
1790
allowAllTraffic=allow_all_traffic,
1791
updateWindow=snake_to_camel_dict(update_window),
1792
),
1793
)
1794
return self.get_workspace_group(res.json()['workspaceGroupID'])
1795
1796
def create_workspace(
1797
self,
1798
name: str,
1799
workspace_group: Union[str, WorkspaceGroup],
1800
size: Optional[str] = None,
1801
auto_suspend: Optional[Dict[str, Any]] = None,
1802
cache_config: Optional[int] = None,
1803
enable_kai: Optional[bool] = None,
1804
wait_on_active: bool = False,
1805
wait_interval: int = 10,
1806
wait_timeout: int = 600,
1807
) -> Workspace:
1808
"""
1809
Create a new workspace.
1810
1811
Parameters
1812
----------
1813
name : str
1814
Name of the workspace
1815
workspace_group : str or WorkspaceGroup
1816
The workspace ID of the workspace
1817
size : str, optional
1818
Workspace size in workspace size notation (S-00, S-1, etc.)
1819
auto_suspend : Dict[str, Any], optional
1820
Auto suspend settings for the workspace. If this field is not
1821
provided, no settings will be enabled.
1822
cache_config : int, optional
1823
Specifies the multiplier for the persistent cache associated
1824
with the workspace. If specified, it enables the cache configuration
1825
multiplier. It can have one of the following values: 1, 2, or 4.
1826
enable_kai : bool, optional
1827
Whether to create a SingleStore Kai-enabled workspace
1828
wait_on_active : bool, optional
1829
Wait for the workspace to be active before returning
1830
wait_timeout : int, optional
1831
Maximum number of seconds to wait before raising an exception
1832
if wait=True
1833
wait_interval : int, optional
1834
Number of seconds between each polling interval
1835
1836
Returns
1837
-------
1838
:class:`Workspace`
1839
1840
"""
1841
if isinstance(workspace_group, WorkspaceGroup):
1842
workspace_group = workspace_group.id
1843
res = self._post(
1844
'workspaces', json=dict(
1845
name=name,
1846
workspaceGroupID=workspace_group,
1847
size=size,
1848
autoSuspend=snake_to_camel_dict(auto_suspend),
1849
cacheConfig=cache_config,
1850
enableKai=enable_kai,
1851
),
1852
)
1853
out = self.get_workspace(res.json()['workspaceID'])
1854
if wait_on_active:
1855
out = self._wait_on_state(
1856
out,
1857
'Active',
1858
interval=wait_interval,
1859
timeout=wait_timeout,
1860
)
1861
# After workspace is active, wait for endpoint to be ready
1862
out = self._wait_on_endpoint(
1863
out,
1864
interval=wait_interval,
1865
timeout=wait_timeout,
1866
)
1867
return out
1868
1869
def get_workspace_group(self, id: str) -> WorkspaceGroup:
1870
"""
1871
Retrieve a workspace group definition.
1872
1873
Parameters
1874
----------
1875
id : str
1876
ID of the workspace group
1877
1878
Returns
1879
-------
1880
:class:`WorkspaceGroup`
1881
1882
"""
1883
res = self._get(f'workspaceGroups/{id}')
1884
return WorkspaceGroup.from_dict(res.json(), manager=self)
1885
1886
def get_workspace(self, id: str) -> Workspace:
1887
"""
1888
Retrieve a workspace definition.
1889
1890
Parameters
1891
----------
1892
id : str
1893
ID of the workspace
1894
1895
Returns
1896
-------
1897
:class:`Workspace`
1898
1899
"""
1900
res = self._get(f'workspaces/{id}')
1901
return Workspace.from_dict(res.json(), manager=self)
1902
1903
def get_starter_workspace(self, id: str) -> StarterWorkspace:
1904
"""
1905
Retrieve a starter workspace definition.
1906
1907
Parameters
1908
----------
1909
id : str
1910
ID of the starter workspace
1911
1912
Returns
1913
-------
1914
:class:`StarterWorkspace`
1915
1916
"""
1917
res = self._get(f'sharedtier/virtualWorkspaces/{id}')
1918
return StarterWorkspace.from_dict(res.json(), manager=self)
1919
1920
def create_starter_workspace(
1921
self,
1922
name: str,
1923
database_name: str,
1924
provider: str,
1925
region_name: str,
1926
) -> 'StarterWorkspace':
1927
"""
1928
Create a new starter (shared tier) workspace.
1929
1930
Parameters
1931
----------
1932
name : str
1933
Name of the starter workspace
1934
database_name : str
1935
Name of the database for the starter workspace
1936
provider : str
1937
Cloud provider for the starter workspace (e.g., 'aws', 'gcp', 'azure')
1938
region_name : str
1939
Cloud provider region for the starter workspace (e.g., 'us-east-1')
1940
1941
Returns
1942
-------
1943
:class:`StarterWorkspace`
1944
"""
1945
1946
payload = {
1947
'name': name,
1948
'databaseName': database_name,
1949
'provider': provider,
1950
'regionName': region_name,
1951
}
1952
1953
res = self._post('sharedtier/virtualWorkspaces', json=payload)
1954
virtual_workspace_id = res.json().get('virtualWorkspaceID')
1955
if not virtual_workspace_id:
1956
raise ManagementError(msg='No virtualWorkspaceID returned from API')
1957
1958
res = self._get(f'sharedtier/virtualWorkspaces/{virtual_workspace_id}')
1959
return StarterWorkspace.from_dict(res.json(), self)
1960
1961
1962
def manage_workspaces(
1963
access_token: Optional[str] = None,
1964
version: Optional[str] = None,
1965
base_url: Optional[str] = None,
1966
*,
1967
organization_id: Optional[str] = None,
1968
) -> WorkspaceManager:
1969
"""
1970
Retrieve a SingleStoreDB workspace manager.
1971
1972
Parameters
1973
----------
1974
access_token : str, optional
1975
The API key or other access token for the workspace management API
1976
version : str, optional
1977
Version of the API to use
1978
base_url : str, optional
1979
Base URL of the workspace management API
1980
organization_id : str, optional
1981
ID of organization, if using a JWT for authentication
1982
1983
Returns
1984
-------
1985
:class:`WorkspaceManager`
1986
1987
"""
1988
return WorkspaceManager(
1989
access_token=access_token, base_url=base_url,
1990
version=version, organization_id=organization_id,
1991
)
1992
1993