diff --git a/.gitignore b/.gitignore index 5fc32a4a..cf9f30c4 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,4 @@ dask-worker-space singlestoredb/mysql/tests/databases.json trees +uv.lock diff --git a/docs/src/api.rst b/docs/src/api.rst index 9e0a872c..91283e6c 100644 --- a/docs/src/api.rst +++ b/docs/src/api.rst @@ -258,13 +258,34 @@ create new ones. WorkspaceManager.workspace_groups WorkspaceManager.starter_workspaces WorkspaceManager.regions + WorkspaceManager.regions_v2 WorkspaceManager.shared_tier_regions + WorkspaceManager.billing WorkspaceManager.create_workspace_group WorkspaceManager.create_workspace WorkspaceManager.create_starter_workspace WorkspaceManager.get_workspace_group WorkspaceManager.get_workspace WorkspaceManager.get_starter_workspace + WorkspaceManager.invitations + WorkspaceManager.get_invitation + WorkspaceManager.create_invitation + WorkspaceManager.users + WorkspaceManager.current_user + WorkspaceManager.get_user + WorkspaceManager.add_user + WorkspaceManager.teams + WorkspaceManager.get_team + WorkspaceManager.create_team + WorkspaceManager.get_roles + WorkspaceManager.get_role + WorkspaceManager.create_role + WorkspaceManager.secrets + WorkspaceManager.get_secret_by_id + WorkspaceManager.create_secret + WorkspaceManager.get_audit_logs + WorkspaceManager.create_private_connection + WorkspaceManager.get_private_connection WorkspaceGroup @@ -279,10 +300,15 @@ or by retrieving an element from :attr:`WorkspaceManager.workspace_groups`. WorkspaceGroup WorkspaceGroup.workspaces WorkspaceGroup.stage + WorkspaceGroup.storage WorkspaceGroup.create_workspace WorkspaceGroup.refresh WorkspaceGroup.update WorkspaceGroup.terminate + WorkspaceGroup.private_connections + WorkspaceGroup.get_access_controls + WorkspaceGroup.update_access_controls + WorkspaceGroup.get_metrics Workspace @@ -300,12 +326,18 @@ Workspaces are created within WorkspaceGroups. They can be created using either Workspace.refresh Workspace.update Workspace.terminate + Workspace.suspend + Workspace.resume + Workspace.private_connections + Workspace.get_kai_private_connection_info + Workspace.get_outbound_allow_list Region ...... -Region objects are accessed from the :attr:`WorkspaceManager.regions` attribute. +Region objects are accessed from the :attr:`WorkspaceManager.regions` attribute +or through the :class:`RegionManager`. .. currentmodule:: singlestoredb.management.region @@ -315,6 +347,19 @@ Region objects are accessed from the :attr:`WorkspaceManager.regions` attribute. Region +RegionManager +^^^^^^^^^^^^^ + +RegionManager provides methods for listing available regions. + +.. autosummary:: + :toctree: generated/ + + RegionManager + RegionManager.list_regions + RegionManager.list_shared_tier_regions + + Organization ............ @@ -330,6 +375,8 @@ They provide access to organization-level resources and operations. Organization.get_secret Organization.jobs Organization.inference_apis + Organization.get_access_controls + Organization.update_access_controls Secret @@ -342,6 +389,290 @@ They represent organization-wide secrets that can be used in various operations. :toctree: generated/ Secret + Secret.update + Secret.delete + Secret.get_access_controls + Secret.update_access_controls + + +Cluster Management +.................. + +The :func:`manage_cluster` function provides access to cluster management functionality. + +.. currentmodule:: singlestoredb + +.. autosummary:: + :toctree: generated/ + + manage_cluster + + +ClusterManager +^^^^^^^^^^^^^^ + +ClusterManager objects are returned by the :func:`manage_cluster` function. +They allow you to create and manage clusters. + +.. currentmodule:: singlestoredb.management.cluster + +.. autosummary:: + :toctree: generated/ + + ClusterManager + ClusterManager.clusters + ClusterManager.regions + ClusterManager.create_cluster + ClusterManager.get_cluster + + +Cluster +^^^^^^^ + +Cluster objects represent SingleStore clusters. They can be created using +:meth:`ClusterManager.create_cluster` or retrieved from :attr:`ClusterManager.clusters`. + +.. autosummary:: + :toctree: generated/ + + Cluster + Cluster.connect + Cluster.refresh + Cluster.update + Cluster.suspend + Cluster.resume + Cluster.terminate + + +User Management +............... + +User objects represent users in your organization. + +.. currentmodule:: singlestoredb.management.users + +.. autosummary:: + :toctree: generated/ + + User + User.get_identity_roles + User.remove + + +Team Management +............... + +Team objects allow you to organize users into groups. + +.. currentmodule:: singlestoredb.management.teams + +.. autosummary:: + :toctree: generated/ + + Team + Team.refresh + Team.update + Team.delete + Team.get_access_controls + Team.update_access_controls + Team.get_identity_roles + + +Role Management +............... + +Role objects define permissions within your organization. + +.. currentmodule:: singlestoredb.management.roles + +.. autosummary:: + :toctree: generated/ + + Role + Role.update + Role.delete + + +Invitation Management +..................... + +Invitation objects represent invitations to join your organization. + +.. currentmodule:: singlestoredb.management.invitations + +.. autosummary:: + :toctree: generated/ + + Invitation + Invitation.revoke + + +Audit Logging +............. + +Audit logs track changes and actions within your organization. + +.. currentmodule:: singlestoredb.management.audit_logs + +.. autosummary:: + :toctree: generated/ + + AuditLog + AuditLogResult + + +Private Connections +................... + +Private connections allow you to establish private network connectivity +to your workspaces. + +.. currentmodule:: singlestoredb.management.private_connections + +.. autosummary:: + :toctree: generated/ + + PrivateConnection + PrivateConnection.refresh + PrivateConnection.update + PrivateConnection.delete + OutboundAllowListEntry + KaiPrivateConnectionInfo + + +Billing +....... + +Billing objects provide access to usage and billing information. + +.. currentmodule:: singlestoredb.management.workspace + +.. autosummary:: + :toctree: generated/ + + Billing + Billing.usage + +.. currentmodule:: singlestoredb.management.billing_usage + +.. autosummary:: + :toctree: generated/ + + UsageItem + BillingUsageItem + + +Starter Workspaces +.................. + +Starter workspaces provide a free tier option for development and testing. + +.. currentmodule:: singlestoredb.management.workspace + +.. autosummary:: + :toctree: generated/ + + StarterWorkspace + StarterWorkspace.connect + StarterWorkspace.terminate + StarterWorkspace.refresh + StarterWorkspace.create_user + StarterWorkspace.get_user + StarterWorkspaceUser + StarterWorkspaceUser.update + StarterWorkspaceUser.delete + + +Storage and Disaster Recovery +............................. + +Storage objects provide access to workspace group storage settings +and disaster recovery functionality. + +.. currentmodule:: singlestoredb.management.storage + +.. autosummary:: + :toctree: generated/ + + Storage + Storage.dr + Storage.update_retention_period + + +DisasterRecovery +^^^^^^^^^^^^^^^^ + +DisasterRecovery objects manage disaster recovery configuration and operations. + +.. autosummary:: + :toctree: generated/ + + DisasterRecovery + DisasterRecovery.get_status + DisasterRecovery.get_regions + DisasterRecovery.setup + DisasterRecovery.failover + DisasterRecovery.failback + DisasterRecovery.start_pre_provision + DisasterRecovery.stop_pre_provision + DRStatus + DRRegion + + +Export Service +.............. + +Export services allow you to replicate data from SingleStore to external systems. + +.. currentmodule:: singlestoredb.management.export + +.. autosummary:: + :toctree: generated/ + + ExportService + ExportService.create_cluster_identity + ExportService.start + ExportService.suspend + ExportService.resume + ExportService.drop + ExportService.status + ExportStatus + + +Inference API +............. + +Inference API management allows you to deploy and manage ML models. + +.. currentmodule:: singlestoredb.management.inference_api + +.. autosummary:: + :toctree: generated/ + + InferenceAPIManager + InferenceAPIManager.get + InferenceAPIManager.start + InferenceAPIManager.stop + InferenceAPIManager.show + InferenceAPIManager.drop + InferenceAPIInfo + InferenceAPIInfo.start + InferenceAPIInfo.stop + InferenceAPIInfo.drop + ModelOperationResult + + +Projects +........ + +Project objects represent projects within your organization. + +.. currentmodule:: singlestoredb.management.projects + +.. autosummary:: + :toctree: generated/ + + Project Jobs Management diff --git a/singlestoredb/management/__init__.py b/singlestoredb/management/__init__.py index 8a87d284..2a19d60a 100644 --- a/singlestoredb/management/__init__.py +++ b/singlestoredb/management/__init__.py @@ -1,8 +1,16 @@ #!/usr/bin/env python +from .audit_logs import AuditLog from .cluster import manage_cluster from .files import manage_files +from .invitations import Invitation from .manager import get_token +from .organization import Secret +from .private_connections import PrivateConnection +from .projects import Project from .region import manage_regions +from .roles import Role +from .teams import Team +from .users import User from .workspace import get_organization from .workspace import get_secret from .workspace import get_stage diff --git a/singlestoredb/management/audit_logs.py b/singlestoredb/management/audit_logs.py new file mode 100644 index 00000000..744d077c --- /dev/null +++ b/singlestoredb/management/audit_logs.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python +"""SingleStoreDB Audit Log Management.""" +from __future__ import annotations + +import datetime +from typing import Any +from typing import cast +from typing import Dict +from typing import List +from typing import Optional +from typing import TYPE_CHECKING + +from .utils import NamedList +from .utils import normalize_audit_log_source +from .utils import normalize_audit_log_type +from .utils import to_datetime +from .utils import vars_to_str + +if TYPE_CHECKING: + from .workspace import WorkspaceManager + + +class AuditLog: + """ + SingleStoreDB audit log entry. + + This object is not instantiated directly. It is used in the results + of API calls on the :class:`WorkspaceManager`. Audit logs are accessed using + :meth:`WorkspaceManager.get_audit_logs`. + + See Also + -------- + :meth:`WorkspaceManager.get_audit_logs` + + """ + + id: str + type: str + source: Optional[str] + timestamp: Optional[datetime.datetime] + user_id: Optional[str] + user_email: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + resource_type: Optional[str] + resource_id: Optional[str] + resource_name: Optional[str] + details: Optional[Dict[str, Any]] + + def __init__( + self, + audit_log_id: str, + type: str, + source: Optional[str] = None, + timestamp: Optional[datetime.datetime] = None, + user_id: Optional[str] = None, + user_email: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + resource_name: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + ): + #: Unique ID of the audit log entry + self.id = audit_log_id + + #: Type of action (e.g., 'Login') + self.type = normalize_audit_log_type(type) or type + + #: Source of the action (e.g., 'Portal', 'Admin', 'SystemJob') + self.source = normalize_audit_log_source(source) if source else source + + #: Timestamp of when the action occurred + self.timestamp = timestamp + + #: ID of the user who performed the action + self.user_id = user_id + + #: Email of the user who performed the action + self.user_email = user_email + + #: First name of the user who performed the action + self.first_name = first_name + + #: Last name of the user who performed the action + self.last_name = last_name + + #: Type of resource affected + self.resource_type = resource_type + + #: ID of the resource affected + self.resource_id = resource_id + + #: Name of the resource affected + self.resource_name = resource_name + + #: Additional details about the action + self.details = details + + self._manager: Optional[WorkspaceManager] = None + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict( + cls, + obj: Dict[str, Any], + manager: 'WorkspaceManager', + ) -> 'AuditLog': + """ + Construct an AuditLog from a dictionary of values. + + Parameters + ---------- + obj : dict + Dictionary of values + manager : WorkspaceManager + The WorkspaceManager the AuditLog belongs to + + Returns + ------- + :class:`AuditLog` + + """ + # Get ID - try auditLogID first, fall back to id + audit_log_id = obj.get('auditLogID') or obj.get('id') + if not audit_log_id: + raise KeyError('Missing required field(s): auditLogID or id') + + out = cls( + audit_log_id=audit_log_id, + type=obj.get('type', ''), + source=obj.get('source'), + timestamp=to_datetime(obj.get('timestamp')), + user_id=obj.get('userID'), + user_email=obj.get('userEmail'), + first_name=obj.get('firstName'), + last_name=obj.get('lastName'), + resource_type=obj.get('resourceType'), + resource_id=obj.get('resourceID'), + resource_name=obj.get('resourceName'), + details=obj.get('details'), + ) + out._manager = manager + return out + + +class AuditLogResult: + """ + Result from audit log query with pagination support. + + Attributes + ---------- + logs : List[AuditLog] + List of audit log entries + next_token : str, optional + Token for fetching the next page of results + + """ + + def __init__( + self, + logs: List[AuditLog], + next_token: Optional[str] = None, + ): + self.logs = logs + self.next_token = next_token + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + +class AuditLogsMixin: + """Mixin class that adds audit log methods to WorkspaceManager.""" + + def get_audit_logs( + self, + type: Optional[str] = None, + source: Optional[str] = None, + start_date: Optional[datetime.datetime] = None, + end_date: Optional[datetime.datetime] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + ) -> AuditLogResult: + """ + Get audit logs for the organization. + + Parameters + ---------- + type : str, optional + Filter by action type (e.g., 'Login') + source : str, optional + Filter by source (e.g., 'Portal', 'Admin', 'SystemJob') + start_date : datetime, optional + Start date for the query range + end_date : datetime, optional + End date for the query range + limit : int, optional + Maximum number of results to return + next_token : str, optional + Token for pagination (from previous response) + first_name : str, optional + Filter by user's first name + last_name : str, optional + Filter by user's last name + + Returns + ------- + :class:`AuditLogResult` + Contains list of audit logs and optional next_token for pagination + + """ + params: Dict[str, Any] = {} + if type is not None: + params['type'] = normalize_audit_log_type(type) + if source is not None: + params['source'] = normalize_audit_log_source(source) + if start_date is not None: + params['startDate'] = start_date.isoformat() + if end_date is not None: + params['endDate'] = end_date.isoformat() + if limit is not None: + params['limit'] = limit + if next_token is not None: + params['nextToken'] = next_token + if first_name is not None: + params['firstName'] = first_name + if last_name is not None: + params['lastName'] = last_name + + manager = cast('WorkspaceManager', self) + res = manager._get('auditLogs', params=params) + data = res.json() + + logs = NamedList([ + AuditLog.from_dict(item, manager) + for item in data.get('auditLogs', []) + ]) + + return AuditLogResult( + logs=logs, + next_token=data.get('nextToken'), + ) diff --git a/singlestoredb/management/invitations.py b/singlestoredb/management/invitations.py new file mode 100644 index 00000000..7e75f1cb --- /dev/null +++ b/singlestoredb/management/invitations.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python +"""SingleStoreDB Invitation Management.""" +from __future__ import annotations + +import datetime +from typing import Any +from typing import cast +from typing import Dict +from typing import List +from typing import Optional +from typing import TYPE_CHECKING + +from ..exceptions import ManagementError +from .utils import NamedList +from .utils import normalize_invitation_state +from .utils import require_fields +from .utils import to_datetime +from .utils import vars_to_str + +if TYPE_CHECKING: + from .workspace import WorkspaceManager + + +class Invitation: + """ + SingleStoreDB invitation definition. + + This object is not instantiated directly. It is used in the results + of API calls on the :class:`WorkspaceManager`. Invitations are created using + :meth:`WorkspaceManager.create_invitation`, or existing invitations are accessed + by either :attr:`WorkspaceManager.invitations` or by calling + :meth:`WorkspaceManager.get_invitation`. + + See Also + -------- + :meth:`WorkspaceManager.create_invitation` + :meth:`WorkspaceManager.get_invitation` + :attr:`WorkspaceManager.invitations` + + """ + + id: str + email: str + state: Optional[str] + message: Optional[str] + teams: List[str] + created_at: Optional[datetime.datetime] + expires_at: Optional[datetime.datetime] + + def __init__( + self, + invitation_id: str, + email: str, + state: Optional[str] = None, + message: Optional[str] = None, + teams: Optional[List[str]] = None, + created_at: Optional[datetime.datetime] = None, + expires_at: Optional[datetime.datetime] = None, + ): + #: Unique ID of the invitation + self.id = invitation_id + + #: Email address the invitation was sent to + self.email = email + + #: State of the invitation (e.g., 'Pending', 'Accepted', 'Revoked') + self.state = normalize_invitation_state(state) if state else state + + #: Optional message included with the invitation + self.message = message + + #: List of team IDs the invited user will be added to + self.teams = teams or [] + + #: Timestamp of when the invitation was created + self.created_at = created_at + + #: Timestamp of when the invitation expires + self.expires_at = expires_at + + self._manager: Optional[WorkspaceManager] = None + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict( + cls, + obj: Dict[str, Any], + manager: 'WorkspaceManager', + ) -> 'Invitation': + """ + Construct an Invitation from a dictionary of values. + + Parameters + ---------- + obj : dict + Dictionary of values + manager : WorkspaceManager + The WorkspaceManager the Invitation belongs to + + Returns + ------- + :class:`Invitation` + + """ + require_fields(obj, 'invitationID', 'email') + out = cls( + invitation_id=obj['invitationID'], + email=obj['email'], + state=obj.get('state'), + message=obj.get('message'), + teams=obj.get('teams', []), + created_at=to_datetime(obj.get('createdAt')), + expires_at=to_datetime(obj.get('expiresAt')), + ) + out._manager = manager + return out + + def revoke(self) -> None: + """Revoke the invitation.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + self._manager._delete(f'invitations/{self.id}') + + +class InvitationsMixin: + """Mixin class that adds invitation management methods to WorkspaceManager.""" + + @property + def invitations(self) -> NamedList[Invitation]: + """Return a list of all open invitations in the organization.""" + manager = cast('WorkspaceManager', self) + res = manager._get('invitations') + return NamedList([ + Invitation.from_dict(item, manager) + for item in res.json() + ]) + + def get_invitation(self, invitation_id: str) -> Invitation: + """ + Retrieve an invitation by ID. + + Parameters + ---------- + invitation_id : str + ID of the invitation + + Returns + ------- + :class:`Invitation` + + """ + manager = cast('WorkspaceManager', self) + res = manager._get(f'invitations/{invitation_id}') + return Invitation.from_dict(res.json(), manager) + + def create_invitation( + self, + email: str, + message: Optional[str] = None, + teams: Optional[List[str]] = None, + ) -> Invitation: + """ + Send an invitation to join the organization. + + Parameters + ---------- + email : str + Email address to send the invitation to + message : str, optional + Custom message to include with the invitation + teams : List[str], optional + List of team IDs to add the user to upon acceptance + + Returns + ------- + :class:`Invitation` + + """ + manager = cast('WorkspaceManager', self) + data: Dict[str, Any] = {'email': email} + if message is not None: + data['message'] = message + if teams is not None: + data['teams'] = teams + + res = manager._post('invitations', json=data) + return self.get_invitation(res.json()['invitationID']) diff --git a/singlestoredb/management/organization.py b/singlestoredb/management/organization.py index 2c0f917d..a02b2efe 100644 --- a/singlestoredb/management/organization.py +++ b/singlestoredb/management/organization.py @@ -1,17 +1,28 @@ #!/usr/bin/env python """SingleStoreDB Cloud Organization.""" +from __future__ import annotations + import datetime +from typing import Any +from typing import cast from typing import Dict from typing import List from typing import Optional +from typing import TYPE_CHECKING from typing import Union from ..exceptions import ManagementError from .inference_api import InferenceAPIManager from .job import JobsManager from .manager import Manager +from .utils import NamedList +from .utils import require_fields +from .utils import to_datetime from .utils import vars_to_str +if TYPE_CHECKING: + from .workspace import WorkspaceManager + def listify(x: Union[str, List[str]]) -> List[str]: if isinstance(x, list): @@ -30,50 +41,84 @@ class Secret(object): SingleStoreDB secrets definition. This object is not directly instantiated. It is used in results - of API calls on the :class:`Organization`. See :meth:`Organization.get_secret`. + of API calls on the :class:`WorkspaceManager`. Secrets are created using + :meth:`WorkspaceManager.create_secret`, or existing secrets are accessed + by either :attr:`WorkspaceManager.secrets` or by calling + :meth:`WorkspaceManager.get_secret_by_id`. + + See Also + -------- + :meth:`WorkspaceManager.create_secret` + :meth:`WorkspaceManager.get_secret_by_id` + :attr:`WorkspaceManager.secrets` + """ + id: str + name: str + value: Optional[str] + created_by: str + created_at: Optional[datetime.datetime] + last_updated_by: Optional[str] + last_updated_at: Optional[datetime.datetime] + deleted_by: Optional[str] + deleted_at: Optional[datetime.datetime] + def __init__( self, id: str, name: str, created_by: str, - created_at: Union[str, datetime.datetime], - last_updated_by: str, - last_updated_at: Union[str, datetime.datetime], + created_at: Optional[datetime.datetime] = None, + last_updated_by: Optional[str] = None, + last_updated_at: Optional[datetime.datetime] = None, value: Optional[str] = None, deleted_by: Optional[str] = None, - deleted_at: Optional[Union[str, datetime.datetime]] = None, + deleted_at: Optional[datetime.datetime] = None, ): - # UUID of the secret + #: UUID of the secret self.id = id - # Name of the secret + #: Name of the secret self.name = name - # Value of the secret + #: Value of the secret self.value = value - # User who created the secret + #: User who created the secret self.created_by = created_by - # Time when the secret was created + #: Time when the secret was created self.created_at = created_at - # UUID of the user who last updated the secret + #: UUID of the user who last updated the secret self.last_updated_by = last_updated_by - # Time when the secret was last updated + #: Time when the secret was last updated self.last_updated_at = last_updated_at - # UUID of the user who deleted the secret + #: UUID of the user who deleted the secret self.deleted_by = deleted_by - # Time when the secret was deleted + #: Time when the secret was deleted self.deleted_at = deleted_at + self._manager: Optional[Manager] = None + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + @classmethod - def from_dict(cls, obj: Dict[str, str]) -> 'Secret': + def from_dict( + cls, + obj: Dict[str, Any], + manager: Manager, + ) -> 'Secret': """ Construct a Secret from a dictionary of values. @@ -81,33 +126,171 @@ def from_dict(cls, obj: Dict[str, str]) -> 'Secret': ---------- obj : dict Dictionary of values + manager : Manager + The Manager the Secret belongs to Returns ------- :class:`Secret` """ + require_fields(obj, 'secretID', 'name', 'createdBy') out = cls( id=obj['secretID'], name=obj['name'], created_by=obj['createdBy'], - created_at=obj['createdAt'], - last_updated_by=obj['lastUpdatedBy'], - last_updated_at=obj['lastUpdatedAt'], + created_at=to_datetime(obj.get('createdAt')), + last_updated_by=obj.get('lastUpdatedBy'), + last_updated_at=to_datetime(obj.get('lastUpdatedAt')), value=obj.get('value'), deleted_by=obj.get('deletedBy'), - deleted_at=obj.get('deletedAt'), + deleted_at=to_datetime(obj.get('deletedAt')), ) - + out._manager = manager return out - def __str__(self) -> str: - """Return string representation.""" - return vars_to_str(self) + def update( + self, + name: Optional[str] = None, + value: Optional[str] = None, + ) -> None: + """ + Update the secret. - def __repr__(self) -> str: - """Return string representation.""" - return str(self) + Parameters + ---------- + name : str, optional + New name for the secret + value : str, optional + New value for the secret + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + + data: Dict[str, Any] = {} + if name is not None: + data['name'] = name + if value is not None: + data['value'] = value + + if data: + self._manager._patch(f'secrets/{self.id}', json=data) + # Update local state + if name is not None: + self.name = name + if value is not None: + self.value = value + + def delete(self) -> None: + """Delete the secret.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + self._manager._delete(f'secrets/{self.id}') + + def get_access_controls(self) -> Dict[str, Any]: + """ + Get access control information for this secret. + + Returns + ------- + dict + Access control information including grants + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get(f'secrets/{self.id}/accessControls') + return res.json() + + def update_access_controls( + self, + grants: Optional[List[Dict[str, Any]]] = None, + revokes: Optional[List[Dict[str, Any]]] = None, + ) -> None: + """ + Update access controls for this secret. + + Parameters + ---------- + grants : list of dict, optional + List of grants to add, each with 'identity' and 'role' keys + revokes : list of dict, optional + List of revokes to remove, each with 'identity' and 'role' keys + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + + data: Dict[str, Any] = {} + if grants is not None: + data['grants'] = grants + if revokes is not None: + data['revokes'] = revokes + + if data: + self._manager._patch(f'secrets/{self.id}/accessControls', json=data) + + +class SecretsMixin: + """Mixin class that adds secret management methods to WorkspaceManager.""" + + @property + def secrets(self) -> NamedList[Secret]: + """Return a list of all secrets in the organization.""" + manager = cast('WorkspaceManager', self) + res = manager._get('secrets') + return NamedList([ + Secret.from_dict(item, manager) + for item in res.json().get('secrets', []) + ]) + + def get_secret_by_id(self, secret_id: str) -> Secret: + """ + Retrieve a secret by ID. + + Parameters + ---------- + secret_id : str + ID of the secret + + Returns + ------- + :class:`Secret` + + """ + manager = cast('WorkspaceManager', self) + res = manager._get(f'secrets/{secret_id}') + return Secret.from_dict(res.json(), manager) + + def create_secret(self, name: str, value: str) -> Secret: + """ + Create a new secret. + + Parameters + ---------- + name : str + Name of the secret + value : str + Value of the secret + + Returns + ------- + :class:`Secret` + + """ + manager = cast('WorkspaceManager', self) + data = {'name': name, 'value': value} + res = manager._post('secrets', json=data) + return self.get_secret_by_id(res.json()['secretID']) class Organization(object): @@ -154,7 +337,10 @@ def get_secret(self, name: str) -> Secret: res = self._manager._get('secrets', params=dict(name=name)) - secrets = [Secret.from_dict(item) for item in res.json()['secrets']] + secrets = [ + Secret.from_dict(item, self._manager) + for item in res.json()['secrets'] + ] if len(secrets) == 0: raise ManagementError(msg=f'Secret {name} not found') @@ -224,3 +410,49 @@ def inference_apis(self) -> InferenceAPIManager: :class:`InferenceAPIManager` """ return InferenceAPIManager(self._manager) + + def get_access_controls(self) -> Dict[str, Any]: + """ + Get access control information for this organization. + + Returns + ------- + dict + Access control information including grants + + """ + if self._manager is None: + raise ManagementError(msg='Organization not initialized') + res = self._manager._get(f'organizations/{self.id}/accessControls') + return res.json() + + def update_access_controls( + self, + grants: Optional[List[Dict[str, Any]]] = None, + revokes: Optional[List[Dict[str, Any]]] = None, + ) -> None: + """ + Update access controls for this organization. + + Parameters + ---------- + grants : list of dict, optional + List of grants to add, each with 'identity' and 'role' keys + revokes : list of dict, optional + List of revokes to remove, each with 'identity' and 'role' keys + + """ + if self._manager is None: + raise ManagementError(msg='Organization not initialized') + + data: Dict[str, Any] = {} + if grants is not None: + data['grants'] = grants + if revokes is not None: + data['revokes'] = revokes + + if data: + self._manager._patch( + f'organizations/{self.id}/accessControls', + json=data, + ) diff --git a/singlestoredb/management/private_connections.py b/singlestoredb/management/private_connections.py new file mode 100644 index 00000000..695a9b35 --- /dev/null +++ b/singlestoredb/management/private_connections.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python +"""SingleStoreDB Private Connection Management.""" +from __future__ import annotations + +import datetime +from typing import Any +from typing import cast +from typing import Dict +from typing import List +from typing import Optional +from typing import TYPE_CHECKING + +from ..exceptions import ManagementError +from .utils import normalize_connection_status +from .utils import normalize_connection_type +from .utils import to_datetime +from .utils import vars_to_str + +if TYPE_CHECKING: + from .workspace import WorkspaceManager + + +class PrivateConnection: + """ + SingleStoreDB private connection definition. + + This object is not instantiated directly. It is used in the results + of API calls on the :class:`WorkspaceManager`. Private connections are created + using :meth:`WorkspaceManager.create_private_connection`. + + See Also + -------- + :meth:`WorkspaceManager.create_private_connection` + :meth:`WorkspaceManager.get_private_connection` + + """ + + id: str + workspace_group_id: str + type: str + status: Optional[str] + service_name: Optional[str] + endpoint_id: Optional[str] + allow_list: List[str] + created_at: Optional[datetime.datetime] + updated_at: Optional[datetime.datetime] + + def __init__( + self, + connection_id: str, + workspace_group_id: str, + type: str, + status: Optional[str] = None, + service_name: Optional[str] = None, + endpoint_id: Optional[str] = None, + allow_list: Optional[List[str]] = None, + created_at: Optional[datetime.datetime] = None, + updated_at: Optional[datetime.datetime] = None, + ): + #: Unique ID of the private connection + self.id = connection_id + + #: ID of the workspace group this connection belongs to + self.workspace_group_id = workspace_group_id + + #: Type of private connection (e.g., 'INBOUND', 'OUTBOUND') + self.type = normalize_connection_type(type) or type + + #: Status of the connection + self.status = normalize_connection_status(status) if status else status + + #: Service name for the private connection + self.service_name = service_name + + #: Endpoint ID + self.endpoint_id = endpoint_id + + #: List of allowed principals/accounts + self.allow_list = allow_list or [] + + #: Timestamp of when the connection was created + self.created_at = created_at + + #: Timestamp of when the connection was last updated + self.updated_at = updated_at + + self._manager: Optional[WorkspaceManager] = None + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict( + cls, + obj: Dict[str, Any], + manager: 'WorkspaceManager', + ) -> 'PrivateConnection': + """ + Construct a PrivateConnection from a dictionary of values. + + Parameters + ---------- + obj : dict + Dictionary of values + manager : WorkspaceManager + The WorkspaceManager the PrivateConnection belongs to + + Returns + ------- + :class:`PrivateConnection` + + """ + # Get ID - try connectionID first, fall back to privateConnectionID + connection_id = obj.get('connectionID') or obj.get('privateConnectionID') + if not connection_id: + raise KeyError( + 'Missing required field(s): connectionID or privateConnectionID', + ) + + workspace_group_id = obj.get('workspaceGroupID') + if not workspace_group_id: + raise KeyError('Missing required field(s): workspaceGroupID') + + out = cls( + connection_id=connection_id, + workspace_group_id=workspace_group_id, + type=obj.get('type', ''), + status=obj.get('status'), + service_name=obj.get('serviceName'), + endpoint_id=obj.get('endpointID'), + allow_list=obj.get('allowList', []), + created_at=to_datetime(obj.get('createdAt')), + updated_at=to_datetime(obj.get('updatedAt')), + ) + out._manager = manager + return out + + def refresh(self) -> None: + """Refresh the private connection data from the server.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get(f'privateConnections/{self.id}') + obj = res.json() + status = obj.get('status') + self.status = normalize_connection_status(status) if status else status + self.service_name = obj.get('serviceName') + self.endpoint_id = obj.get('endpointID') + self.allow_list = obj.get('allowList', []) + self.updated_at = to_datetime(obj.get('updatedAt')) + + def update(self, allow_list: List[str]) -> None: + """ + Update the private connection's allow list. + + Parameters + ---------- + allow_list : List[str] + New list of allowed principals/accounts + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + + data = {'allowList': allow_list} + self._manager._patch(f'privateConnections/{self.id}', json=data) + self.refresh() + + def delete(self) -> None: + """Delete the private connection.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + self._manager._delete(f'privateConnections/{self.id}') + + +class OutboundAllowListEntry: + """ + SingleStoreDB outbound allow list entry. + + Represents a destination that a workspace is allowed to connect to. + + """ + + destination: str + description: Optional[str] + + def __init__( + self, + destination: str, + description: Optional[str] = None, + ): + #: The allowed destination (e.g., IP address, hostname) + self.destination = destination + + #: Description of the allow list entry + self.description = description + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> 'OutboundAllowListEntry': + """Construct from a dictionary.""" + return cls( + destination=obj.get('destination', ''), + description=obj.get('description'), + ) + + +class KaiPrivateConnectionInfo: + """ + SingleStoreDB Kai private connection information. + + Contains details about the Kai (MongoDB-compatible) private connection endpoint. + + """ + + service_name: Optional[str] + endpoint_id: Optional[str] + status: Optional[str] + + def __init__( + self, + service_name: Optional[str] = None, + endpoint_id: Optional[str] = None, + status: Optional[str] = None, + ): + #: Service name for Kai private connection + self.service_name = service_name + + #: Endpoint ID for Kai private connection + self.endpoint_id = endpoint_id + + #: Status of the Kai private connection + self.status = normalize_connection_status(status) if status else status + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> 'KaiPrivateConnectionInfo': + """Construct from a dictionary.""" + return cls( + service_name=obj.get('serviceName'), + endpoint_id=obj.get('endpointID'), + status=obj.get('status'), + ) + + +class PrivateConnectionsMixin: + """Mixin class that adds private connection methods to WorkspaceManager.""" + + def create_private_connection( + self, + workspace_group_id: str, + type: str, + allow_list: Optional[List[str]] = None, + ) -> PrivateConnection: + """ + Create a new private connection. + + Parameters + ---------- + workspace_group_id : str + ID of the workspace group + type : str + Type of private connection (e.g., 'PRIVATELINK', 'PRIVATECONNECT') + allow_list : List[str], optional + List of allowed principals/accounts + + Returns + ------- + :class:`PrivateConnection` + + """ + manager = cast('WorkspaceManager', self) + data: Dict[str, Any] = { + 'workspaceGroupID': workspace_group_id, + 'type': normalize_connection_type(type), + } + if allow_list is not None: + data['allowList'] = allow_list + + res = manager._post('privateConnections', json=data) + return self.get_private_connection(res.json()['privateConnectionID']) + + def get_private_connection(self, connection_id: str) -> PrivateConnection: + """ + Retrieve a private connection by ID. + + Parameters + ---------- + connection_id : str + ID of the private connection + + Returns + ------- + :class:`PrivateConnection` + + """ + manager = cast('WorkspaceManager', self) + res = manager._get(f'privateConnections/{connection_id}') + return PrivateConnection.from_dict(res.json(), manager) diff --git a/singlestoredb/management/projects.py b/singlestoredb/management/projects.py new file mode 100644 index 00000000..13b57104 --- /dev/null +++ b/singlestoredb/management/projects.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +"""SingleStoreDB Project Management.""" +from __future__ import annotations + +import datetime +from typing import Any +from typing import cast +from typing import Dict +from typing import Optional +from typing import TYPE_CHECKING + +from .utils import NamedList +from .utils import normalize_project_edition +from .utils import require_fields +from .utils import to_datetime +from .utils import vars_to_str + +if TYPE_CHECKING: + from .workspace import WorkspaceManager + + +class Project: + """ + SingleStoreDB project definition. + + This object is not instantiated directly. It is used in the results + of API calls on the :class:`WorkspaceManager`. Projects are accessed + via :attr:`WorkspaceManager.projects`. + + See Also + -------- + :attr:`WorkspaceManager.projects` + + """ + + id: str + name: str + edition: Optional[str] + created_at: Optional[datetime.datetime] + + def __init__( + self, + project_id: str, + name: str, + edition: Optional[str] = None, + created_at: Optional[datetime.datetime] = None, + ): + #: Unique ID of the project + self.id = project_id + + #: Name of the project + self.name = name + + #: Edition of the project (e.g., 'STANDARD', 'ENTERPRISE', 'SHARED') + self.edition = normalize_project_edition(edition) if edition else edition + + #: Timestamp of when the project was created + self.created_at = created_at + + self._manager: Optional[WorkspaceManager] = None + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict(cls, obj: Dict[str, Any], manager: 'WorkspaceManager') -> 'Project': + """ + Construct a Project from a dictionary of values. + + Parameters + ---------- + obj : dict + Dictionary of values + manager : WorkspaceManager + The WorkspaceManager the Project belongs to + + Returns + ------- + :class:`Project` + + """ + require_fields(obj, 'projectID', 'name') + out = cls( + project_id=obj['projectID'], + name=obj['name'], + edition=obj.get('edition'), + created_at=to_datetime(obj.get('createdAt')), + ) + out._manager = manager + return out + + +class ProjectsMixin: + """Mixin class that adds project management methods to WorkspaceManager.""" + + @property + def projects(self) -> NamedList[Project]: + """Return a list of all projects in the organization.""" + manager = cast('WorkspaceManager', self) + res = manager._get('projects') + return NamedList([ + Project.from_dict(item, manager) + for item in res.json() + ]) diff --git a/singlestoredb/management/region.py b/singlestoredb/management/region.py index 7bc39a7e..850707ca 100644 --- a/singlestoredb/management/region.py +++ b/singlestoredb/management/region.py @@ -5,6 +5,7 @@ from .manager import Manager from .utils import NamedList +from .utils import normalize_cloud_provider from .utils import vars_to_str @@ -32,8 +33,8 @@ def __init__( #: Name of the region self.name = name - #: Name of the cloud provider - self.provider = provider + #: Name of the cloud provider (e.g., 'AWS', 'GCP', 'AZURE') + self.provider = normalize_cloud_provider(provider) or provider #: Name of the provider region self.region_name = region_name diff --git a/singlestoredb/management/roles.py b/singlestoredb/management/roles.py new file mode 100644 index 00000000..c7e267c3 --- /dev/null +++ b/singlestoredb/management/roles.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python +"""SingleStoreDB Role Management.""" +from __future__ import annotations + +from typing import Any +from typing import cast +from typing import Dict +from typing import List +from typing import Optional +from typing import TYPE_CHECKING + +from ..exceptions import ManagementError +from .utils import NamedList +from .utils import normalize_resource_type +from .utils import require_fields +from .utils import vars_to_str + +if TYPE_CHECKING: + from .workspace import WorkspaceManager + + +class Role: + """ + SingleStoreDB role definition. + + This object is not instantiated directly. It is used in the results + of API calls on the :class:`WorkspaceManager`. Roles are accessed using + :meth:`WorkspaceManager.get_roles` or :meth:`WorkspaceManager.get_role`. + + See Also + -------- + :meth:`WorkspaceManager.get_roles` + :meth:`WorkspaceManager.get_role` + :meth:`WorkspaceManager.create_role` + + """ + + name: str + resource_type: str + permissions: List[str] + inherits: List[str] + is_custom: bool + + def __init__( + self, + name: str, + resource_type: str, + permissions: Optional[List[str]] = None, + inherits: Optional[List[str]] = None, + is_custom: bool = False, + ): + #: Name of the role + self.name = name + + #: Resource type this role applies to (e.g., 'organization', 'workspace') + self.resource_type = normalize_resource_type(resource_type) or resource_type + + #: List of permissions granted by this role + self.permissions = permissions or [] + + #: List of roles this role inherits from + self.inherits = inherits or [] + + #: Whether this is a custom role + self.is_custom = is_custom + + self._manager: Optional[WorkspaceManager] = None + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict( + cls, + obj: Dict[str, Any], + resource_type: str, + manager: 'WorkspaceManager', + ) -> 'Role': + """ + Construct a Role from a dictionary of values. + + Parameters + ---------- + obj : dict + Dictionary of values + resource_type : str + The resource type this role applies to + manager : WorkspaceManager + The WorkspaceManager the Role belongs to + + Returns + ------- + :class:`Role` + + """ + require_fields(obj, 'name') + out = cls( + name=obj['name'], + resource_type=resource_type, + permissions=obj.get('permissions', []), + inherits=obj.get('inherits', []), + is_custom=obj.get('isCustom', False), + ) + out._manager = manager + return out + + def update( + self, + name: Optional[str] = None, + permissions: Optional[List[str]] = None, + inherits: Optional[List[str]] = None, + ) -> None: + """ + Update the role. + + Parameters + ---------- + name : str, optional + New name for the role + permissions : List[str], optional + New list of permissions for the role + inherits : List[str], optional + New list of roles to inherit from + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + + if not self.is_custom: + raise ManagementError( + msg='Cannot update a built-in role.', + ) + + data: Dict[str, Any] = {} + if name is not None: + data['name'] = name + if permissions is not None: + data['permissions'] = permissions + if inherits is not None: + data['inherits'] = inherits + + if not data: + return # No parameters provided, nothing to update + + old_name = self.name + self._manager._patch( + f'roles/{self.resource_type}/{old_name}', + json=data, + ) + # Update local state (refresh not possible if name changed) + if name is not None: + self.name = name + if permissions is not None: + self.permissions = permissions + if inherits is not None: + self.inherits = inherits + + def delete(self) -> None: + """Delete the role.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + + if not self.is_custom: + raise ManagementError( + msg='Cannot delete a built-in role.', + ) + + self._manager._delete(f'roles/{self.resource_type}/{self.name}') + + +class RolesMixin: + """Mixin class that adds role management methods to WorkspaceManager.""" + + def get_roles(self, resource_type: str) -> NamedList[Role]: + """ + Get all roles for a resource type. + + Parameters + ---------- + resource_type : str + The resource type (e.g., 'Organization', 'WorkspaceGroup', 'Secret') + + Returns + ------- + :class:`NamedList` of :class:`Role` + + """ + manager = cast('WorkspaceManager', self) + resource_type = normalize_resource_type(resource_type) or resource_type + res = manager._get(f'roles/{resource_type}') + return NamedList([ + Role.from_dict(item, resource_type, manager) + for item in res.json() + ]) + + def get_role(self, resource_type: str, role_name: str) -> Role: + """ + Get a specific role. + + Parameters + ---------- + resource_type : str + The resource type (e.g., 'Organization', 'WorkspaceGroup', 'Secret') + role_name : str + Name of the role + + Returns + ------- + :class:`Role` + + """ + manager = cast('WorkspaceManager', self) + resource_type = normalize_resource_type(resource_type) or resource_type + res = manager._get(f'roles/{resource_type}/{role_name}') + return Role.from_dict(res.json(), resource_type, manager) + + def create_role( + self, + resource_type: str, + name: str, + permissions: Optional[List[str]] = None, + inherits: Optional[List[str]] = None, + ) -> Role: + """ + Create a new custom role. + + Parameters + ---------- + resource_type : str + The resource type (e.g., 'Organization', 'WorkspaceGroup', 'Secret') + name : str + Name for the new role + permissions : List[str], optional + List of permissions to grant + inherits : List[str], optional + List of roles to inherit from + + Returns + ------- + :class:`Role` + + """ + manager = cast('WorkspaceManager', self) + resource_type = normalize_resource_type(resource_type) or resource_type + data: Dict[str, Any] = {'name': name} + if permissions is not None: + data['permissions'] = permissions + if inherits is not None: + data['inherits'] = inherits + + manager._post(f'roles/{resource_type}', json=data) + return self.get_role(resource_type, name) diff --git a/singlestoredb/management/storage.py b/singlestoredb/management/storage.py new file mode 100644 index 00000000..061d3cec --- /dev/null +++ b/singlestoredb/management/storage.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python +"""SingleStoreDB Storage and Disaster Recovery Management.""" +from __future__ import annotations + +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import TYPE_CHECKING + +from .utils import normalize_cloud_provider +from .utils import vars_to_str + +if TYPE_CHECKING: + from .manager import Manager + + +class DRRegion: + """ + Available region for disaster recovery. + + """ + + region_id: str + region: str + provider: str + + def __init__( + self, + region_id: str, + region: str, + provider: str, + ): + #: Unique ID of the region + self.region_id = region_id + + #: Region name/code + self.region = region + + #: Cloud provider (e.g., 'AWS', 'GCP', 'AZURE') + self.provider = normalize_cloud_provider(provider) or provider + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> 'DRRegion': + """Construct from a dictionary.""" + return cls( + region_id=obj.get('regionID', ''), + region=obj.get('region', ''), + provider=obj.get('provider', ''), + ) + + +class DRStatus: + """ + Disaster recovery status information. + + """ + + state: str + primary_region_id: Optional[str] + secondary_region_id: Optional[str] + databases: List[str] + + def __init__( + self, + state: str, + primary_region_id: Optional[str] = None, + secondary_region_id: Optional[str] = None, + databases: Optional[List[str]] = None, + ): + #: Current DR state (e.g., 'ENABLED', 'DISABLED', 'FAILOVER_IN_PROGRESS') + self.state = state + + #: ID of the primary region + self.primary_region_id = primary_region_id + + #: ID of the secondary (DR) region + self.secondary_region_id = secondary_region_id + + #: List of databases included in DR + self.databases = databases or [] + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> 'DRStatus': + """Construct from a dictionary.""" + return cls( + state=obj.get('state', ''), + primary_region_id=obj.get('primaryRegionID'), + secondary_region_id=obj.get('secondaryRegionID'), + databases=obj.get('databases', []), + ) + + +class DisasterRecovery: + """ + Disaster recovery operations for a workspace group. + + This object is not instantiated directly. It is accessed via + :attr:`Storage.dr`. + + """ + + def __init__(self, workspace_group_id: str, manager: 'Manager'): + self._workspace_group_id = workspace_group_id + self._manager = manager + + def __str__(self) -> str: + """Return string representation.""" + return f'DisasterRecovery(workspace_group_id={self._workspace_group_id!r})' + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + def get_status(self) -> DRStatus: + """ + Get the current disaster recovery status. + + Returns + ------- + :class:`DRStatus` + + """ + res = self._manager._get( + f'workspaceGroups/{self._workspace_group_id}/storage/DR/status', + ) + return DRStatus.from_dict(res.json()) + + def get_regions(self) -> List[DRRegion]: + """ + Get available regions for disaster recovery. + + Returns + ------- + List[:class:`DRRegion`] + + """ + res = self._manager._get( + f'workspaceGroups/{self._workspace_group_id}/storage/DR/regions', + ) + return [ + DRRegion.from_dict(item) + for item in res.json().get('regions', []) + ] + + def setup( + self, + region_id: str, + databases: Optional[List[str]] = None, + ) -> None: + """ + Set up disaster recovery. + + Parameters + ---------- + region_id : str + ID of the secondary region for DR + databases : List[str], optional + List of database names to include in DR + + """ + data: Dict[str, Any] = {'regionID': region_id} + if databases is not None: + data['databases'] = databases + + self._manager._post( + f'workspaceGroups/{self._workspace_group_id}/storage/DR/setup', + json=data, + ) + + def failover(self) -> None: + """ + Trigger a failover to the secondary region. + + """ + self._manager._patch( + f'workspaceGroups/{self._workspace_group_id}/storage/DR/failover', + ) + + def failback(self) -> None: + """ + Trigger a failback to the primary region. + + """ + self._manager._patch( + f'workspaceGroups/{self._workspace_group_id}/storage/DR/failback', + ) + + def start_pre_provision(self) -> None: + """ + Start pre-provisioning for faster failover. + + """ + self._manager._patch( + f'workspaceGroups/{self._workspace_group_id}/storage/DR/startPreProvision', + ) + + def stop_pre_provision(self) -> None: + """ + Stop pre-provisioning. + + """ + self._manager._patch( + f'workspaceGroups/{self._workspace_group_id}/storage/DR/stopPreProvision', + ) + + +class Storage: + """ + Storage operations for a workspace group. + + This object is not instantiated directly. It is accessed via + :attr:`WorkspaceGroup.storage`. + + """ + + def __init__(self, workspace_group_id: str, manager: 'Manager'): + self._workspace_group_id = workspace_group_id + self._manager = manager + self._dr: Optional[DisasterRecovery] = None + + def __str__(self) -> str: + """Return string representation.""" + return f'Storage(workspace_group_id={self._workspace_group_id!r})' + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @property + def dr(self) -> DisasterRecovery: + """ + Access disaster recovery operations. + + Returns + ------- + :class:`DisasterRecovery` + + """ + if self._dr is None: + self._dr = DisasterRecovery(self._workspace_group_id, self._manager) + return self._dr + + def update_retention_period(self, days: int) -> None: + """ + Update the storage retention period. + + Parameters + ---------- + days : int + Number of days to retain storage data + + """ + self._manager._patch( + f'workspaceGroups/{self._workspace_group_id}/storage/retentionPeriod', + json={'retentionPeriod': days}, + ) diff --git a/singlestoredb/management/teams.py b/singlestoredb/management/teams.py new file mode 100644 index 00000000..f9238c01 --- /dev/null +++ b/singlestoredb/management/teams.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python +"""SingleStoreDB Team Management.""" +from __future__ import annotations + +import datetime +from typing import Any +from typing import cast +from typing import Dict +from typing import List +from typing import Optional +from typing import TYPE_CHECKING + +from ..exceptions import ManagementError +from .utils import NamedList +from .utils import normalize_resource_type +from .utils import require_fields +from .utils import to_datetime +from .utils import vars_to_str + +if TYPE_CHECKING: + from .workspace import WorkspaceManager + + +class Team: + """ + SingleStoreDB team definition. + + This object is not instantiated directly. It is used in the results + of API calls on the :class:`WorkspaceManager`. Teams are created using + :meth:`WorkspaceManager.create_team`, or existing teams are accessed by + either :attr:`WorkspaceManager.teams` or by calling + :meth:`WorkspaceManager.get_team`. + + See Also + -------- + :meth:`WorkspaceManager.create_team` + :meth:`WorkspaceManager.get_team` + :attr:`WorkspaceManager.teams` + + """ + + id: str + name: str + description: Optional[str] + member_ids: List[str] + created_at: Optional[datetime.datetime] + + def __init__( + self, + team_id: str, + name: str, + description: Optional[str] = None, + member_ids: Optional[List[str]] = None, + created_at: Optional[datetime.datetime] = None, + ): + #: Unique ID of the team + self.id = team_id + + #: Name of the team + self.name = name + + #: Description of the team + self.description = description + + #: List of user IDs that are members of this team + self.member_ids = member_ids or [] + + #: Timestamp of when the team was created + self.created_at = created_at + + self._manager: Optional[WorkspaceManager] = None + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict(cls, obj: Dict[str, Any], manager: 'WorkspaceManager') -> 'Team': + """ + Construct a Team from a dictionary of values. + + Parameters + ---------- + obj : dict + Dictionary of values + manager : WorkspaceManager + The WorkspaceManager the Team belongs to + + Returns + ------- + :class:`Team` + + """ + require_fields(obj, 'teamID', 'name') + out = cls( + team_id=obj['teamID'], + name=obj['name'], + description=obj.get('description'), + member_ids=obj.get('memberIDs', []), + created_at=to_datetime(obj.get('createdAt')), + ) + out._manager = manager + return out + + def refresh(self) -> 'Team': + """Update the object to the current state.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + new_obj = self._manager.get_team(self.id) + for name, value in vars(new_obj).items(): + setattr(self, name, value) + return self + + def update( + self, + name: Optional[str] = None, + description: Optional[str] = None, + member_ids: Optional[List[str]] = None, + ) -> None: + """ + Update the team definition. + + Parameters + ---------- + name : str, optional + New name for the team + description : str, optional + New description for the team + member_ids : List[str], optional + New list of member user IDs + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + data = { + k: v for k, v in dict( + name=name, + description=description, + memberIDs=member_ids, + ).items() if v is not None + } + self._manager._patch(f'teams/{self.id}', json=data) + self.refresh() + + def delete(self) -> None: + """Delete the team.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + self._manager._delete(f'teams/{self.id}') + + def get_access_controls(self) -> Dict[str, Any]: + """ + Get the access controls (RBAC) for this team. + + Returns + ------- + Dict[str, Any] + Dictionary containing access control information + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get(f'teams/{self.id}/accessControls') + return res.json() + + def update_access_controls( + self, + grants: Optional[List[Dict[str, Any]]] = None, + revokes: Optional[List[Dict[str, Any]]] = None, + ) -> None: + """ + Update the access controls (RBAC) for this team. + + Parameters + ---------- + grants : List[Dict[str, Any]], optional + List of access grants to add. Each grant should contain + 'resourceID', 'resourceType', and 'role'. + revokes : List[Dict[str, Any]], optional + List of access grants to remove. Each revoke should contain + 'resourceID', 'resourceType', and 'role'. + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + data = {} + if grants is not None: + data['grants'] = grants + if revokes is not None: + data['revokes'] = revokes + self._manager._patch(f'teams/{self.id}/accessControls', json=data) + + def get_identity_roles(self, resource_type: str) -> List[Dict[str, Any]]: + """ + Get the roles assigned to this team for a specific resource type. + + Parameters + ---------- + resource_type : str + The type of resource (e.g., 'Organization', 'WorkspaceGroup') + + Returns + ------- + List[Dict[str, Any]] + List of role assignments + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get( + f'teams/{self.id}/identityRoles', + params={'resourceType': normalize_resource_type(resource_type)}, + ) + return res.json() + + +class TeamsMixin: + """Mixin class that adds team management methods to WorkspaceManager.""" + + @property + def teams(self) -> NamedList[Team]: + """Return a list of all teams in the organization.""" + manager = cast('WorkspaceManager', self) + res = manager._get('teams') + return NamedList([ + Team.from_dict(item, manager) + for item in res.json() + ]) + + def get_team(self, team_id: str) -> Team: + """ + Retrieve a team by ID. + + Parameters + ---------- + team_id : str + ID of the team + + Returns + ------- + :class:`Team` + + """ + manager = cast('WorkspaceManager', self) + res = manager._get(f'teams/{team_id}') + return Team.from_dict(res.json(), manager) + + def create_team( + self, + name: str, + description: Optional[str] = None, + member_ids: Optional[List[str]] = None, + ) -> Team: + """ + Create a new team. + + Parameters + ---------- + name : str + Name of the team + description : str, optional + Description of the team + member_ids : List[str], optional + List of user IDs to add as members + + Returns + ------- + :class:`Team` + + """ + manager = cast('WorkspaceManager', self) + data: Dict[str, Any] = {'name': name} + if description is not None: + data['description'] = description + if member_ids is not None: + data['memberIDs'] = member_ids + + res = manager._post('teams', json=data) + return self.get_team(res.json()['teamID']) diff --git a/singlestoredb/management/users.py b/singlestoredb/management/users.py new file mode 100644 index 00000000..50ea7d68 --- /dev/null +++ b/singlestoredb/management/users.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python +"""SingleStoreDB User Management.""" +from __future__ import annotations + +import datetime +from typing import Any +from typing import cast +from typing import Dict +from typing import List +from typing import Optional +from typing import TYPE_CHECKING + +from ..exceptions import ManagementError +from .utils import NamedList +from .utils import normalize_resource_type +from .utils import require_fields +from .utils import to_datetime +from .utils import vars_to_str + +if TYPE_CHECKING: + from .workspace import WorkspaceManager + + +class User: + """ + SingleStoreDB user definition. + + This object is not instantiated directly. It is used in the results + of API calls on the :class:`WorkspaceManager`. Users are accessed by + either :attr:`WorkspaceManager.users` or by calling + :meth:`WorkspaceManager.get_user`. + + See Also + -------- + :meth:`WorkspaceManager.get_user` + :meth:`WorkspaceManager.add_user` + :attr:`WorkspaceManager.users` + :attr:`WorkspaceManager.current_user` + + """ + + id: str + email: str + first_name: Optional[str] + last_name: Optional[str] + created_at: Optional[datetime.datetime] + + def __init__( + self, + user_id: str, + email: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + created_at: Optional[datetime.datetime] = None, + ): + #: Unique ID of the user + self.id = user_id + + #: Email address of the user + self.email = email + + #: First name of the user + self.first_name = first_name + + #: Last name of the user + self.last_name = last_name + + #: Timestamp of when the user was created + self.created_at = created_at + + self._manager: Optional[WorkspaceManager] = None + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict(cls, obj: Dict[str, Any], manager: 'WorkspaceManager') -> 'User': + """ + Construct a User from a dictionary of values. + + Parameters + ---------- + obj : dict + Dictionary of values + manager : WorkspaceManager + The WorkspaceManager the User belongs to + + Returns + ------- + :class:`User` + + """ + require_fields(obj, 'userID', 'email') + out = cls( + user_id=obj['userID'], + email=obj['email'], + first_name=obj.get('firstName'), + last_name=obj.get('lastName'), + created_at=to_datetime(obj.get('createdAt')), + ) + out._manager = manager + return out + + def get_identity_roles(self, resource_type: str) -> List[Dict[str, Any]]: + """ + Get the roles assigned to this user for a specific resource type. + + Parameters + ---------- + resource_type : str + The type of resource (e.g., 'Organization', 'WorkspaceGroup') + + Returns + ------- + List[Dict[str, Any]] + List of role assignments + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get( + f'users/{self.id}/identityRoles', + params={'resourceType': normalize_resource_type(resource_type)}, + ) + return res.json() + + def remove(self) -> None: + """Remove the user from the organization.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + self._manager._delete(f'users/{self.id}') + + +class UsersMixin: + """Mixin class that adds user management methods to WorkspaceManager.""" + + @property + def users(self) -> NamedList[User]: + """Return a list of all users in the organization.""" + manager = cast('WorkspaceManager', self) + res = manager._get('users') + return NamedList([ + User.from_dict(item, manager) + for item in res.json() + ]) + + @property + def current_user(self) -> User: + """Return the current authenticated user.""" + manager = cast('WorkspaceManager', self) + res = manager._get('users/current') + return User.from_dict(res.json(), manager) + + def get_user(self, user_id: str) -> User: + """ + Retrieve a user by ID. + + Parameters + ---------- + user_id : str + ID of the user + + Returns + ------- + :class:`User` + + """ + manager = cast('WorkspaceManager', self) + res = manager._get(f'users/{user_id}') + return User.from_dict(res.json(), manager) + + def add_user(self, email: str) -> User: + """ + Add an existing user to the organization by email. + + Parameters + ---------- + email : str + Email address of the user to add + + Returns + ------- + :class:`User` + + """ + manager = cast('WorkspaceManager', self) + res = manager._post('users', json={'email': email}) + return self.get_user(res.json()['userID']) diff --git a/singlestoredb/management/utils.py b/singlestoredb/management/utils.py index 5aa072e4..34aac661 100644 --- a/singlestoredb/management/utils.py +++ b/singlestoredb/management/utils.py @@ -328,6 +328,95 @@ def vars_to_str(obj: Any) -> str: return '{}({})'.format(type(obj).__name__, ', '.join(attrs)) +def _normalize_upper(s: Optional[str]) -> Optional[str]: + """Normalize string to uppercase.""" + if s is None: + return None + return s.upper() + + +def _normalize_lower(s: Optional[str]) -> Optional[str]: + """Normalize string to lowercase.""" + if s is None: + return None + return s.lower() + + +def _normalize_title(s: Optional[str]) -> Optional[str]: + """Normalize string to title case.""" + if s is None: + return None + return s.title() + + +# Specific enum normalizers - use these in API calls and constructors +def normalize_resource_type(s: Optional[str]) -> Optional[str]: + """Normalize resource type to lowercase (e.g., 'organization', 'workspace').""" + return _normalize_lower(s) + + +def normalize_connection_type(s: Optional[str]) -> Optional[str]: + """Normalize private connection type to uppercase (e.g., 'INBOUND', 'OUTBOUND').""" + return _normalize_upper(s) + + +def normalize_audit_log_type(s: Optional[str]) -> Optional[str]: + """Normalize audit log type to title case (e.g., 'Login').""" + return _normalize_title(s) + + +def normalize_audit_log_source(s: Optional[str]) -> Optional[str]: + """Normalize audit log source to title case (e.g., 'Portal', 'Admin').""" + return _normalize_title(s) + + +def normalize_cloud_provider(s: Optional[str]) -> Optional[str]: + """Normalize cloud provider to uppercase (e.g., 'AWS', 'GCP', 'AZURE').""" + return _normalize_upper(s) + + +def normalize_deployment_type(s: Optional[str]) -> Optional[str]: + """Normalize deployment type to uppercase (e.g., 'PRODUCTION', 'NON-PRODUCTION').""" + return _normalize_upper(s) + + +def normalize_project_edition(s: Optional[str]) -> Optional[str]: + """Normalize project edition to uppercase (e.g., 'STANDARD', 'ENTERPRISE').""" + return _normalize_upper(s) + + +def normalize_invitation_state(s: Optional[str]) -> Optional[str]: + """Normalize invitation state to title case (e.g., 'Pending', 'Accepted').""" + return _normalize_title(s) + + +def normalize_connection_status(s: Optional[str]) -> Optional[str]: + """Normalize connection status to uppercase (e.g., 'PENDING', 'ACTIVE').""" + return _normalize_upper(s) + + +def require_fields(obj: Dict[str, Any], *fields: str) -> None: + """ + Validate that required fields are present in a dictionary. + + Parameters + ---------- + obj : dict + Dictionary to validate + *fields : str + Field names that must be present + + Raises + ------ + KeyError + If any required field is missing + + """ + missing = [f for f in fields if f not in obj] + if missing: + raise KeyError(f"Missing required field(s): {', '.join(missing)}") + + def single_item(s: Any) -> Any: """Return only item if ``s`` is a list, otherwise return ``s``.""" if isinstance(s, list): diff --git a/singlestoredb/management/workspace.py b/singlestoredb/management/workspace.py index 6df98a12..5f721160 100644 --- a/singlestoredb/management/workspace.py +++ b/singlestoredb/management/workspace.py @@ -18,6 +18,7 @@ from .. import config from .. import connection from ..exceptions import ManagementError +from .audit_logs import AuditLogsMixin from .billing_usage import BillingUsageItem from .files import FileLocation from .files import FilesObject @@ -25,12 +26,25 @@ from .files import FilesObjectBytesWriter from .files import FilesObjectTextReader from .files import FilesObjectTextWriter +from .invitations import InvitationsMixin from .manager import Manager from .organization import Organization +from .organization import SecretsMixin +from .private_connections import KaiPrivateConnectionInfo +from .private_connections import OutboundAllowListEntry +from .private_connections import PrivateConnection +from .private_connections import PrivateConnectionsMixin +from .projects import ProjectsMixin from .region import Region +from .roles import RolesMixin +from .storage import Storage +from .teams import TeamsMixin +from .users import UsersMixin from .utils import camel_to_snake_dict from .utils import from_datetime from .utils import NamedList +from .utils import normalize_cloud_provider +from .utils import normalize_deployment_type from .utils import PathLike from .utils import snake_to_camel from .utils import snake_to_camel_dict @@ -676,6 +690,9 @@ class Workspace(object): resume_attachments: Optional[List[Dict[str, Any]]] scaling_progress: Optional[int] last_resumed_at: Optional[datetime.datetime] + kai_enabled: Optional[bool] + auto_scale: Optional[Dict[str, Any]] + scale_factor: Optional[int] def __init__( self, @@ -693,6 +710,9 @@ def __init__( resume_attachments: Optional[List[Dict[str, Any]]] = None, scaling_progress: Optional[int] = None, last_resumed_at: Optional[Union[str, datetime.datetime]] = None, + kai_enabled: Optional[bool] = None, + auto_scale: Optional[Dict[str, Any]] = None, + scale_factor: Optional[int] = None, ): #: Name of the workspace self.name = name @@ -728,8 +748,11 @@ def __init__( #: Multiplier for the persistent cache self.cache_config = cache_config - #: Deployment type of the workspace - self.deployment_type = deployment_type + #: Deployment type of the workspace (e.g., 'PRODUCTION', 'NON-PRODUCTION') + if deployment_type: + self.deployment_type = normalize_deployment_type(deployment_type) + else: + self.deployment_type = deployment_type #: Database attachments self.resume_attachments = [ @@ -744,6 +767,15 @@ def __init__( #: Timestamp when workspace was last resumed self.last_resumed_at = to_datetime(last_resumed_at) + #: Whether Kai (MongoDB compatibility) is enabled + self.kai_enabled = kai_enabled + + #: Auto-scale settings (max_scale_factor, sensitivity) + self.auto_scale = camel_to_snake_dict(auto_scale) + + #: Scale factor for the workspace (1, 2, or 4) + self.scale_factor = scale_factor + self._manager: Optional[WorkspaceManager] = None def __str__(self) -> str: @@ -786,6 +818,9 @@ def from_dict(cls, obj: Dict[str, Any], manager: 'WorkspaceManager') -> 'Workspa last_resumed_at=obj.get('lastResumedAt'), resume_attachments=obj.get('resumeAttachments'), scaling_progress=obj.get('scalingProgress'), + kai_enabled=obj.get('kaiEnabled'), + auto_scale=obj.get('autoScale'), + scale_factor=obj.get('scaleFactor'), ) out._manager = manager return out @@ -796,6 +831,7 @@ def update( cache_config: Optional[int] = None, deployment_type: Optional[str] = None, size: Optional[str] = None, + auto_scale: Optional[Dict[str, Any]] = None, ) -> None: """ Update the workspace definition. @@ -810,9 +846,12 @@ def update( multiplier. It can have one of the following values: 1, 2, or 4. deployment_type : str, optional The deployment type that will be applied to all the workspaces - within the group + within the group (case-insensitive) size : str, optional Size of the workspace (in workspace size notation), such as "S-1". + auto_scale : Dict[str, Any], optional + Auto-scale settings: dict(max_scale_factor=int, sensitivity=str). + sensitivity can be 'LOW', 'MEDIUM', or 'HIGH' (case-insensitive). """ if self._manager is None: @@ -823,8 +862,9 @@ def update( k: v for k, v in dict( autoSuspend=snake_to_camel_dict(auto_suspend), cacheConfig=cache_config, - deploymentType=deployment_type, + deploymentType=normalize_deployment_type(deployment_type), size=size, + autoScale=snake_to_camel_dict(auto_scale), ).items() if v is not None } self._manager._patch(f'workspaces/{self.id}', json=data) @@ -984,6 +1024,56 @@ def resume( ) self.refresh() + @property + def private_connections(self) -> NamedList[PrivateConnection]: + """Return a list of private connections for this workspace.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get(f'workspaces/{self.id}/privateConnections') + return NamedList([ + PrivateConnection.from_dict(item, self._manager) + for item in res.json() + ]) + + def get_kai_private_connection_info(self) -> KaiPrivateConnectionInfo: + """ + Get Kai (MongoDB-compatible) private connection information. + + Returns + ------- + :class:`KaiPrivateConnectionInfo` + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get(f'workspaces/{self.id}/privateConnections/kai') + return KaiPrivateConnectionInfo.from_dict(res.json()) + + def get_outbound_allow_list(self) -> List[OutboundAllowListEntry]: + """ + Get the outbound allow list for this workspace. + + Returns + ------- + List[:class:`OutboundAllowListEntry`] + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get( + f'workspaces/{self.id}/privateConnections/outboundAllowList', + ) + return [ + OutboundAllowListEntry.from_dict(item) + for item in res.json().get('allowList', []) + ] + class WorkspaceGroup(object): """ @@ -1010,6 +1100,14 @@ class WorkspaceGroup(object): firewall_ranges: List[str] terminated_at: Optional[datetime.datetime] allow_all_traffic: bool + state: Optional[str] + deployment_type: Optional[str] + update_window: Optional[Dict[str, Any]] + smart_dr: Optional[bool] + expires_at: Optional[datetime.datetime] + project_id: Optional[str] + high_availability_two_zones: Optional[bool] + opt_in_preview_feature: Optional[bool] def __init__( self, @@ -1020,6 +1118,14 @@ def __init__( firewall_ranges: List[str], terminated_at: Optional[Union[str, datetime.datetime]], allow_all_traffic: Optional[bool], + state: Optional[str] = None, + deployment_type: Optional[str] = None, + update_window: Optional[Dict[str, Any]] = None, + smart_dr: Optional[bool] = None, + expires_at: Optional[Union[str, datetime.datetime]] = None, + project_id: Optional[str] = None, + high_availability_two_zones: Optional[bool] = None, + opt_in_preview_feature: Optional[bool] = None, ): #: Name of the workspace group self.name = name @@ -1042,6 +1148,33 @@ def __init__( #: Should all traffic be allowed? self.allow_all_traffic = allow_all_traffic or False + #: State of the workspace group (ACTIVE, PENDING, FAILED, TERMINATED) + self.state = state + + #: Deployment type (PRODUCTION or NON-PRODUCTION) + if deployment_type: + self.deployment_type = normalize_deployment_type(deployment_type) + else: + self.deployment_type = deployment_type + + #: Update window settings (day: 0-6, hour: 0-23) + self.update_window = camel_to_snake_dict(update_window) + + #: Whether Smart Disaster Recovery is enabled + self.smart_dr = smart_dr + + #: Timestamp of when the workspace group will expire + self.expires_at = to_datetime(expires_at) + + #: Project ID this workspace group belongs to + self.project_id = project_id + + #: Whether the workspace group spans two availability zones + self.high_availability_two_zones = high_availability_two_zones + + #: Whether preview features are enabled (NON-PRODUCTION only) + self.opt_in_preview_feature = opt_in_preview_feature + self._manager: Optional[WorkspaceManager] = None def __str__(self) -> str: @@ -1083,6 +1216,14 @@ def from_dict( firewall_ranges=obj.get('firewallRanges', []), terminated_at=obj.get('terminatedAt'), allow_all_traffic=obj.get('allowAllTraffic'), + state=obj.get('state'), + deployment_type=obj.get('deploymentType'), + update_window=obj.get('updateWindow'), + smart_dr=obj.get('smartDR'), + expires_at=obj.get('expiresAt'), + project_id=obj.get('projectID'), + high_availability_two_zones=obj.get('highAvailabilityTwoZones'), + opt_in_preview_feature=obj.get('optInPreviewFeature'), ) out._manager = manager return out @@ -1128,6 +1269,7 @@ def update( expires_at: Optional[str] = None, allow_all_traffic: Optional[bool] = None, update_window: Optional[Dict[str, int]] = None, + deployment_type: Optional[str] = None, ) -> None: """ Update the workspace group definition. @@ -1153,6 +1295,8 @@ def update( Allow all traffic to the workspace group update_window : Dict[str, int], optional Specify the day and hour of an update window: dict(day=0-6, hour=0-23) + deployment_type : str, optional + Deployment type: 'PRODUCTION' or 'NON-PRODUCTION' (case-insensitive) """ if self._manager is None: @@ -1167,6 +1311,7 @@ def update( expiresAt=expires_at, allowAllTraffic=allow_all_traffic, updateWindow=snake_to_camel_dict(update_window), + deploymentType=normalize_deployment_type(deployment_type), ).items() if v is not None } self._manager._patch(f'workspaceGroups/{self.id}', json=data) @@ -1222,6 +1367,7 @@ def create_workspace( auto_suspend: Optional[Dict[str, Any]] = None, cache_config: Optional[int] = None, enable_kai: Optional[bool] = None, + scale_factor: Optional[int] = None, wait_on_active: bool = False, wait_interval: int = 10, wait_timeout: int = 600, @@ -1244,6 +1390,8 @@ def create_workspace( multiplier. It can have one of the following values: 1, 2, or 4. enable_kai : bool, optional Whether to create a SingleStore Kai-enabled workspace + scale_factor : int, optional + Scale factor for the workspace. Valid values are 1, 2, or 4. wait_on_active : bool, optional Wait for the workspace to be active before returning wait_timeout : int, optional @@ -1269,6 +1417,7 @@ def create_workspace( auto_suspend=snake_to_camel_dict(auto_suspend), cache_config=cache_config, enable_kai=enable_kai, + scale_factor=scale_factor, wait_on_active=wait_on_active, wait_interval=wait_interval, wait_timeout=wait_timeout, @@ -1288,6 +1437,218 @@ def workspaces(self) -> NamedList[Workspace]: [Workspace.from_dict(item, self._manager) for item in res.json()], ) + @property + def private_connections(self) -> NamedList[PrivateConnection]: + """Return a list of private connections for this workspace group.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get(f'workspaceGroups/{self.id}/privateConnections') + return NamedList([ + PrivateConnection.from_dict(item, self._manager) + for item in res.json() + ]) + + @property + def storage(self) -> Storage: + """ + Access storage operations for this workspace group. + + Returns + ------- + :class:`Storage` + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + return Storage(self.id, self._manager) + + def get_access_controls(self) -> Dict[str, Any]: + """ + Get access control information for this workspace group. + + Returns + ------- + dict + Access control information including grants + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get(f'workspaceGroups/{self.id}/accessControls') + return res.json() + + def update_access_controls( + self, + grants: Optional[List[Dict[str, Any]]] = None, + revokes: Optional[List[Dict[str, Any]]] = None, + ) -> None: + """ + Update access controls for this workspace group. + + Parameters + ---------- + grants : list of dict, optional + List of grants to add, each with 'identity' and 'role' keys + revokes : list of dict, optional + List of revokes to remove, each with 'identity' and 'role' keys + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + + data: Dict[str, Any] = {} + if grants is not None: + data['grants'] = grants + if revokes is not None: + data['revokes'] = revokes + + if data: + self._manager._patch( + f'workspaceGroups/{self.id}/accessControls', + json=data, + ) + + def get_metrics(self) -> str: + """ + Get metrics for this workspace group in OpenMetrics format. + + Returns + ------- + str + Metrics in OpenMetrics/Prometheus format + + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + org = self._manager.organization + res = self._manager._get( + f'v2/organizations/{org.id}/workspaceGroups/{self.id}/metrics', + ) + return res.text + + +class StarterWorkspaceUser: + """ + SingleStoreDB starter workspace user definition. + + This object is not instantiated directly. It is used in the results + of API calls on the :class:`StarterWorkspace`. Users are created using + :meth:`StarterWorkspace.create_user`. + + See Also + -------- + :meth:`StarterWorkspace.create_user` + :meth:`StarterWorkspace.get_user` + + """ + + id: str + name: str + password: Optional[str] + + def __init__( + self, + user_id: str, + name: str, + starter_workspace_id: str, + password: Optional[str] = None, + ): + #: Unique ID of the user + self.id = user_id + + #: Username + self.name = name + + #: Password (only available immediately after creation) + self.password = password + + self._starter_workspace_id = starter_workspace_id + self._manager: Optional[Manager] = None + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict( + cls, + obj: Dict[str, Any], + starter_workspace_id: str, + manager: Manager, + password: Optional[str] = None, + ) -> 'StarterWorkspaceUser': + """ + Construct a StarterWorkspaceUser from a dictionary of values. + + Parameters + ---------- + obj : dict + Dictionary of values + starter_workspace_id : str + ID of the starter workspace this user belongs to + manager : Manager + The Manager the user belongs to + password : str, optional + Password (only available after creation) + + Returns + ------- + :class:`StarterWorkspaceUser` + + """ + out = cls( + user_id=obj.get('userID', ''), + name=obj.get('userName', ''), + starter_workspace_id=starter_workspace_id, + password=password, + ) + out._manager = manager + return out + + def update(self, password: str) -> None: + """ + Update the user's password. + + Parameters + ---------- + password : str + New password for the user + + """ + if self._manager is None: + raise ManagementError( + msg='No manager is associated with this object.', + ) + self._manager._patch( + f'sharedtier/virtualWorkspaces/{self._starter_workspace_id}/users/{self.id}', + json={'password': password}, + ) + self.password = password + + def delete(self) -> None: + """Delete the user.""" + if self._manager is None: + raise ManagementError( + msg='No manager is associated with this object.', + ) + self._manager._delete( + f'sharedtier/virtualWorkspaces/{self._starter_workspace_id}/users/{self.id}', + ) + class StarterWorkspace(object): """ @@ -1454,7 +1815,7 @@ def create_user( self, username: str, password: Optional[str] = None, - ) -> Dict[str, str]: + ) -> StarterWorkspaceUser: """ Create a new user for this starter workspace. @@ -1468,8 +1829,8 @@ def create_user( Returns ------- - Dict[str, str] - Dictionary containing 'userID' and 'password' of the created user + :class:`StarterWorkspaceUser` + The created user object Raises ------ @@ -1497,16 +1858,53 @@ def create_user( if not user_id: raise ManagementError(msg='No userID returned from API') - # Return the password provided by user or generated by API + # Get the password provided by user or generated by API returned_password = password if password is not None \ else response_data.get('password') if not returned_password: raise ManagementError(msg='No password available from API response') - return { - 'user_id': user_id, - 'password': returned_password, - } + user = StarterWorkspaceUser( + user_id=user_id, + name=username, + starter_workspace_id=self.id, + password=returned_password, + ) + user._manager = self._manager + return user + + def get_user(self, user_id: str) -> StarterWorkspaceUser: + """ + Get a user by ID. + + Parameters + ---------- + user_id : str + The ID of the user to retrieve + + Returns + ------- + :class:`StarterWorkspaceUser` + + Raises + ------ + ManagementError + If no workspace manager is associated with this object. + """ + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + + res = self._manager._get( + f'sharedtier/virtualWorkspaces/{self.id}/users/{user_id}', + ) + + return StarterWorkspaceUser.from_dict( + res.json(), + self.id, + self._manager, + ) class Billing(object): @@ -1580,7 +1978,17 @@ def current(self) -> Organization: return Organization.from_dict(res, self._manager) -class WorkspaceManager(Manager): +class WorkspaceManager( + AuditLogsMixin, + InvitationsMixin, + PrivateConnectionsMixin, + ProjectsMixin, + RolesMixin, + SecretsMixin, + TeamsMixin, + UsersMixin, + Manager, +): """ SingleStoreDB workspace manager. @@ -1644,6 +2052,21 @@ def regions(self) -> NamedList[Region]: res = self._get('regions') return NamedList([Region.from_dict(item, self) for item in res.json()]) + @ttl_property(datetime.timedelta(hours=1)) + def regions_v2(self) -> NamedList[Region]: + """ + Return a list of available regions using the v2 API. + + This is the non-deprecated version of the regions endpoint. + + Returns + ------- + :class:`NamedList` of :class:`Region` + + """ + res = self._get('v2/regions') + return NamedList([Region.from_dict(item, self) for item in res.json()]) + @ttl_property(datetime.timedelta(hours=1)) def shared_tier_regions(self) -> NamedList[Region]: """Return a list of regions that support shared tier workspaces.""" @@ -1664,6 +2087,10 @@ def create_workspace_group( smart_dr: Optional[bool] = None, allow_all_traffic: Optional[bool] = None, update_window: Optional[Dict[str, int]] = None, + deployment_type: Optional[str] = None, + high_availability_two_zones: Optional[bool] = None, + opt_in_preview_feature: Optional[bool] = None, + project_id: Optional[str] = None, ) -> WorkspaceGroup: """ Create a new workspace group. @@ -1706,6 +2133,14 @@ def create_workspace_group( Allow all traffic to the workspace group update_window : Dict[str, int], optional Specify the day and hour of an update window: dict(day=0-6, hour=0-23) + deployment_type : str, optional + Deployment type: 'PRODUCTION' or 'NON-PRODUCTION' (case-insensitive) + high_availability_two_zones : bool, optional + Whether to deploy the workspace group across two availability zones + opt_in_preview_feature : bool, optional + Whether to enable preview features (only for NON-PRODUCTION) + project_id : str, optional + Project ID to assign the workspace group to Returns ------- @@ -1725,6 +2160,10 @@ def create_workspace_group( smartDR=smart_dr, allowAllTraffic=allow_all_traffic, updateWindow=snake_to_camel_dict(update_window), + deploymentType=normalize_deployment_type(deployment_type), + highAvailabilityTwoZones=high_availability_two_zones, + optInPreviewFeature=opt_in_preview_feature, + projectID=project_id, ), ) return self.get_workspace_group(res.json()['workspaceGroupID']) @@ -1737,6 +2176,7 @@ def create_workspace( auto_suspend: Optional[Dict[str, Any]] = None, cache_config: Optional[int] = None, enable_kai: Optional[bool] = None, + scale_factor: Optional[int] = None, wait_on_active: bool = False, wait_interval: int = 10, wait_timeout: int = 600, @@ -1761,6 +2201,8 @@ def create_workspace( multiplier. It can have one of the following values: 1, 2, or 4. enable_kai : bool, optional Whether to create a SingleStore Kai-enabled workspace + scale_factor : int, optional + Scale factor for the workspace. Valid values are 1, 2, or 4. wait_on_active : bool, optional Wait for the workspace to be active before returning wait_timeout : int, optional @@ -1784,6 +2226,7 @@ def create_workspace( autoSuspend=snake_to_camel_dict(auto_suspend), cacheConfig=cache_config, enableKai=enable_kai, + scaleFactor=scale_factor, ), ) out = self.get_workspace(res.json()['workspaceID']) @@ -1870,7 +2313,7 @@ def create_starter_workspace( database_name : str Name of the database for the starter workspace provider : str - Cloud provider for the starter workspace (e.g., 'aws', 'gcp', 'azure') + Cloud provider for the starter workspace (e.g., 'AWS', 'GCP', 'Azure') region_name : str Cloud provider region for the starter workspace (e.g., 'us-east-1') @@ -1882,7 +2325,7 @@ def create_starter_workspace( payload = { 'name': name, 'databaseName': database_name, - 'provider': provider, + 'provider': normalize_cloud_provider(provider), 'regionName': region_name, }