diff --git a/.github/workflows/test_and_publish.yml b/.github/workflows/test_and_publish.yml new file mode 100644 index 0000000..49d9884 --- /dev/null +++ b/.github/workflows/test_and_publish.yml @@ -0,0 +1,192 @@ +name: Tests + +on: + push: + branches: + - main + - '*.*.*' + + pull_request: + branches: + - main + - '*.*.*' + + release: + types: [ published ] + +jobs: + + autoflake: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@master + with: + python-version: '3.14' + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install packages + run: poetry install --with=dev --no-root + + - name: Autoflake + run: | + poetry run autoflake kasm/ -r --check-diff + + black: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@master + with: + python-version: '3.14' + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install packages + run: poetry install --with=dev --no-root + + - name: Black + run: | + poetry run black --check -l 120 kasm/ + + isort: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@master + with: + python-version: '3.14' + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install packages + run: poetry install --with=dev --no-root + + - name: Isort + run: | + poetry run isort --check kasm/ + + pycodestyle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@master + with: + python-version: '3.14' + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install packages + run: poetry install --with=dev --no-root + + - name: Pycodestyle + run: | + poetry run pycodestyle kasm/ + + pydocstyle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@master + with: + python-version: '3.14' + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install packages + run: poetry install --with=dev --no-root + + - name: Pydocstyle + run: | + poetry run pydocstyle --count kasm/ + + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@master + with: + python-version: '3.14' + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install packages + run: poetry install --with=dev --no-root + + - name: Mypy + run: | + poetry run mypy kasm --disallow-untyped-def + + bandit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@master + with: + python-version: '3.14' + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install packages + run: poetry install --with=dev --no-root + + - name: Bandit + run: | + poetry run bandit --ini=setup.cfg -ll 2> /dev/null + +# publish: +# needs: [ autoflake, black, isort, pycodestyle, pydocstyle, mypy, bandit ] +# if: github.event_name == 'release' && github.event.action == 'published' +# runs-on: ubuntu-latest +# continue-on-error: true +# environment: +# name: pypi +# url: https://pypi.org/p/pykasm +# permissions: +# id-token: write +# +# steps: +# - uses: actions/checkout@master +# +# - name: Set up Python 3.13 +# uses: actions/setup-python@v4 +# with: +# python-version: '3.14' +# +# - name: Install Poetry +# run: | +# curl -sSL https://install.python-poetry.org | python3 - +# echo "$HOME/.local/bin" >> $GITHUB_PATH +# +# - name: Build the package +# run: poetry build +# +# - name: Verify metadata +# run: | +# python -m pip install twine +# python -m twine check dist/* +# +# - name: Publish distribution to PyPI +# uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index b7faf40..1dccef8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +poetry.lock + +# IDE +.vscode +.idea + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] @@ -182,9 +188,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e159aa4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,99 @@ +# Contributing + +Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. + +You can contribute in many ways: + +## Types of Contributions + +### Report Bugs + +Report bugs at https://github.com/Codoc-os/pykasm. + +If you are reporting a bug, please include: + +* Your operating system name and version. +* Any details about your local setup that might be helpful in troubleshooting. +* Detailed steps to reproduce the bug. + +### Fix Bugs + +Look through the GitHub issues for bugs. Anything tagged with "bug" +is open to whoever wants to implement it. + +### Implement Features + +Look through the GitHub issues for features. Anything tagged with "feature" +is open to whoever wants to implement it. + +### Write Documentation + +PyKasm could always use more documentation, whether as part of the official PyKasm docs, +in docstrings, or even on the web in blog posts, articles, and such. + +### Submit Feedback + +The best way to send feedback is to file an issue at https://github.com/Codoc-os/pykasm/issues. + +If you are proposing a feature: + +* Explain in detail how it would work. +* Keep the scope as narrow as possible, to make it easier to implement. +* Remember that this is a volunteer-driven project, and that contributions are welcome :) + +--- + +## Setting up local environment + +Ready to contribute? Here's how to set up `pykasm` for local development. + +1. Fork the `pykasm` repo on GitHub. + +2. Clone your fork locally: + + * `git clone git@github.com:/pykasm.git` + +3. Install your local copy into a virtualenv. + + ```shell + python3 -m venv venv + source venv/bin/activate + pip3 install poetry + poetry install --with=dev --no-root + ``` + +4. Create a branch for local development: + + * `git checkout -b name-of-your-bugfix-or-feature` + + Now you can make your changes locally. + +## Submitting your changes + +1. Ensure your code is correctly formatted and documented: + +```sh +poetry run ./bin/lint.sh +``` + +2. Commit your changes and push your branch to GitHub: + +```sh +git add . +git commit -m "Your detailed description of your changes." +git push origin name-of-your-bugfix-or-feature +``` + +3. Submit a pull request through the GitHub website. + +## Pull Request Guidelines + +Before you submit a pull request, check that it meets these guidelines: + +1. The pull request should include tests + +2. If the pull request adds functionality, the documentation should be updated. + +3. The pull request should pass all checks and tests. Check + https://github.com/qcoumes/pykasm/actions + and make sure that the tests pass for all supported Python versions. diff --git a/bin/colors.sh b/bin/colors.sh new file mode 100755 index 0000000..e3ee2cd --- /dev/null +++ b/bin/colors.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Reset +Color_Off=$'\e[0m' # Text Reset + +# Regular Colors +Red=$'\e[0;31m' # Red +Green=$'\e[0;32m' # Green +Yellow=$'\e[0;33m' # Yellow +Purple=$'\e[0;35m' # Purple +Cyan=$'\e[0;36m' # Cyan diff --git a/bin/lint.sh b/bin/lint.sh new file mode 100755 index 0000000..543f74a --- /dev/null +++ b/bin/lint.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +BASE_PATH="$(dirname "$0")" +source "$BASE_PATH/colors.sh" +EXIT_CODE=0 + +echo "" + +################################################################################ +# AUTOFLAKE # +################################################################################ +echo -n "${Cyan}Fixing code with autoflake... $Color_Off" +autoflake kasm/ -ri +echo "${Green}Ok ✅ $Color_Off" +echo "" + +################################################################################ +# ISORT # +################################################################################ +echo -n "${Cyan}Formatting import with isort... $Color_Off" +out=$(isort kasm/) +if [ ! -z "$out" ] ; then + echo "" + echo -e "$out" +fi +echo "${Green}Ok ✅ $Color_Off" +echo "" + + +################################################################################ +# BLACK # +################################################################################ +echo -n "${Cyan}Formatting code with black... $Color_Off" +out=$(black -l 120 kasm/ 2>&1) +if [[ "$out" == *"reformatted"* ]]; then + echo "" + echo -e "$out" +fi +echo "${Green}Ok ✅ $Color_Off" +echo "" + + +################################################################################ +# PYCODESTYLE # +################################################################################ +echo -n "${Cyan}Checking code style with pycodestyle... $Color_Off" +out=$(poetry run pycodestyle kasm/) +if [ "$?" -ne 0 ] ; then + echo "${Red}Error !$Color_Off" + echo -e "$out" + EXIT_CODE=1 +else + echo "${Green}Ok ✅ $Color_Off" +fi +echo "" + + +################################################################################ +# PYDOCSTYLE # +################################################################################ +echo -n "${Cyan}Checking docstring style with pydocstyle... $Color_Off" +out=$(pydocstyle --count kasm/) +if [ "$?" -ne 0 ] ; then + echo "${Red}Error !$Color_Off" + echo -e "$out" + EXIT_CODE=1 +else + echo "${Green}Ok ✅ $Color_Off" +fi +echo "" + + +################################################################################ +# MYPY # +################################################################################ +echo -n "${Cyan}Checking typing with mypy... $Color_Off" +out=$(mypy kasm/ --disallow-untyped-def) +if [ "$?" -ne 0 ] ; then + echo "${Red}Error !$Color_Off" + echo -e "$out" + EXIT_CODE=1 +else + echo "${Green}Ok ✅ $Color_Off" +fi +echo "" + + +################################################################################ +# BANDIT # +################################################################################ +echo -n "${Cyan}Checking security issues bandit... $Color_Off" +out=$(bandit --ini=setup.cfg -ll 2> /dev/null) +if [ "$?" -ne 0 ] ; then + echo "${Red}Error !$Color_Off" + echo -e "$out" + EXIT_CODE=1 +else + echo "${Green}Ok ✅ $Color_Off" +fi +echo "" + + +################################################################################ + + +if [ $EXIT_CODE = 1 ] ; then + echo "${Red}⚠ You must fix the errors before committing ⚠$Color_Off" + exit $EXIT_CODE +fi +echo "${Purple}✨ You can commit without any worry ✨$Color_Off" diff --git a/kasm/__init__.py b/kasm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kasm/abc.py b/kasm/abc.py new file mode 100644 index 0000000..cc68f0a --- /dev/null +++ b/kasm/abc.py @@ -0,0 +1,32 @@ +from kasm.clients import AsyncClient, SyncClient +from kasm.connections import async_connections, connections + + +class KasmObject: + """Base class for all Kasm objects.""" + + def __init__(self, using: str | None) -> None: + self._using = using + + @staticmethod + def _normalize_id(id_: str) -> str: + """Normalize IDs between different endpoints. + + Some endpoints return IDs with dashes, while others do not. This method + removes dashes from IDs to ensure consistency. + """ + return id_.replace("-", "") + + def client(self, using: str | None = None) -> SyncClient: + """Return the synchronous client associated with the object. + + It can be overridden with `using`. + """ + return connections.get_connection(using or self._using) + + def async_client(self, using: str | None = None) -> AsyncClient: + """Return the asynchronous client associated with the object. + + It can be overridden with `using`. + """ + return async_connections.get_connection(using or self._using) diff --git a/kasm/attributes.py b/kasm/attributes.py new file mode 100644 index 0000000..b075f1e --- /dev/null +++ b/kasm/attributes.py @@ -0,0 +1,139 @@ +from typing import TYPE_CHECKING, Any, Self + +from kasm.abc import KasmObject +from kasm.connections import async_connections, connections + +if TYPE_CHECKING: + from kasm.users import User + + +class Attribute(KasmObject): + """Represent the attributes (preferences) for a Kasm user.""" + + def __init__( + self, + using: str | None, + user_attributes_id: str, + user: "User", + ssh_public_key: str, + show_tips: bool, + theme: str, + preferred_language: str, + preferred_timezone: str, + toggle_control_panel: bool, + default_image: str | None, + auto_login_kasm: bool | None, + ) -> None: + super().__init__(using) + self.user_attributes_id = self._normalize_id(user_attributes_id) + self.user = user + self.ssh_public_key = ssh_public_key + self.show_tips = show_tips + self.theme = theme + self.preferred_language = preferred_language + self.preferred_timezone = preferred_timezone + self.toggle_control_panel = toggle_control_panel + self.default_image = default_image + self.auto_login_kasm = auto_login_kasm + + def __repr__(self) -> str: + return f"" + + @classmethod + def _from_response(cls, using: str | None, data: dict[str, Any], user: "User") -> Self: + """Create an instance from the typical API response structure. + + `data` must be the dictionary containing the instance information, e.g. + * `{ "user_attributes_id": "", ...}` + not + * `{ "user_attributes": { "user_attributes_id": "", ...}}`. + """ + return cls( + using=using, + user_attributes_id=data["user_attributes_id"], + user=user, + ssh_public_key=data["ssh_public_key"], + show_tips=data["show_tips"], + theme=data["theme"], + preferred_language=data["preferred_language"], + preferred_timezone=data["preferred_timezone"], + toggle_control_panel=data["toggle_control_panel"], + default_image=data["default_image"], + auto_login_kasm=data["auto_login_kasm"], + ) + + @classmethod + def get(cls, user: "User", using: str | None = None) -> Self: + """Get the attribute (preferences) settings for the user. + + Permission Required: `Users View`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-user-attributes + """ + data = connections.get_connection(using).users.get_attributes(user.user_id).json() + return cls._from_response(using, data["user_attributes"], user) + + @classmethod + async def aget(cls, user: "User", using: str | None = None) -> Self: + """Get the attribute (preferences) settings for the user. + + Permission Required: `Users View`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-user-attributes + """ + data = (await async_connections.get_connection(using).users.get_attributes(user.user_id)).json() + return cls._from_response(using, data["user_attributes"], user) + + def refresh(self) -> Self: + """Refresh the instance data from the API.""" + return self.get(user=self.user) + + async def arefresh(self) -> Self: + """Refresh the instance data from the API.""" + return await self.aget(user=self.user) + + def save(self, using: str | None = None) -> Self: + """Save the user attributes. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user-attributes + """ + self.client(using).users.update_attributes( + user_id=self.user.user_id, + ssh_public_key=self.ssh_public_key, + show_tips=self.show_tips, + theme=self.theme, + preferred_language=self.preferred_language, + preferred_timezone=self.preferred_timezone, + toggle_control_panel=self.toggle_control_panel, + default_image=self.default_image, + auto_login_kasm=self.auto_login_kasm, + ) + return self + + async def asave(self, using: str | None = None) -> Self: + """Save the user attributes. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user-attributes + """ + await self.async_client(using).users.update_attributes( + user_id=self.user.user_id, + ssh_public_key=self.ssh_public_key, + show_tips=self.show_tips, + theme=self.theme, + preferred_language=self.preferred_language, + preferred_timezone=self.preferred_timezone, + toggle_control_panel=self.toggle_control_panel, + default_image=self.default_image, + auto_login_kasm=self.auto_login_kasm, + ) + return self diff --git a/kasm/clients/__init__.py b/kasm/clients/__init__.py new file mode 100644 index 0000000..a9f79d4 --- /dev/null +++ b/kasm/clients/__init__.py @@ -0,0 +1,4 @@ +from .asyncio.clients import AsyncClient +from .sync.clients import SyncClient + +__all__ = ("AsyncClient", "SyncClient") diff --git a/kasm/clients/abc/__init__.py b/kasm/clients/abc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kasm/clients/abc/clients.py b/kasm/clients/abc/clients.py new file mode 100644 index 0000000..a7be5c6 --- /dev/null +++ b/kasm/clients/abc/clients.py @@ -0,0 +1,73 @@ +import abc +import functools +import operator +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from kasm.clients.abc.images import ImageClientABC + from kasm.clients.abc.sessions import SessionClientABC + from kasm.clients.abc.users import UserClientABC + +C = TypeVar("C", bound="ClientABC") + + +class NamespacedClientABC(abc.ABC, Generic[C]): + """Base class for specific namespaced clients of `ClientABC`.""" + + def __init__(self, client: C) -> None: + self.client = client + + @property + @abc.abstractmethod + def endpoints(self) -> dict[str, str]: + """Return the endpoints used by the namespaced client.""" + + +class ClientABC(abc.ABC): + """Base class for client handling communication with the Kasm API.""" + + images: "ImageClientABC" + sessions: "SessionClientABC" + users: "UserClientABC" + + def __init__( + self, + key: str, + secret: str, + url: str = None, + scheme: str = "http", + host: str = None, + port: str | int = None, + prefix: str = "/", + timeout: int = 30, + verify_ssl: bool = True, + allow_redirects: bool = False, + ) -> None: + + match (url, scheme, host, port, prefix): + case (str(), _, None, _, _): + self.url = url + case (None, str(), str(), _, str()): + port = f":{port}" if port else "" + self.url = f"{scheme}://{host}{port}{prefix}" + case _: + raise ValueError("You must provide either `url`, or `scheme`, `host`, `port` and `prefix`.") + + self.key = key + self.secret = secret + self.timeout = timeout + self.verify_ssl = verify_ssl + self.allow_redirects = allow_redirects + + def __hash__(self) -> int: + return hash((self.url, self.key, self.secret, self.timeout, self.verify_ssl, self.allow_redirects)) + + def __eq__(self, other: object) -> bool: + return isinstance(other, self.__class__) and hash(self) == hash(other) + + @functools.cached_property + def endpoints(self) -> dict[str, str]: + """Reduce all namespaced client endpoints into a single dict.""" + return functools.reduce( + operator.or_, [c.endpoints for c in self.__dict__.values() if isinstance(c, NamespacedClientABC)] + ) diff --git a/kasm/clients/abc/images.py b/kasm/clients/abc/images.py new file mode 100644 index 0000000..a6e4b59 --- /dev/null +++ b/kasm/clients/abc/images.py @@ -0,0 +1,33 @@ +import abc +import functools +from typing import TYPE_CHECKING, Awaitable, Generic, TypeVar +from urllib.parse import urljoin + +from kasm.clients.abc.clients import NamespacedClientABC +from kasm.responses import ClientResponse + +if TYPE_CHECKING: + from kasm.clients.abc.clients import ClientABC # noqa: F401 + +C = TypeVar("C", bound="ClientABC") + + +class ImageClientABC(NamespacedClientABC[C], Generic[C]): + """Base class for sub-client handling interaction with images.""" + + @functools.cached_property + def endpoints(self) -> dict[str, str]: + """Return the endpoints used by the namespaced client.""" + return { + "get-images": urljoin(self.client.url, "/api/public/get_images"), + } + + @abc.abstractmethod + def get(self) -> ClientResponse | Awaitable[ClientResponse]: + """Retrieve a list of available images. + + Permission Required: `Images View`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-images + """ diff --git a/kasm/clients/abc/sessions.py b/kasm/clients/abc/sessions.py new file mode 100644 index 0000000..01465a2 --- /dev/null +++ b/kasm/clients/abc/sessions.py @@ -0,0 +1,238 @@ +import abc +import functools +from typing import TYPE_CHECKING, Any, Awaitable, Generic, TypeVar +from urllib.parse import urljoin + +from kasm.clients.abc.clients import NamespacedClientABC +from kasm.enums import PersistentProfileMode, RdpClientType +from kasm.responses import ClientResponse + +if TYPE_CHECKING: + from kasm.clients.abc.clients import ClientABC # noqa: F401 + +C = TypeVar("C", bound="ClientABC") + + +class SessionClientABC(NamespacedClientABC[C], Generic[C]): + """Base class for sub-client handling interaction with Kasm sessions.""" + + @functools.cached_property + def endpoints(self) -> dict[str, str]: + """Return the endpoints used by the namespaced client.""" + return { + "request-session": urljoin(self.client.url, "/api/public/request_kasm"), + "status-session": urljoin(self.client.url, "/api/public/get_kasm_status"), + "join-session": urljoin(self.client.url, "/api/public/join_kasm"), + "get-sessions": urljoin(self.client.url, "/api/public/get_kasms"), + "destroy-session": urljoin(self.client.url, "/api/public/destroy_kasm"), + "keepalive-session": urljoin(self.client.url, "/api/public/keepalive"), + "execute-session": urljoin(self.client.url, "/api/public/exec_command_kasm"), + } + + @abc.abstractmethod + def request( + self, + user_id: str, + image_id: str, + enable_sharing: bool = False, + kasm_url: str = None, + environment: dict[str, str] = None, + connection_info: dict[str, str] = None, + client_language: str = None, + client_timezone: str = None, + egress_gateway_id: str = None, + persistent_profile_mode: PersistentProfileMode = None, + rdp_client_type: RdpClientType = None, + server_id: str = None, + raise_on_error: bool = True, + ) -> ClientResponse | Awaitable[ClientResponse]: + """Request a new session to be created. + + Use `status()` to ensure the session reaches a running state, + before directing the user to the session. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#request-kasm + + Parameters + ---------- + user_id: str + If specified, the Kasm session will be created under this user. If + omitted, an anonymous user will be created and used. + image_id: str + The ID of the image to use for the Kasm session. If omitted, the + default image set at the group or user level will be used. + enable_sharing: bool, optional + If set to `True`, the Kasm session will be created with sharing mode + enabled by default. Default to `False`. + kasm_url: str, optional + If specified, the browser inside the session will navigate to this + URL. Only applicable to browser-based images (e.g., Kasm Chrome, + Firefox, Tor Browser). + environment: dict[str, str], optional + Environment variables to inject into the created container session. + connection_info: dict[str, str], optional + Custom RDP/VNC/SSH connection settings. Not applicable for + container-based sessions. + client_language: str, optional + Language to be passed as an environment variable to the Kasm + session. Refer to "Valid Languages" documentation for accepted + values. + client_timezone: str, optional + Timezone to be passed as an environment variable to the Kasm + session. Refer to "Valid Timezones" documentation for accepted + values. + egress_gateway_id: str, optional + ID of the Egress Gateway the session should connect to at launch. + The user must have permission and an associated Egress Credential. + Only applies to container-based sessions. See the Egress + documentation for details. + persistent_profile_mode: PersistentProfileMode, optional + Controls behavior of persistent profiles (only for container + sessions with persistent profile path configured). + - ENABLED: Uses a persistent profile. If it doesn't exist, it will be created. + - DISABLED: Does not load a persistent profile. + - RESET: Deletes any existing profile and creates a new one. + rdp_client_type: RdpClientType, optional + Required when the Workspace image is an RDP Server and the RDP + Client option is set to User Selectable. + - GUAC: Session is web-native. + - RDP_CLIENT: User connects using an RDP client. + server_id: str, optional + Only applicable for sessions using non-container-based Server Pools. + Specifies the exact server in the pool to use for the session. + """ + + @abc.abstractmethod + def status( + self, + user_id: str, + session_id: str, + skip_agent_check: bool = False, + raise_on_error: bool = True, + ) -> ClientResponse | Awaitable[ClientResponse]: + """Retrieve the status of an existing session. + + This call also updates the session token for the user, creating a new + connection link and invalidating the old one. + + You should wait until the sessions reaches a `running` operational + status before directing the end user into the session. + + If the session is not in a `running` state, the `kasm` object may not be + provided, in which case the caller should interrogate the top level + `operational_status`. `operational_progress` and `operational_message` + may be used to provide context to the user. + + Use `skip_agent_check` to skip connecting out to the agent to verify + status of the container, instead use the current value in the database + for the status. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasm-status + """ + + @abc.abstractmethod + def join( + self, user_id: str, shared_id: str, raise_on_error: bool = True + ) -> ClientResponse | Awaitable[ClientResponse]: + """Join an existing session. + + Returns the status of the shared Kasm and a join url to connect to the + session as a view-only user. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#join-kasm + """ + + @abc.abstractmethod + def get(self, raise_on_error: bool = True) -> ClientResponse | Awaitable[ClientResponse]: + """Retrieve a list of live sessions. + + Use `status` if you want to retrieve information about a specific + session. + + Permission Required: `Sessions View` + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasms + """ + + @abc.abstractmethod + def destroy( + self, + user_id: str, + session_id: str, + raise_on_error: bool = True, + **kwargs: Any, + ) -> ClientResponse | Awaitable[ClientResponse]: + """Destroy a Kasm session. + + The backend system my destroy the session immediately or queue the task + for further processing prior to deletion such as saving off persistent + profiles. + If integrators wish to confirm the session is fully deleted, follow-up + calls to `status()` may be called. An error will be thrown to confirm + the session no longer exists. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#destroy-kasm + """ + + @abc.abstractmethod + def keepalive(self, session_id: str, raise_on_error: bool = True) -> ClientResponse | Awaitable[ClientResponse]: + """Issue a keepalive to reset the expiration time of a Kasm session. + + The new expiration time will be updated to reflect the + `keepalive_expiration` Group Setting assigned to the Kasm's associated + user. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#keepalive + """ + + @abc.abstractmethod + def execute( + self, + user_id: str, + session_id: str, + command: str, + workdir: str, + environment: dict[str, str] = None, + privileged: bool = False, + user: str = None, + raise_on_error: bool = True, + ) -> ClientResponse | Awaitable[ClientResponse]: + """Execute an arbitrary command inside a user's session. + + Parameters + ---------- + user_id: str + The ID of the user to whom the session belongs. + session_id: str + The ID of the Kasm session for the command will be executed on. + command: str + The command to execute inside the user's container. + workdir: str + The working directory path for the exec session inside the container. + environment: dict[str, str], optional + Environment variables to set for the exec session. + privileged: bool, optional + If `True`, runs the exec session with privileged permissions. + Default to `False`. + user: str, optional + The user to run the command as. By default, the session runs as the + sandboxed user. Set to "root" to run as root, which disables sandbox + protections. Any value other than "root" may cause the command to + fail. + """ diff --git a/kasm/clients/abc/users.py b/kasm/clients/abc/users.py new file mode 100644 index 0000000..fc49177 --- /dev/null +++ b/kasm/clients/abc/users.py @@ -0,0 +1,208 @@ +import abc +import functools +from typing import TYPE_CHECKING, Awaitable, Generic, TypeVar +from urllib.parse import urljoin + +from kasm.clients.abc.clients import NamespacedClientABC +from kasm.responses import ClientResponse + +if TYPE_CHECKING: + from kasm.clients.abc.clients import ClientABC # noqa: F401 + +C = TypeVar("C", bound="ClientABC") + + +class UserClientABC(NamespacedClientABC[C], Generic[C]): + """Base class for sub-client handling interaction with users.""" + + @functools.cached_property + def endpoints(self) -> dict[str, str]: + """Return the endpoints used by the namespaced client.""" + return { + "create-user": urljoin(self.client.url, "/api/public/create_user"), + "get-user": urljoin(self.client.url, "/api/public/get_user"), + "get-users": urljoin(self.client.url, "/api/public/get_users"), + "update-user": urljoin(self.client.url, "/api/public/update_user"), + "delete-user": urljoin(self.client.url, "/api/public/delete_user"), + "logout-user": urljoin(self.client.url, "/api/public/logout_user"), + "get-user-attributes": urljoin(self.client.url, "/api/public/get_attributes"), + "update-user-attributes": urljoin(self.client.url, "/api/public/update_user_attributes"), + "add-user-group": urljoin(self.client.url, "/api/public/add_user_group"), + "remove-user-group": urljoin(self.client.url, "/api/public/remove_user_group"), + "assign-user-server": urljoin(self.client.url, "/api/public/assign_user_server"), + "unassign-user-server": urljoin(self.client.url, "/api/public/unassign_user_server"), + } + + @abc.abstractmethod + def create( + self, + username: str, + password: str, + first_name: str, + last_name: str, + phone: str, + organization: str, + locked: bool = False, + disabled: bool = False, + raise_on_error: bool = True, + ) -> ClientResponse | Awaitable[ClientResponse]: + """Create a new user. + + Permission Required: `Users Create`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#create-user + """ + + @abc.abstractmethod + def get( + self, *, user_id: str = None, username: str = None, raise_on_error: bool = True + ) -> ClientResponse | Awaitable[ClientResponse]: + """Retrieve the properties of existing users. + + Target user's `user_id` or `username` can be passed to retrieve a + specific user. + + Permission Required: `Users View`. Additionally, `Servers View` to + receive assigned server information. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-user + https://docs.kasm.com/docs/developers/developer_api#get-users. + """ + + @abc.abstractmethod + def update( + self, + user_id: str, + username: str, + password: str, + first_name: str, + last_name: str, + phone: str, + organization: str, + locked: bool = False, + disabled: bool = False, + raise_on_error: bool = True, + ) -> ClientResponse | Awaitable[ClientResponse]: + """Update the properties of an existing user. + + Permission Required: `Users Modify` and `Users Modify Admin` to update + users with Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user + """ + + @abc.abstractmethod + def delete( + self, user_id: str, force: bool = False, raise_on_error: bool = True + ) -> ClientResponse | Awaitable[ClientResponse]: + """Delete an existing user. + + If the user has any existing Kasm sessions, deletion will fail. Set the + `force` argument to `True` to delete the user's sessions and delete + the user. + + Permission Required: `Users Delete` and `Users Modify`, + `Users Modify Admin` is required to delete a user with Global Admin + permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#delete-user + """ + + @abc.abstractmethod + def logout(self, user_id: str, raise_on_error: bool = True) -> ClientResponse | Awaitable[ClientResponse]: + """Logout all sessions for an existing user. + + Permission Required: `Users Auth Session`. + + For more information, see https://docs.kasm.com/docs/developers/developer_api#logout-user + """ + + @abc.abstractmethod + def get_attributes(self, user_id: str, raise_on_error: bool = True) -> ClientResponse | Awaitable[ClientResponse]: + """Get the attribute (preferences) settings for an existing user. + + Permission Required: `Users View`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-user-attributes + """ + + @abc.abstractmethod + def update_attributes( + self, + user_id: str, + ssh_public_key: str, + show_tips: bool, + theme: str, + preferred_language: str, + preferred_timezone: str, + toggle_control_panel: bool, + default_image: str | None, + auto_login_kasm: bool | None, + ) -> ClientResponse | Awaitable[ClientResponse]: + """Update a user attributes. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user-attributes + """ + + @abc.abstractmethod + def add_to_group( + self, user_id: str, group_id: str, raise_on_error: bool = True + ) -> ClientResponse | Awaitable[ClientResponse]: + """Add a User to an existing Group. + + Permission Required: `Groups Modify`, `Groups Modify System` to add the + user to the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#add-user-to-group + """ + + @abc.abstractmethod + def remove_from_group( + self, user_id: str, group_id: str, raise_on_error: bool = True + ) -> ClientResponse | Awaitable[ClientResponse]: + """Remove a User from an existing Group. + + Permission Required: `Groups Modify`, `Groups Modify System` to remove + the user from the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#remove-user-from-group + """ + + @abc.abstractmethod + def assign_server( + self, user_id: str, server_id: str, raise_on_error: bool = True + ) -> ClientResponse | Awaitable[ClientResponse]: + """Assign a User to a Server in a Server Pool. + + The relationship between User and Server is many-to-many. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#assign-user-to-server + """ + + @abc.abstractmethod + def unassign_server( + self, user_id: str, server_id: str, raise_on_error: bool = True + ) -> ClientResponse | Awaitable[ClientResponse]: + """Unassigns a User from a Server in a Server Pool. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#assign-user-to-server + """ diff --git a/kasm/clients/asyncio/__init__.py b/kasm/clients/asyncio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kasm/clients/asyncio/clients.py b/kasm/clients/asyncio/clients.py new file mode 100644 index 0000000..9ae463b --- /dev/null +++ b/kasm/clients/asyncio/clients.py @@ -0,0 +1,66 @@ +from typing import Any, Literal + +import aiohttp + +from kasm.clients.abc.clients import ClientABC +from kasm.clients.asyncio.images import ImageAsyncClient +from kasm.clients.asyncio.sessions import SessionAsyncClient +from kasm.clients.asyncio.users import UserAsyncClient +from kasm.exceptions import RequestError +from kasm.responses import ClientResponse + + +class AsyncClient(ClientABC): + """Asynchronous implementation of `ClientABC` using `aiohttp`.""" + + def __init__( + self, + key: str, + secret: str, + url: str = None, + scheme: str = "http", + host: str = None, + port: str | int = None, + prefix: str = "/", + timeout: int = 30, + verify_ssl: bool = True, + allow_redirects: bool = False, + ) -> None: + super().__init__(key, secret, url, scheme, host, port, prefix, timeout, verify_ssl, allow_redirects) + self.timeout: aiohttp.ClientTimeout = aiohttp.ClientTimeout(timeout) + self.images: ImageAsyncClient = ImageAsyncClient(self) + self.sessions: SessionAsyncClient = SessionAsyncClient(self) + self.users: UserAsyncClient = UserAsyncClient(self) + + async def _request( + self, + method: Literal["get", "post", "delete", "put", "patch", "options"], + endpoint: str, + raise_on_error: bool = True, + **kwargs: Any, + ) -> ClientResponse: + url = self.endpoints[endpoint] + kwargs["headers"] = {"Accept": "application/json", "Content-Type": "application/json"} | kwargs.get( + "headers", {} + ) + kwargs["json"] = kwargs.get("json", {}) | {"api_key": self.key, "api_key_secret": self.secret} + async with aiohttp.ClientSession() as session: + async with session.request( + method, url, **kwargs, timeout=self.timeout, ssl=self.verify_ssl, allow_redirects=self.allow_redirects + ) as response: + wrapped = await ClientResponse.from_aiohttp(response) + if wrapped.status_code >= 300 and raise_on_error: + raise RequestError(wrapped) + return wrapped + + async def get(self, endpoint: str, raise_on_error: bool = True, **kwargs: Any) -> ClientResponse: + """Make a GET request to Kasm API.""" + return await self._request("get", endpoint, raise_on_error=raise_on_error, **kwargs) + + async def post(self, endpoint: str, raise_on_error: bool = True, **kwargs: Any) -> ClientResponse: + """Make a POST request to Kasm API.""" + return await self._request("post", endpoint, raise_on_error=raise_on_error, **kwargs) + + async def delete(self, endpoint: str, raise_on_error: bool = True, **kwargs: Any) -> ClientResponse: + """Make a DELETE request to Kasm API.""" + return await self._request("delete", endpoint, raise_on_error=raise_on_error, **kwargs) diff --git a/kasm/clients/asyncio/images.py b/kasm/clients/asyncio/images.py new file mode 100644 index 0000000..711d52e --- /dev/null +++ b/kasm/clients/asyncio/images.py @@ -0,0 +1,23 @@ +from typing import TYPE_CHECKING + +from kasm.clients.abc.images import ImageClientABC +from kasm.responses import ClientResponse + +if TYPE_CHECKING: + from kasm.clients.asyncio.clients import AsyncClient + + +class ImageAsyncClient(ImageClientABC): + """Asynchronous implementation of `ImageClientABC` using `aiohttp`.""" + + client: "AsyncClient" + + async def get(self) -> ClientResponse: + """Retrieve a list of available images. + + Permission Required: `Images View`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-images + """ + return await self.client.post("get-images") diff --git a/kasm/clients/asyncio/sessions.py b/kasm/clients/asyncio/sessions.py new file mode 100644 index 0000000..21cd236 --- /dev/null +++ b/kasm/clients/asyncio/sessions.py @@ -0,0 +1,228 @@ +from typing import TYPE_CHECKING, Any + +from kasm.clients.abc.sessions import SessionClientABC +from kasm.enums import PersistentProfileMode, RdpClientType +from kasm.responses import ClientResponse + +if TYPE_CHECKING: + from kasm.clients.asyncio.clients import AsyncClient + + +class SessionAsyncClient(SessionClientABC): + """Asynchronous implementation of `SessionClientABC` using `aiohttp`.""" + + client: "AsyncClient" + + async def request( + self, + user_id: str, + image_id: str, + enable_sharing: bool = False, + kasm_url: str = None, + environment: dict[str, str] = None, + connection_info: dict[str, str] = None, + client_language: str = None, + client_timezone: str = None, + egress_gateway_id: str = None, + persistent_profile_mode: PersistentProfileMode = None, + rdp_client_type: RdpClientType = None, + server_id: str = None, + raise_on_error: bool = True, + ) -> ClientResponse: + """Request a new session to be created. + + Use `status()` to ensure the session reaches a running state, + before directing the user to the session. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#request-kasm + + Parameters + ---------- + user_id: str + If specified, the Kasm session will be created under this user. + image_id: str + The ID of the image to use for the Kasm session. + enable_sharing: bool, optional + If set to `True`, the Kasm session will be created with sharing mode + enabled by default. Default to `False`. + kasm_url: str, optional + If specified, the browser inside the session will navigate to this + URL. Only applicable to browser-based images (e.g., Kasm Chrome, + Firefox, Tor Browser). + environment: dict[str, str], optional + Environment variables to inject into the created container session. + connection_info: dict[str, str], optional + Custom RDP/VNC/SSH connection settings. Not applicable for + container-based sessions. + client_language: str, optional + Language to be passed as an environment variable to the Kasm + session. Refer to "Valid Languages" documentation for accepted + values. + client_timezone: str, optional + Timezone to be passed as an environment variable to the Kasm + session. Refer to "Valid Timezones" documentation for accepted + values. + egress_gateway_id: str, optional + ID of the Egress Gateway the session should connect to at launch. + The user must have permission and an associated Egress Credential. + Only applies to container-based sessions. See the Egress + documentation for details. + persistent_profile_mode: PersistentProfileMode, optional + Controls behavior of persistent profiles (only for container + sessions with persistent profile path configured). + - ENABLED: Uses a persistent profile. If it doesn't exist, it will be created. + - DISABLED: Does not load a persistent profile. + - RESET: Deletes any existing profile and creates a new one. + rdp_client_type: RdpClientType, optional + Required when the Workspace image is an RDP Server and the RDP + Client option is set to User Selectable. + - GUAC: Session is web-native. + - RDP_CLIENT: User connects using an RDP client. + server_id: str, optional + Only applicable for sessions using non-container-based Server Pools. + Specifies the exact server in the pool to use for the session. + """ + payload = {k: v for k, v in locals().items() if v is not None and k not in ("self", "raise_on_error")} + return await self.client.post("request-session", json=payload, raise_on_error=raise_on_error) + + async def status( + self, user_id: str, session_id: str, skip_agent_check: bool = False, raise_on_error: bool = True + ) -> ClientResponse: + """Retrieve the status of an existing session. + + This call also updates the session token for the user, creating a new + connection link and invalidating the old one. + + You should wait until the sessions reaches a `running` operational + status before directing the end user into the session. + + If the session is not in a `running` state, the `kasm` object may not be + provided, in which case the caller should interrogate the top level + `operational_status`. `operational_progress` and `operational_message` + may be used to provide context to the user. + + Use `skip_agent_check` to skip connecting out to the agent to verify + status of the container, instead use the current value in the database + for the status. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasm-status + """ + payload = {"user_id": user_id, "kasm_id": session_id, "skip_agent_check": skip_agent_check} + return await self.client.post("status-session", json=payload, raise_on_error=raise_on_error) + + async def join(self, user_id: str, shared_id: str, raise_on_error: bool = True) -> ClientResponse: + """Join an existing session. + + Return the status of the shared Kasm and a join url to connect to the + session as a view-only user. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#join-kasm + """ + payload = {"user_id": user_id, "shared_id": shared_id} + return await self.client.post("join-session", json=payload, raise_on_error=raise_on_error) + + async def get(self, raise_on_error: bool = True) -> ClientResponse: + """Retrieve a list of live sessions. + + Use `status` if you want to retrieve information about a specific + session. + + Permission Required: `Sessions View` + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasms + """ + return await self.client.post("get-sessions", raise_on_error=raise_on_error) + + async def destroy( + self, user_id: str, session_id: str, raise_on_error: bool = True, **kwargs: Any + ) -> ClientResponse: + """Destroy a Kasm session. + + The backend system my destroy the session immediately or queue the task + for further processing prior to deletion such as saving off persistent + profiles. + If integrators wish to confirm the session is fully deleted, follow-up + calls to `status()` may be called. An error will be thrown to confirm + the session no longer exists. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#destroy-kasm + """ + payload = {"user_id": user_id, "kasm_id": session_id} + return await self.client.post("destroy-session", json=payload, raise_on_error=raise_on_error) + + async def keepalive(self, session_id: str, raise_on_error: bool = True) -> ClientResponse: + """Issue a keepalive to reset the expiration time of a Kasm session. + + The new expiration time will be updated to reflect the + `keepalive_expiration` Group Setting assigned to the Kasm's associated + user. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#keepalive + """ + payload = {"kasm_id": session_id} + return await self.client.post("keepalive-session", json=payload, raise_on_error=raise_on_error) + + async def execute( + self, + user_id: str, + session_id: str, + command: str, + workdir: str, + environment: dict[str, str] = None, + privileged: bool = False, + user: str = None, + raise_on_error: bool = True, + ) -> ClientResponse: + """Execute an arbitrary command inside a user's session. + + Parameters + ---------- + user_id: str + The ID of the user to whom the session belongs. + session_id: str + The ID of the Kasm session for the command will be executed on. + command: str + The command to execute inside the user's container. + workdir: str + The working directory path for the exec session inside the container. + environment: dict[str, str], optional + Environment variables to set for the exec session. + privileged: bool, optional + If `True`, runs the exec session with privileged permissions. + Default to `False`. + user: str, optional + The user to run the command as. By default, the session runs as the + sandboxed user. Set to "root" to run as root, which disables sandbox + protections. Any value other than "root" may cause the command to + fail. + """ + payload = { + "user_id": user_id, + "kasm_id": session_id, + "exec_config": { + "cmd": command, + "workdir": workdir, + "privileged": privileged, + }, + } + if environment is not None: + payload["exec_config"]["environment"] = environment + if user is not None: + payload["exec_config"]["user"] = user + return await self.client.post("execute-session", json=payload, raise_on_error=raise_on_error) diff --git a/kasm/clients/asyncio/users.py b/kasm/clients/asyncio/users.py new file mode 100644 index 0000000..3985877 --- /dev/null +++ b/kasm/clients/asyncio/users.py @@ -0,0 +1,219 @@ +from typing import TYPE_CHECKING + +from kasm.clients.abc.users import UserClientABC +from kasm.responses import ClientResponse + +if TYPE_CHECKING: + from kasm.clients.asyncio.clients import AsyncClient + + +class UserAsyncClient(UserClientABC): + """Asynchronous implementation of `UserClientABC` using `aiohttp`.""" + + client: "AsyncClient" + + async def create( + self, + username: str, + password: str, + first_name: str | None, + last_name: str | None, + phone: str | None, + organization: str | None, + locked: bool = False, + disabled: bool = False, + raise_on_error: bool = True, + ) -> ClientResponse: + """Create a new user. + + Permission Required: `Users Create`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#create-user + """ + payload = { + "target_user": { + "username": username, + "password": password, + "first_name": first_name, + "last_name": last_name, + "phone": phone, + "organization": organization, + "locked": locked, + "disabled": disabled, + } + } + return await self.client.post("create-user", json=payload, raise_on_error=raise_on_error) + + async def get(self, *, user_id: str = None, username: str = None, raise_on_error: bool = True) -> ClientResponse: + """Retrieve the properties of existing users. + + Target user's `user_id` or `username` can be passed to retrieve a + specific user. + + Permission Required: `Users View`. Additionally, `Servers View` to + receive assigned server information. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-user + https://docs.kasm.com/docs/developers/developer_api#get-users. + """ + if user_id is not None: + return await self.client.post( + "get-user", json={"target_user": {"user_id": user_id}}, raise_on_error=raise_on_error + ) + if username is not None: + return await self.client.post( + "get-user", json={"target_user": {"username": username}}, raise_on_error=raise_on_error + ) + return await self.client.post("get-users", raise_on_error=raise_on_error) + + async def update( + self, + user_id: str = None, + username: str = None, + password: str = None, + first_name: str = None, + last_name: str = None, + phone: str = None, + organization: str = None, + locked: bool = None, + disabled: bool = None, + raise_on_error: bool = True, + ) -> ClientResponse: + """Update the properties of an existing user. + + Permission Required: `Users Modify` and `Users Modify Admin` to update + users with Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user + """ + payload = { + "target_user": {k: v for k, v in locals().items() if v is not None and k not in ("self", "raise_on_error")} + } + return await self.client.post("update-user", json=payload, raise_on_error=raise_on_error) + + async def delete(self, user_id: str, force: bool = False, raise_on_error: bool = True) -> ClientResponse: + """Delete an existing user. + + If the user has any existing Kasm sessions, deletion will fail. Set the + `force` argument to `True` to delete the user's sessions and delete + the user. + + Permission Required: `Users Delete` and `Users Modify`, + `Users Modify Admin` is required to delete a user with Global Admin + permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#delete-user + """ + payload = {"target_user": {"user_id": user_id}, "force": force} + return await self.client.post("delete-user", json=payload, raise_on_error=raise_on_error) + + async def logout(self, user_id: str, raise_on_error: bool = True) -> ClientResponse: + """Logout all sessions for an existing user. + + Permission Required: `Users Auth Session`. + + For more information, see https://docs.kasm.com/docs/developers/developer_api#logout-user + """ + payload = {"target_user": {"user_id": user_id}} + return await self.client.post("logout-user", json=payload, raise_on_error=raise_on_error) + + async def get_attributes(self, user_id: str, raise_on_error: bool = True) -> ClientResponse: + """Get the attribute (preferences) settings for an existing user. + + Permission Required: `Users View`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-user-attributes + """ + payload = {"target_user": {"user_id": user_id}} + return await self.client.post("get-user-attributes", json=payload, raise_on_error=raise_on_error) + + async def update_attributes( + self, + user_id: str, + ssh_public_key: str, + show_tips: bool, + theme: str, + preferred_language: str, + preferred_timezone: str, + toggle_control_panel: bool, + default_image: str | None, + auto_login_kasm: bool | None, + raise_on_error: bool = True, + ) -> ClientResponse: + """Update a user attributes. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user-attributes + """ + payload = { + "target_user_attributes": { + "user_id": user_id, + "ssh_public_key": ssh_public_key, + "show_tips": show_tips, + "theme": theme, + "preferred_language": preferred_language, + "preferred_timezone": preferred_timezone, + "toggle_control_panel": toggle_control_panel, + "default_image": default_image, + "auto_login_kasm": auto_login_kasm, + } + } + return await self.client.post("update-user-attributes", json=payload, raise_on_error=raise_on_error) + + async def add_to_group(self, user_id: str, group_id: str, raise_on_error: bool = True) -> ClientResponse: + """Add a User to an existing Group. + + Permission Required: `Groups Modify`, `Groups Modify System` to add the + user to the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#add-user-to-group + """ + payload = {"target_user": {"user_id": user_id}, "target_group": {"group_id": group_id}} + return await self.client.post("add-user-group", json=payload, raise_on_error=raise_on_error) + + async def remove_from_group(self, user_id: str, group_id: str, raise_on_error: bool = True) -> ClientResponse: + """Remove a User from an existing Group. + + Permission Required: `Groups Modify`, `Groups Modify System` to remove + the user from the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#remove-user-from-group + """ + payload = {"target_user": {"user_id": user_id}, "target_group": {"group_id": group_id}} + return await self.client.post("remove-user-group", json=payload, raise_on_error=raise_on_error) + + async def assign_server(self, user_id: str, server_id: str, raise_on_error: bool = True) -> ClientResponse: + """Assign a User to a Server in a Server Pool. + + The relationship between User and Server is many-to-many. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#assign-user-to-server + """ + payload = {"target_user": {"user_id": user_id}, "target_server": {"server_id": server_id}} + return await self.client.post("assign-user-server", json=payload, raise_on_error=raise_on_error) + + async def unassign_server(self, user_id: str, server_id: str, raise_on_error: bool = True) -> ClientResponse: + """Unassigns a User from a Server in a Server Pool. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#assign-user-to-server + """ + payload = {"target_user": {"user_id": user_id}, "target_server": {"server_id": server_id}} + return await self.client.post("unassign-user-server", json=payload, raise_on_error=raise_on_error) diff --git a/kasm/clients/sync/__init__.py b/kasm/clients/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kasm/clients/sync/clients.py b/kasm/clients/sync/clients.py new file mode 100644 index 0000000..0afe5a9 --- /dev/null +++ b/kasm/clients/sync/clients.py @@ -0,0 +1,71 @@ +from typing import Any, Literal + +import requests + +from kasm.clients.abc.clients import ClientABC +from kasm.clients.sync.images import ImageSyncClient +from kasm.clients.sync.sessions import SessionSyncClient +from kasm.clients.sync.users import UserSyncClient +from kasm.exceptions import RequestError +from kasm.responses import ClientResponse + + +class SyncClient(ClientABC): + """Synchronous implementation of `ClientABC` using `requests`.""" + + def __init__( + self, + key: str, + secret: str, + url: str = None, + scheme: str = "http", + host: str = None, + port: str | int = None, + prefix: str = "/", + timeout: int = 30, + verify_ssl: bool = True, + allow_redirects: bool = False, + ) -> None: + super().__init__(key, secret, url, scheme, host, port, prefix, timeout, verify_ssl, allow_redirects) + self.images: ImageSyncClient = ImageSyncClient(self) + self.sessions: SessionSyncClient = SessionSyncClient(self) + self.users: UserSyncClient = UserSyncClient(self) + + def _request( + self, + method: Literal["get", "post", "delete", "put", "patch", "options"], + endpoint: str, + raise_on_error: bool = True, + **kwargs: Any, + ) -> ClientResponse: + url = self.endpoints[endpoint] + kwargs["headers"] = {"Accept": "application/json", "Content-Type": "application/json"} | kwargs.get( + "headers", {} + ) + kwargs["json"] = kwargs.get("json", {}) | {"api_key": self.key, "api_key_secret": self.secret} + + response = ClientResponse.from_requests( + requests.request( + method, + url, + **kwargs, + timeout=self.timeout, + verify=self.verify_ssl, + allow_redirects=self.allow_redirects, + ) + ) + if response.status_code >= 300 and raise_on_error: + raise RequestError(response) + return response + + def get(self, endpoint: str, raise_on_error: bool = True, **kwargs: Any) -> ClientResponse: + """Make a GET request to Kasm API.""" + return self._request("get", endpoint, raise_on_error=raise_on_error, **kwargs) + + def post(self, endpoint: str, raise_on_error: bool = True, **kwargs: Any) -> ClientResponse: + """Make a POST request to Kasm API.""" + return self._request("post", endpoint, raise_on_error=raise_on_error, **kwargs) + + def delete(self, endpoint: str, raise_on_error: bool = True, **kwargs: Any) -> ClientResponse: + """Make a DELETE request to Kasm API.""" + return self._request("delete", endpoint, raise_on_error=raise_on_error, **kwargs) diff --git a/kasm/clients/sync/images.py b/kasm/clients/sync/images.py new file mode 100644 index 0000000..6a82686 --- /dev/null +++ b/kasm/clients/sync/images.py @@ -0,0 +1,23 @@ +from typing import TYPE_CHECKING + +from kasm.clients.abc.images import ImageClientABC +from kasm.responses import ClientResponse + +if TYPE_CHECKING: + from kasm.clients.sync.clients import SyncClient + + +class ImageSyncClient(ImageClientABC): + """Synchronous implementation of `ImageClientABC` using `requests`.""" + + client: "SyncClient" + + def get(self) -> ClientResponse: + """Retrieve a list of available images. + + Permission Required: `Images View`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-images + """ + return self.client.post("get-images") diff --git a/kasm/clients/sync/sessions.py b/kasm/clients/sync/sessions.py new file mode 100644 index 0000000..d02172a --- /dev/null +++ b/kasm/clients/sync/sessions.py @@ -0,0 +1,227 @@ +from typing import TYPE_CHECKING, Any + +from kasm.clients.abc.sessions import SessionClientABC +from kasm.enums import PersistentProfileMode, RdpClientType +from kasm.responses import ClientResponse + +if TYPE_CHECKING: + from kasm.clients.sync.clients import SyncClient + + +class SessionSyncClient(SessionClientABC): + """Synchronous implementation of `SessionClientABC` using `requests`.""" + + client: "SyncClient" + + def request( + self, + user_id: str, + image_id: str, + enable_sharing: bool = False, + kasm_url: str = None, + environment: dict[str, str] = None, + connection_info: dict[str, str] = None, + client_language: str = None, + client_timezone: str = None, + egress_gateway_id: str = None, + persistent_profile_mode: PersistentProfileMode = None, + rdp_client_type: RdpClientType = None, + server_id: str = None, + raise_on_error: bool = True, + ) -> ClientResponse: + """Request a new session to be created. + + Use `status()` to ensure the session reaches a running state, + before directing the user to the session. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#request-kasm + + Parameters + ---------- + user_id: str + If specified, the Kasm session will be created under this user. + image_id: str + The ID of the image to use for the Kasm session. + enable_sharing: bool, optional + If set to `True`, the Kasm session will be created with sharing mode + enabled by default. Default to `False`. + kasm_url: str, optional + If specified, the browser inside the session will navigate to this + URL. Only applicable to browser-based images (e.g., Kasm Chrome, + Firefox, Tor Browser). + environment: dict[str, str], optional + Environment variables to inject into the created container session. + connection_info: dict[str, str], optional + Custom RDP/VNC/SSH connection settings. Not applicable for + container-based sessions. + client_language: str, optional + Language to be passed as an environment variable to the Kasm + session. Refer to "Valid Languages" documentation for accepted + values. + client_timezone: str, optional + Timezone to be passed as an environment variable to the Kasm + session. Refer to "Valid Timezones" documentation for accepted + values. + egress_gateway_id: str, optional + ID of the Egress Gateway the session should connect to at launch. + The user must have permission and an associated Egress Credential. + Only applies to container-based sessions. See the Egress + documentation for details. + persistent_profile_mode: PersistentProfileMode, optional + Controls behavior of persistent profiles (only for container + sessions with persistent profile path configured). + - ENABLED: Uses a persistent profile. If it doesn't exist, it will be created. + - DISABLED: Does not load a persistent profile. + - RESET: Deletes any existing profile and creates a new one. + rdp_client_type: RdpClientType, optional + Required when the Workspace image is an RDP Server and the RDP + Client option is set to User Selectable. + - GUAC: Session is web-native. + - RDP_CLIENT: User connects using an RDP client. + server_id: str, optional + Only applicable for sessions using non-container-based Server Pools. + Specifies the exact server in the pool to use for the session. + """ + payload = {k: v for k, v in locals().items() if v is not None and k not in ("self", "raise_on_error")} + return self.client.post("request-session", json=payload, raise_on_error=raise_on_error) + + def status( + self, user_id: str, session_id: str, skip_agent_check: bool = False, raise_on_error: bool = True + ) -> ClientResponse: + """Retrieve the status of an existing session. + + This call also updates the session token for the user, creating a new + connection link and invalidating the old one. + + You should wait until the sessions reaches a `running` operational + status before directing the end user into the session. + + If the session is not in a `running` state, the `kasm` object may not be + provided, in which case the caller should interrogate the top level + `operational_status`. `operational_progress` and `operational_message` + may be used to provide context to the user. + + Use `skip_agent_check` to skip connecting out to the agent to verify + status of the container, instead use the current value in the database + for the status. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasm-status + """ + payload = {"user_id": user_id, "kasm_id": session_id, "skip_agent_check": skip_agent_check} + return self.client.post("status-session", json=payload, raise_on_error=raise_on_error) + + def join(self, user_id: str, shared_id: str, raise_on_error: bool = True) -> ClientResponse: + """Join an existing session. + + Returns the status of the shared Kasm and a join url to connect to the + session as a view-only user. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#join-kasm + """ + payload = {"user_id": user_id, "shared_id": shared_id} + return self.client.post("join-session", json=payload, raise_on_error=raise_on_error) + + def get(self, raise_on_error: bool = True) -> ClientResponse: + """Retrieve a list of live sessions. + + Use `status` if you want to retrieve information about a specific + session. + + Permission Required: `Sessions View` + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasms + """ + return self.client.post("get-sessions", raise_on_error=raise_on_error) + + def destroy(self, user_id: str, session_id: str, raise_on_error: bool = True, **kwargs: Any) -> ClientResponse: + """Destroy a Kasm session. + + The backend system my destroy the session immediately or queue the task + for further processing prior to deletion such as saving off persistent + profiles. + + If integrators wish to confirm the session is fully deleted, follow-up + calls to `status()` may be called. An error will be thrown to confirm + the session no longer exists. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#destroy-kasm + """ + payload = {"user_id": user_id, "kasm_id": session_id} + return self.client.post("destroy-session", json=payload, raise_on_error=raise_on_error) + + def keepalive(self, session_id: str, raise_on_error: bool = True) -> ClientResponse: + """Issue a keepalive to reset the expiration time of a Kasm session. + + The new expiration time will be updated to reflect the + `keepalive_expiration` Group Setting assigned to the Kasm's associated + user. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#keepalive + """ + payload = {"kasm_id": session_id} + return self.client.post("keepalive-session", json=payload, raise_on_error=raise_on_error) + + def execute( + self, + user_id: str, + session_id: str, + command: str, + workdir: str, + environment: dict[str, str] = None, + privileged: bool = False, + user: str = None, + raise_on_error: bool = True, + ) -> ClientResponse: + """Execute an arbitrary command inside a user's session. + + Parameters + ---------- + user_id: str + The ID of the user to whom the session belongs. + session_id: str + The ID of the Kasm session for the command will be executed on. + command: str + The command to execute inside the user's container. + workdir: str + The working directory path for the exec session inside the container. + environment: dict[str, str], optional + Environment variables to set for the exec session. + privileged: bool, optional + If `True`, runs the exec session with privileged permissions. + Default to `False`. + user: str, optional + The user to run the command as. By default, the session runs as the + sandboxed user. Set to "root" to run as root, which disables sandbox + protections. Any value other than "root" may cause the command to + fail. + """ + payload = { + "user_id": user_id, + "kasm_id": session_id, + "exec_config": { + "cmd": command, + "workdir": workdir, + "privileged": privileged, + }, + } + if environment is not None: + payload["exec_config"]["environment"] = environment + if user is not None: + payload["exec_config"]["user"] = user + return self.client.post("execute-session", json=payload, raise_on_error=raise_on_error) diff --git a/kasm/clients/sync/users.py b/kasm/clients/sync/users.py new file mode 100644 index 0000000..3956921 --- /dev/null +++ b/kasm/clients/sync/users.py @@ -0,0 +1,219 @@ +from typing import TYPE_CHECKING + +from kasm.clients.abc.users import UserClientABC +from kasm.responses import ClientResponse + +if TYPE_CHECKING: + from kasm.clients.sync.clients import SyncClient + + +class UserSyncClient(UserClientABC): + """Synchronous implementation of `UserClientABC` using `requests`.""" + + client: "SyncClient" + + def create( + self, + username: str, + password: str, + first_name: str | None = None, + last_name: str | None = None, + phone: str | None = None, + organization: str | None = None, + locked: bool = False, + disabled: bool = False, + raise_on_error: bool = True, + ) -> ClientResponse: + """Create a new user. + + Permission Required: `Users Create`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#create-user + """ + payload = { + "target_user": { + "username": username, + "password": password, + "first_name": first_name, + "last_name": last_name, + "phone": phone, + "organization": organization, + "locked": locked, + "disabled": disabled, + } + } + return self.client.post("create-user", json=payload, raise_on_error=raise_on_error) + + def get(self, *, user_id: str = None, username: str = None, raise_on_error: bool = True) -> ClientResponse: + """Retrieve the properties of existing users. + + Target user's `user_id` or `username` can be passed to retrieve a + specific user. + + Permission Required: `Users View`. Additionally, `Servers View` to + receive assigned server information. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-user + https://docs.kasm.com/docs/developers/developer_api#get-users. + """ + if user_id is not None: + return self.client.post( + "get-user", json={"target_user": {"user_id": user_id}}, raise_on_error=raise_on_error + ) + if username is not None: + return self.client.post( + "get-user", json={"target_user": {"username": username}}, raise_on_error=raise_on_error + ) + return self.client.post("get-users", raise_on_error=raise_on_error) + + def update( + self, + user_id: str = None, + username: str = None, + password: str = None, + first_name: str = None, + last_name: str = None, + phone: str = None, + organization: str = None, + locked: bool = None, + disabled: bool = None, + raise_on_error: bool = True, + ) -> ClientResponse: + """Update the properties of an existing user. + + Permission Required: `Users Modify` and `Users Modify Admin` to update + users with Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user + """ + payload = { + "target_user": {k: v for k, v in locals().items() if v is not None and k not in ("self", "raise_on_error")} + } + return self.client.post("update-user", json=payload, raise_on_error=raise_on_error) + + def delete(self, user_id: str, force: bool = False, raise_on_error: bool = True) -> ClientResponse: + """Delete an existing user. + + If the user has any existing Kasm sessions, deletion will fail. Set the + `force` argument to `True` to delete the user's sessions and delete + the user. + + Permission Required: `Users Delete` and `Users Modify`, + `Users Modify Admin` is required to delete a user with Global Admin + permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#delete-user + """ + payload = {"target_user": {"user_id": user_id}, "force": force} + return self.client.post("delete-user", json=payload, raise_on_error=raise_on_error) + + def logout(self, user_id: str, raise_on_error: bool = True) -> ClientResponse: + """Logout all sessions for an existing user. + + Permission Required: `Users Auth Session`. + + For more information, see https://docs.kasm.com/docs/developers/developer_api#logout-user + """ + payload = {"target_user": {"user_id": user_id}} + return self.client.post("logout-user", json=payload, raise_on_error=raise_on_error) + + def get_attributes(self, user_id: str, raise_on_error: bool = True) -> ClientResponse: + """Get the attribute (preferences) settings for an existing user. + + Permission Required: `Users View`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-user-attributes + """ + payload = {"target_user": {"user_id": user_id}} + return self.client.post("get-user-attributes", json=payload, raise_on_error=raise_on_error) + + def update_attributes( + self, + user_id: str, + ssh_public_key: str, + show_tips: bool, + theme: str, + preferred_language: str, + preferred_timezone: str, + toggle_control_panel: bool, + default_image: str | None, + auto_login_kasm: bool | None, + raise_on_error: bool = True, + ) -> ClientResponse: + """Update a user attributes. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user-attributes + """ + payload = { + "target_user_attributes": { + "user_id": user_id, + "ssh_public_key": ssh_public_key, + "show_tips": show_tips, + "theme": theme, + "preferred_language": preferred_language, + "preferred_timezone": preferred_timezone, + "toggle_control_panel": toggle_control_panel, + "default_image": default_image, + "auto_login_kasm": auto_login_kasm, + } + } + return self.client.post("update-user-attributes", json=payload, raise_on_error=raise_on_error) + + def add_to_group(self, user_id: str, group_id: str, raise_on_error: bool = True) -> ClientResponse: + """Add a User to an existing Group. + + Permission Required: `Groups Modify`, `Groups Modify System` to add the + user to the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#add-user-to-group + """ + payload = {"target_user": {"user_id": user_id}, "target_group": {"group_id": group_id}} + return self.client.post("add-user-group", json=payload, raise_on_error=raise_on_error) + + def remove_from_group(self, user_id: str, group_id: str, raise_on_error: bool = True) -> ClientResponse: + """Remove a User from an existing Group. + + Permission Required: `Groups Modify`, `Groups Modify System` to remove + the user from the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#remove-user-from-group + """ + payload = {"target_user": {"user_id": user_id}, "target_group": {"group_id": group_id}} + return self.client.post("remove-user-group", json=payload, raise_on_error=raise_on_error) + + def assign_server(self, user_id: str, server_id: str, raise_on_error: bool = True) -> ClientResponse: + """Assign a User to a Server in a Server Pool. + + The relationship between User and Server is many-to-many. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#assign-user-to-server + """ + payload = {"target_user": {"user_id": user_id}, "target_server": {"server_id": server_id}} + return self.client.post("assign-user-server", json=payload, raise_on_error=raise_on_error) + + def unassign_server(self, user_id: str, server_id: str, raise_on_error: bool = True) -> ClientResponse: + """Unassigns a User from a Server in a Server Pool. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#assign-user-to-server + """ + payload = {"target_user": {"user_id": user_id}, "target_server": {"server_id": server_id}} + return self.client.post("unassign-user-server", json=payload, raise_on_error=raise_on_error) diff --git a/kasm/connections.py b/kasm/connections.py new file mode 100644 index 0000000..a34c5f5 --- /dev/null +++ b/kasm/connections.py @@ -0,0 +1,103 @@ +import copy +from typing import Any, Generic, Type, TypeVar + +from kasm.clients import AsyncClient, SyncClient +from kasm.clients.abc.clients import ClientABC + +Client = TypeVar("Client", bound=ClientABC) + + +class Connections(Generic[Client]): + """Holds connections to different Kasm API.""" + + def __init__(self, client_class: Type[Client]) -> None: + self._client_class = client_class + self._client: dict[str, Client] = {} + + def __getitem__(self, alias: str) -> Client: + return self._client[alias] + + def __setitem__(self, alias: str, client: Client) -> None: + self._client[alias] = client + + def __delitem__(self, alias: str) -> None: + del self._client[alias] + + def configure(self, **kwargs: Any) -> None: + """Configure multiple clients at once. + + Useful for passing in config dictionaries obtained from other sources, + like Django's settings or a configuration management tool. + + Examples + -------- + ```python + connections.configure( + default={ + 'scheme': 'http', + 'host': 'localhost' + 'port': 80, + 'prefix': 'api', + 'key': 'foo', + 'secret': 'bar' + }, + test={ + 'scheme': 'http', + 'host': 'localhost', + 'port': 5678, + 'prefix': 'test', + 'key': 'foo', + 'secret': 'bar', + } + ) + + # Or using a dict + config = { + 'default': { + 'scheme': 'http', + 'host': 'localhost' + 'port': 80, + 'prefix': 'api', + 'key': 'foo', + 'secret': 'bar' + }, + 'test': { + 'scheme': 'http', + 'host': 'localhost', + 'port': 5678, + 'prefix': 'test', + 'id': 1, + 'key': 'foo', + 'secret': 'bar', + } + } + connections.configure(**config) + ``` + """ + for k, v in kwargs.items(): + self.create_connection(k, **v) + + def create_connection(self, alias: str, **kwargs: Any) -> Any: + """Create a client and register it under given alias.""" + client = self._client[alias] = self._client_class(**kwargs) + return client + + def get_connection(self, alias: str = None) -> Client: + """Return the client corresponding to alias.""" + return self._client[alias or "default"] + + add_connection = __setitem__ + + remove_connection = __delitem__ + + +# Using a global instances holding all the connections allows to easily reuse +# them across an application. +connections: Connections[SyncClient] = Connections(SyncClient) +async_connections: Connections[AsyncClient] = Connections(AsyncClient) + + +def configure(**kwargs: Any) -> None: # pragma: no cover + """Configure both the synchronous and asynchronous clients.""" + connections.configure(**copy.deepcopy(kwargs)) + async_connections.configure(**kwargs) diff --git a/kasm/enums.py b/kasm/enums.py new file mode 100644 index 0000000..9422de3 --- /dev/null +++ b/kasm/enums.py @@ -0,0 +1,24 @@ +import enum + + +class PersistentProfileMode(str, enum.Enum): + """Allowed values for the different modes for persistent profile management.""" + + DISABLED = "Disabled" + ENABLED = "Enabled" + RESET = "Reset" + + +class RdpClientType(str, enum.Enum): + """Allowed values for the different types of RDP clients.""" + + GUAC = "GUAC" + RDP_CLIENT = "RDP_CLIENT" + + +class CpuAllocationMethod(str, enum.Enum): + """Allowed values for the different CPU allocation methods.""" + + QUOTAS = "Quotas" + SHARES = "Shares" + INHERIT = "Inherit" diff --git a/kasm/exceptions.py b/kasm/exceptions.py new file mode 100644 index 0000000..4fe80d3 --- /dev/null +++ b/kasm/exceptions.py @@ -0,0 +1,18 @@ +from kasm.responses import ClientResponse + + +class KasmAPIError(Exception): + """Base class for all exceptions raised by `pykasm`.""" + + +class RequestError(KasmAPIError): + """Raised when a request to Kasm fails.""" + + def __init__(self, response: ClientResponse, message: str = None) -> None: + self.response = response + self.message = message or "Request to '{url}' failed with status code '{status_code}':\n{content}" + + def __str__(self) -> str: + return self.message.format( + url=self.response.url, status_code=self.response.status_code, content=self.response.content + ) diff --git a/kasm/groups.py b/kasm/groups.py new file mode 100644 index 0000000..e5feb56 --- /dev/null +++ b/kasm/groups.py @@ -0,0 +1,82 @@ +from typing import TYPE_CHECKING + +from kasm.abc import KasmObject + +if TYPE_CHECKING: + from kasm.users import User + + +class Group(KasmObject): + """Represent a Kasm group.""" + + def __init__(self, using: str | None, group_id: str, name: str) -> None: + super().__init__(using) + self.group_id = self._normalize_id(group_id) + self.name = name + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}: {self.group_id} - {self.name}>" + + def add_user(self, user: "str | User", using: str | None = None) -> None: + """Add a user to the group. + + Permission Required: `Groups Modify`, `Groups Modify System` to add the + user to the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#add-user-to-group + """ + from kasm.users import User + + if isinstance(user, User): + user.add_to_group(self, using=using) + else: + self.client(using).users.add_to_group(user, self.group_id) + + async def aadd_user(self, user: "str | User", using: str | None = None) -> None: + """Add a user to the group. + + Permission Required: `Groups Modify`, `Groups Modify System` to add the + user to the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#add-user-to-group + """ + from kasm.users import User + + if isinstance(user, User): + await user.aadd_to_group(self, using=using) + else: + await self.async_client(using).users.add_to_group(user, self.group_id) + + def remove_user(self, user: "str | User", using: str | None = None) -> None: + """Remove a user from the group. + + Permission Required: `Groups Modify`, `Groups Modify System` to remove + the user from the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#remove-user-from-group + """ + from kasm.users import User + + if isinstance(user, User): + user.remove_from_group(self, using=using) + else: + self.client(using).users.remove_from_group(user, self.group_id) + + async def aremove_user(self, user: "str | User", using: str | None = None) -> None: + """Remove a user from the group. + + Permission Required: `Groups Modify`, `Groups Modify System` to remove + the user from the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#remove-user-from-group + """ + from kasm.users import User + + if isinstance(user, User): + await user.aremove_from_group(self, using=using) + else: + await self.async_client(using).users.remove_from_group(user, self.group_id) diff --git a/kasm/images.py b/kasm/images.py new file mode 100644 index 0000000..5fc77be --- /dev/null +++ b/kasm/images.py @@ -0,0 +1,277 @@ +from typing import Any, Self + +from kasm.abc import KasmObject +from kasm.connections import async_connections, connections +from kasm.enums import CpuAllocationMethod + + +class ImageAttribute: + """Represent a Kasm image attribute.""" + + def __init__(self, attr_id: str, image: "Image", name: str, category: str, value: str) -> None: + self.attr_id = attr_id + self.image = image + self.name = name + self.category = category + self.value = value + + +class Image(KasmObject): + """Represent a Kasm image.""" + + def __init__( + self, + using: str | None, + image_id: str, + name: str | None = None, + friendly_name: str | None = None, + description: str | None = None, + image_type: str | None = None, + image_src: str | None = None, + hash: str | None = None, + docker_registry: str | None = None, + docker_user: str | None = None, + docker_token: str | None = None, + uncompressed_size_mb: int | None = None, + x_res: int | None = None, + y_res: int | None = None, + hidden: bool | None = None, + memory: int | None = None, + cores: float | None = None, + cpu_allocation_method: CpuAllocationMethod | None = None, + require_gpu: bool | None = None, + gpu_count: int | None = None, + zone_id: str | None = None, + zone_name: str | None = None, + restrict_to_zone: bool | None = None, + server_id: str | None = None, + restrict_to_server: bool | None = None, + server_pool_id: str | None = None, + restrict_to_network: bool | None = None, + restrict_network_names: list[str] | None = None, + allow_network_selection: bool | None = None, + override_egress_gateways: bool | None = None, + enabled: bool | None = None, + available: bool | None = None, + session_time_limit: str | None = None, + session_banner: str | None = None, + session_banner_id: str | None = None, + session_banner_force_disabled: bool | None = None, + persistent_profile_path: str | None = None, + persistent_profile_config: dict[str, str] | None = None, + enforce_workspace_persistence: bool | None = None, + is_remote_app: bool | None = None, + remote_app_name: str | None = None, + remote_app_args: str | None = None, + remote_app_icon: str | None = None, + remote_app_program: str | None = None, + link_url: str | None = None, + categories: list[str] | None = None, + default_category: str | None = None, + include_labels: list[str] | None = None, + exclude_labels: list[str] | None = None, + filter_policy_id: str | None = None, + filter_policy_name: str | None = None, + filter_policy_force_disabled: bool | None = None, + image_attributes: list[ImageAttribute] | None = None, + notes: str | None = None, + volume_mappings: dict[str, Any] | None = None, + launch_config: dict[str, Any] | None = None, + run_config: dict[str, Any] | None = None, + exec_config: dict[str, Any] | None = None, + server: dict[str, Any] | None = None, + server_pool: dict[str, Any] | None = None, + ) -> None: + super().__init__(using) + self.image_id = self._normalize_id(image_id) + self.name = name + self.friendly_name = friendly_name + self.description = description + self.image_type = image_type + self.image_src = image_src + self.hash = hash + self.docker_registry = docker_registry + self.docker_user = docker_user + self.docker_token = docker_token + self.uncompressed_size_mb = uncompressed_size_mb + self.x_res = x_res + self.y_res = y_res + self.hidden = hidden + self.memory = memory + self.cores = cores + self.cpu_allocation_method = cpu_allocation_method + self.require_gpu = require_gpu + self.gpu_count = gpu_count + self.zone_id = zone_id + self.zone_name = zone_name + self.restrict_to_zone = restrict_to_zone + self.server_id = server_id + self.restrict_to_server = restrict_to_server + self.server_pool_id = server_pool_id + self.restrict_to_network = restrict_to_network + self.restrict_network_names = restrict_network_names or [] + self.allow_network_selection = allow_network_selection + self.override_egress_gateways = override_egress_gateways + self.enabled = enabled + self.available = available + self.session_time_limit = session_time_limit + self.session_banner = session_banner + self.session_banner_id = session_banner_id + self.session_banner_force_disabled = session_banner_force_disabled + self.persistent_profile_path = persistent_profile_path + self.persistent_profile_config = persistent_profile_config or {} + self.enforce_workspace_persistence = enforce_workspace_persistence + self.is_remote_app = is_remote_app + self.remote_app_name = remote_app_name + self.remote_app_args = remote_app_args + self.remote_app_icon = remote_app_icon + self.remote_app_program = remote_app_program + self.link_url = link_url + self.categories = categories or [] + self.default_category = default_category + self.include_labels = include_labels or [] + self.exclude_labels = exclude_labels or [] + self.filter_policy_id = filter_policy_id + self.filter_policy_name = filter_policy_name + self.filter_policy_force_disabled = filter_policy_force_disabled + self.image_attributes = image_attributes or [] + self.notes = notes + self.volume_mappings = volume_mappings or {} + self.launch_config = launch_config or {} + self.run_config = run_config or {} + self.exec_config = exec_config or {} + self.server = server or {} + self.server_pool = server_pool or {} + + def __repr__(self) -> str: + return f"" + + @classmethod + def _from_response(cls, using: str | None, data: dict[str, Any]) -> Self: + """Create an instance from the typical API response structure. + + `data` must be the dictionary containing the instance information, e.g. + * `{ "image_id": "", ...}` + not + * `{ "image": { "image_id": "", ...}}`. + """ + image = cls( + using=using, + image_id=data["image_id"], + name=data["name"], + friendly_name=data.get("friendly_name"), + description=data.get("description"), + image_type=data.get("image_type"), + image_src=data.get("image_src"), + hash=data.get("hash"), + docker_registry=data.get("docker_registry"), + docker_user=data.get("docker_user"), + docker_token=data.get("docker_token"), + uncompressed_size_mb=data.get("uncompressed_size_mb"), + x_res=data.get("x_res"), + y_res=data.get("y_res"), + hidden=data.get("hidden"), + memory=data.get("memory"), + cores=data.get("cores"), + cpu_allocation_method=data.get("cpu_allocation_method"), + require_gpu=data.get("require_gpu"), + gpu_count=data.get("gpu_count"), + zone_id=data.get("zone_id"), + zone_name=data.get("zone_name"), + restrict_to_zone=data.get("restrict_to_zone"), + server_id=data.get("server_id"), + restrict_to_server=data.get("restrict_to_server"), + server_pool_id=data.get("server_pool_id"), + restrict_to_network=data.get("restrict_to_network"), + restrict_network_names=data.get("restrict_network_names"), + allow_network_selection=data.get("allow_network_selection"), + override_egress_gateways=data.get("override_egress_gateways"), + enabled=data.get("enabled"), + available=data.get("available"), + session_time_limit=data.get("session_time_limit"), + session_banner=data.get("session_banner"), + session_banner_id=data.get("session_banner_id"), + session_banner_force_disabled=data.get("session_banner_force_disabled"), + persistent_profile_path=data.get("persistent_profile_path"), + persistent_profile_config=data.get("persistent_profile_config"), + enforce_workspace_persistence=data.get("enforce_workspace_persistence"), + is_remote_app=data.get("is_remote_app"), + remote_app_name=data.get("remote_app_name"), + remote_app_args=data.get("remote_app_args"), + remote_app_icon=data.get("remote_app_icon"), + remote_app_program=data.get("remote_app_program"), + link_url=data.get("link_url"), + categories=data.get("categories"), + default_category=data.get("default_category"), + include_labels=data.get("include_labels"), + exclude_labels=data.get("exclude_labels"), + filter_policy_id=data.get("filter_policy_id"), + filter_policy_name=data.get("filter_policy_name"), + filter_policy_force_disabled=data.get("filter_policy_force_disabled"), + notes=data.get("notes"), + volume_mappings=data.get("volume_mappings"), + launch_config=data.get("launch_config"), + run_config=data.get("run_config"), + exec_config=data.get("exec_config"), + server=data.get("server"), + server_pool=data.get("server_pool"), + ) + image.image_attributes = [ + ImageAttribute(attr_id=a["attr_id"], image=image, name=a["name"], category=a["category"], value=a["value"]) + for a in data.get("imageAttributes", []) + ] + return image + + @classmethod + def all(cls, using: str | None = None) -> list[Self]: + """Retrieve a list of available images. + + Permission Required: `Images View` + + For more information, see: + https://docs.kasm.com/docs/latest/developers/developer_api/index.html#get-images + """ + data = connections.get_connection(using).images.get().json() + return [cls._from_response(using, image) for image in data["images"]] + + @classmethod + async def aall(cls, using: str | None = None) -> list[Self]: + """Retrieve a list of available images. + + Permission Required: `Images View` + + For more information, see: + https://docs.kasm.com/docs/latest/developers/developer_api/index.html#get-images + """ + data = (await async_connections.get_connection(using).images.get()).json() + return [cls._from_response(using, image) for image in data["images"]] + + @classmethod + def get(cls, image_id: str, using: str | None = None) -> Self | None: + """Retrieve a list of available images. + + Permission Required: `Images View`. + + For more information, see: + https://docs.kasm.com/docs/latest/developers/developer_api/index.html#get-images + """ + return next((image for image in cls.all(using=using) if image.image_id == image_id), None) + + @classmethod + async def aget(cls, image_id: str, using: str | None = None) -> Self | None: + """Retrieve a list of available images. + + Permission Required: `Images View`. + + For more information, see: + https://docs.kasm.com/docs/latest/developers/developer_api/index.html#get-images + """ + return next((image for image in await cls.aall(using=using) if image.image_id == image_id), None) + + def refresh(self) -> Self | None: + """Refresh the instance data from the API.""" + return self.get(self.image_id) + + async def arefresh(self) -> Self | None: + """Refresh the instance data from the API.""" + return await self.aget(self.image_id) diff --git a/kasm/responses.py b/kasm/responses.py new file mode 100644 index 0000000..76d6ab9 --- /dev/null +++ b/kasm/responses.py @@ -0,0 +1,34 @@ +import json +from typing import Any, Mapping + +import aiohttp +import requests + + +class ClientResponse: + """Common interface for responses from the clients.""" + + def __init__(self, url: str, status_code: int, content: bytes, headers: Mapping[str, str]) -> None: + self.url = url + self.status_code = status_code + self.content = content + self.headers = headers + + @classmethod + def from_requests(cls, response: requests.Response) -> "ClientResponse": + """Create a Response from a requests.Response.""" + return cls(response.url, response.status_code, response.content, response.headers) + + @classmethod + async def from_aiohttp(cls, response: aiohttp.ClientResponse) -> "ClientResponse": + """Create a Response from a aiohttp.ClientResponse.""" + return cls(str(response.url), response.status, await response.read(), response.headers) + + def json(self) -> dict[str, Any]: + """Return the content as a json.""" + return json.loads(self.content.decode()) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} [{self.status_code}]>" + + __str__ = __repr__ diff --git a/kasm/sessions.py b/kasm/sessions.py new file mode 100644 index 0000000..90bbd14 --- /dev/null +++ b/kasm/sessions.py @@ -0,0 +1,744 @@ +import datetime +from typing import Any, Self +from urllib.parse import urlencode, urljoin + +from kasm.abc import KasmObject +from kasm.connections import async_connections, connections +from kasm.enums import PersistentProfileMode, RdpClientType +from kasm.images import Image +from kasm.status import Status +from kasm.users import User + + +class Session(KasmObject): + """Represent a Kasm session. + + Attributes other than `session_id`, `user` and `status` should not be relied + upon as long as `status` is not equal to `Status.RUNNING`. + """ + + def __init__( + self, + using: str | None, + session_id: str, + user: User, + status: Status | None = None, + image: Image | None = None, + url: str | None = None, + host: str | None = None, + port: int | None = None, + token: str | None = None, + view_only_token: str | None = None, + share_id: str | None = None, + container_id: str | None = None, + container_ip: str | None = None, + docker_network: str | None = None, + server_id: str | None = None, + zone_name: str | None = None, + hostname: str | None = None, + point_of_presence: str | None = None, + cores: float | None = None, + memory: int | None = None, + gpus: int | None = None, + start_date: datetime.datetime | None = None, + expiration_date: datetime.datetime | None = None, + keepalive_date: datetime.datetime | None = None, + created_date: datetime.datetime | None = None, + is_persistent_profile: bool | None = None, + persistent_profile_mode: PersistentProfileMode | None = None, + client_settings: dict[str, str | int | bool] | None = None, + port_map: dict[str, dict[str, str | int]] | None = None, + connection_info: dict[str, str] | None = None, + egress_gateway_id: str | None = None, + egress_credential_id: str | None = None, + egress_provider_name: str | None = None, + egress_gateway_name: str | None = None, + egress_gateway_country: str | None = None, + egress_gateway_city: str | None = None, + rdp_client_type: str | None = None, + is_standby: bool | None = None, + agent_installed: bool | None = None, + operational_message: str | None = None, + operational_progress: int | None = None, + autoscale_config_id: str | None = None, + staging_config_id: str | None = None, + cast_config_id: str | None = None, + connection_proxy_id: str | None = None, + connection_credential: str | None = None, + border: str | None = None, + queued_tasks: list | None = None, + ) -> None: + super().__init__(using=using) + self.session_id = self._normalize_id(session_id) + self.user = user + self.status = status + self.image = image + self._url = url + self.host = host + self.port = port + self.token = token + self.view_only_token = view_only_token + self.share_id = share_id + self.container_id = container_id + self.container_ip = container_ip + self.docker_network = docker_network + self.server_id = server_id + self.zone_name = zone_name + self.hostname = hostname + self.point_of_presence = point_of_presence + self.cores = cores + self.memory = memory + self.gpus = gpus + self.start_date = start_date + self.expiration_date = expiration_date + self.keepalive_date = keepalive_date + self.created_date = created_date + self.is_persistent_profile = is_persistent_profile + self.persistent_profile_mode = persistent_profile_mode + self.client_settings = client_settings or {} + self.port_map = port_map or {} + self.connection_info = connection_info or {} + self.egress_gateway_id = egress_gateway_id + self.egress_credential_id = egress_credential_id + self.egress_provider_name = egress_provider_name + self.egress_gateway_name = egress_gateway_name + self.egress_gateway_country = egress_gateway_country + self.egress_gateway_city = egress_gateway_city + self.rdp_client_type = rdp_client_type + self.is_standby = is_standby + self.agent_installed = agent_installed + self.operational_message = operational_message + self.operational_progress = operational_progress + self.autoscale_config_id = autoscale_config_id + self.staging_config_id = staging_config_id + self.cast_config_id = cast_config_id + self.connection_proxy_id = connection_proxy_id + self.connection_credential = connection_credential + self.border = border + self.queued_tasks = queued_tasks or [] + + def __repr__(self) -> str: + return f"" + + def url( + self, + disable_control_panel: bool = False, + disable_tips: bool = False, + disable_viewers: bool = False, + disable_fix_res: bool = False, + using: str | None = None, + ) -> str | None: + """Return the URL of the session. + + If the session was retrieve with `Session.all()`, no URL will be + provided, use `refresh()` to retrieve a session with an URL. + + Parameters + ---------- + disable_control_panel : bool, optional + If set to `True`, hides the Kasm control panel normally used for + uploads, downloads etc. Users will be unable to access this + functionality. + disable_tips : bool, optional + If set to `True`, stops the tips modal from showing when the user + connects to a session. + disable_viewers : bool, optional + If set to `True`, hides the list of viewers for shared sessions. + disable_fix_res : bool, optional + By default, shared sessions are forced into a fixed resolution + and aspect ratio. Setting this argument to `True` will allow the + shared session to operate with a dynamic resolution and aspect + ratio. + using : str | None, optional + The using parameter, by default None + """ + if self._url is None: + return None + + base_url = urljoin(self.client(using).url, self._url) + params = { + "disable_control_panel": int(disable_control_panel), + "disable_tips": int(disable_tips), + "disable_viewers": int(disable_viewers), + "disable_fix_res": int(disable_fix_res), + } + params = {k: v for k, v in params.items() if v} + if not params: + return base_url + return f"{base_url}?{urlencode(params)}" + + @classmethod + def _from_response_starting(cls, using: str | None, user: User, data: dict[str, Any]) -> Self: + """Create an instance from the typical API response structure from a non-running session.""" + return cls( + using=using, + session_id=data["kasm_id"], + status=Status(data["status"]), + user=user, + ) + + @classmethod + def _from_response_running(cls, using: str | None, user: User, image: Image | None, data: dict[str, Any]) -> Self: + """Create an instance from the typical API response structure from a running session. + + `data` must be the dictionary containing the instance information, e.g. + * `{ "kasm_id": "", ...}` + not + * `{ "kasm": { "kasm_id": "", ...}}`. + """ + if data.get("start_date") == "None": + data["start_date"] = None + if data.get("expiration_date") == "None": + data["expiration_date"] = None + if data.get("keepalive_date") == "None": + data["keepalive_date"] = None + if data.get("created_date") == "None": + data["created_date"] = None + return cls( + using=using, + session_id=data["kasm_id"], + user=user, + status=Status(data["operational_status"]), + image=image, + url=data.get("url"), + host=data.get("host"), + port=data.get("port"), + token=data.get("token"), + view_only_token=data.get("view_only_token"), + share_id=data.get("share_id"), + container_id=data.get("container_id"), + container_ip=data.get("container_ip"), + docker_network=data.get("docker_network"), + server_id=data.get("server_id"), + zone_name=data.get("zone_name"), + hostname=data.get("hostname"), + point_of_presence=data.get("point_of_presence"), + cores=data.get("cores"), + memory=data.get("memory"), + gpus=data.get("gpus"), + start_date=data.get("start_date") and datetime.datetime.fromisoformat(data["start_date"]), + expiration_date=data.get("expiration_date") and datetime.datetime.fromisoformat(data["expiration_date"]), + keepalive_date=data.get("keepalive_date") and datetime.datetime.fromisoformat(data["keepalive_date"]), + created_date=data.get("created_date") and datetime.datetime.fromisoformat(data["created_date"]), + is_persistent_profile=data.get("is_persistent_profile"), + persistent_profile_mode=data.get("persistent_profile_mode"), + client_settings=data.get("client_settings"), + port_map=data.get("port_map"), + connection_info=data.get("connection_info"), + egress_gateway_id=data.get("egress_gateway_id"), + egress_credential_id=data.get("egress_credential_id"), + egress_provider_name=data.get("egress_provider_name"), + egress_gateway_name=data.get("egress_gateway_name"), + egress_gateway_country=data.get("egress_gateway_country"), + egress_gateway_city=data.get("egress_gateway_city"), + rdp_client_type=data.get("rdp_client_type"), + is_standby=data.get("is_standby"), + agent_installed=data.get("agent_installed"), + operational_message=data.get("operational_message"), + operational_progress=data.get("operational_progress"), + autoscale_config_id=data.get("autoscale_config_id"), + staging_config_id=data.get("staging_config_id"), + cast_config_id=data.get("cast_config_id"), + connection_proxy_id=data.get("connection_proxy_id"), + connection_credential=data.get("connection_credential"), + border=data.get("border"), + queued_tasks=data.get("queued_tasks"), + ) + + @classmethod + def all(cls, using: str | None = None) -> list[Self]: + """Retrieve a list of running sessions. + + You can request a specific session by providing + + Permission Required: `Sessions View` + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasms + """ + data = connections.get_connection(using).sessions.get().json() + sessions = [] + users = {u.user_id: u for u in User.all(using=using)} + images = {i.image_id: i for i in Image.all(using=using)} + for session in data["kasms"]: + user_id = cls._normalize_id(session["user_id"]) + image_id = cls._normalize_id(session["image_id"]) + sessions.append(cls._from_response_running(using, users[user_id], images[image_id], session)) + return sessions + + @classmethod + async def aall(cls, using: str | None = None) -> list[Self]: + """Retrieve a list of running sessions. + + You can request a specific session by providing + + Permission Required: `Sessions View` + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasms + """ + data = (await async_connections.get_connection(using).sessions.get()).json() + sessions = [] + users = {u.user_id: u for u in await User.aall(using=using)} + images = {i.image_id: i for i in await Image.aall(using=using)} + for session in data["kasms"]: + user_id = cls._normalize_id(session["user_id"]) + image_id = cls._normalize_id(session["image_id"]) + sessions.append(cls._from_response_running(using, users[user_id], images[image_id], session)) + return sessions + + @classmethod + def get(cls, session_id: str, user: str | User, using: str | None = None) -> Self: + """Retrieve a specific session. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasm-status + """ + if isinstance(user, str): + user = User.get(user_id=user, using=using) + session = cls(using=using, session_id=session_id, user=user) + return session.update_status(using=using) + + @classmethod + async def aget(cls, session_id: str, user: str | User, using: str | None = None) -> Self: + """Retrieve a specific session. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasm-status + """ + if isinstance(user, str): + user = await User.aget(user_id=user, using=using) + session = cls(using=using, session_id=session_id, user=user) + return await session.aupdate_status(using=using) + + @classmethod + def request( + cls, + user: User | str, + image: Image | str, + enable_sharing: bool = False, + kasm_url: str = None, + environment: dict[str, str] = None, + connection_info: dict[str, str] = None, + client_language: str = None, + client_timezone: str = None, + egress_gateway_id: str = None, + persistent_profile_mode: PersistentProfileMode = None, + rdp_client_type: RdpClientType = None, + server_id: str = None, + using: str | None = None, + ) -> Self: + """Request a new session to be created. + + Use `update_status()` to ensure the session reaches a running state, + before directing the user to the session. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#request-kasm + + Parameters + ---------- + user: User | str + If specified, the Kasm session will be created under this user. The + value must be an instance of `User` or the `user_id` string. + image: Image | str + The ID of the image to use for the Kasm session. The value + must be an instance of `Image` or the `image_id` string. + enable_sharing: bool, optional + If set to `True`, the Kasm session will be created with sharing mode + enabled by default. Default to `False`. + kasm_url: str, optional + If specified, the browser inside the session will navigate to this + URL. Only applicable to browser-based images (e.g., Kasm Chrome, + Firefox, Tor Browser). + environment: dict[str, str], optional + Environment variables to inject into the created container session. + connection_info: dict[str, str], optional + Custom RDP/VNC/SSH connection settings. Not applicable for + container-based sessions. + client_language: str, optional + Language to be passed as an environment variable to the Kasm + session. Refer to "Valid Languages" documentation for accepted + values. + client_timezone: str, optional + Timezone to be passed as an environment variable to the Kasm + session. Refer to "Valid Timezones" documentation for accepted + values. + egress_gateway_id: str, optional + ID of the Egress Gateway the session should connect to at launch. + The user must have permission and an associated Egress Credential. + Only applies to container-based sessions. See the Egress + documentation for details. + persistent_profile_mode: PersistentProfileMode, optional + Controls behavior of persistent profiles (only for container + sessions with persistent profile path configured). + - ENABLED: Uses a persistent profile. If it doesn't exist, it will be created. + - DISABLED: Does not load a persistent profile. + - RESET: Deletes any existing profile and creates a new one. + rdp_client_type: RdpClientType, optional + Required when the Workspace image is an RDP Server and the RDP + Client option is set to User Selectable. + - GUAC: Session is web-native. + - RDP_CLIENT: User connects using an RDP client. + server_id: str, optional + Only applicable for sessions using non-container-based Server Pools. + Specifies the exact server in the pool to use for the session. + """ + user_instance: User = User.get(user_id=user, using=using) if isinstance(user, str) else user + data = ( + connections.get_connection(using) + .sessions.request( + user_instance.user_id, + image if isinstance(image, str) else image.image_id, + enable_sharing, + kasm_url, + environment, + connection_info, + client_language, + client_timezone, + egress_gateway_id, + persistent_profile_mode, + rdp_client_type, + server_id, + ) + .json() + ) + return cls._from_response_starting(using, user_instance, data) + + @classmethod + async def arequest( + cls, + user: User | str, + image: Image | str, + enable_sharing: bool = False, + kasm_url: str = None, + environment: dict[str, str] = None, + connection_info: dict[str, str] = None, + client_language: str = None, + client_timezone: str = None, + egress_gateway_id: str = None, + persistent_profile_mode: PersistentProfileMode = None, + rdp_client_type: RdpClientType = None, + server_id: str = None, + using: str | None = None, + ) -> Self: + """Request a new session to be created. + + Use `update_status()` to ensure the session reaches a running state, + before directing the user to the session. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#request-kasm + + Parameters + ---------- + user: User | str + If specified, the Kasm session will be created under this user. The + value must be an instance of `User` or the `user_id` string. + image: Image | str + The ID of the image to use for the Kasm session. The value + must be an instance of `Image` or the `image_id` string. + enable_sharing: bool, optional + If set to `True`, the Kasm session will be created with sharing mode + enabled by default. Default to `False`. + kasm_url: str, optional + If specified, the browser inside the session will navigate to this + URL. Only applicable to browser-based images (e.g., Kasm Chrome, + Firefox, Tor Browser). + environment: dict[str, str], optional + Environment variables to inject into the created container session. + connection_info: dict[str, str], optional + Custom RDP/VNC/SSH connection settings. Not applicable for + container-based sessions. + client_language: str, optional + Language to be passed as an environment variable to the Kasm + session. Refer to "Valid Languages" documentation for accepted + values. + client_timezone: str, optional + Timezone to be passed as an environment variable to the Kasm + session. Refer to "Valid Timezones" documentation for accepted + values. + egress_gateway_id: str, optional + ID of the Egress Gateway the session should connect to at launch. + The user must have permission and an associated Egress Credential. + Only applies to container-based sessions. See the Egress + documentation for details. + persistent_profile_mode: PersistentProfileMode, optional + Controls behavior of persistent profiles (only for container + sessions with persistent profile path configured). + - ENABLED: Uses a persistent profile. If it doesn't exist, it will be created. + - DISABLED: Does not load a persistent profile. + - RESET: Deletes any existing profile and creates a new one. + rdp_client_type: RdpClientType, optional + Required when the Workspace image is an RDP Server and the RDP + Client option is set to User Selectable. + - GUAC: Session is web-native. + - RDP_CLIENT: User connects using an RDP client. + server_id: str, optional + Only applicable for sessions using non-container-based Server Pools. + Specifies the exact server in the pool to use for the session. + """ + user_instance: User = (await User.aget(user_id=user, using=using)) if isinstance(user, str) else user + data = ( + await async_connections.get_connection(using).sessions.request( + user_instance.user_id, + image if isinstance(image, str) else image.image_id, + enable_sharing, + kasm_url, + environment, + connection_info, + client_language, + client_timezone, + egress_gateway_id, + persistent_profile_mode, + rdp_client_type, + server_id, + ) + ).json() + return cls._from_response_starting(using, user_instance, data) + + def refresh(self) -> Self: + """Refresh the instance data from the API.""" + return self.get(self.session_id, self.user) + + async def arefresh(self) -> Self: + """Refresh the instance data from the API.""" + return await self.aget(self.session_id, self.user) + + def update_status(self, skip_agent_check: bool = False, using: str | None = None) -> Self: + """Return an new instance with its status updated. + + This call updates the session token for the user, creating a new + connection url and invalidating the old one. + + You should wait until the sessions reaches a `running` operational + status before directing the end user into the session. + + If the session is not in a `running` state, the `kasm` object may not be + provided, in which case the caller should interrogate the top level + `operational_status`. `operational_progress` and `operational_message` + may be used to provide context to the user. + + Use `skip_agent_check` to skip connecting out to the agent to verify + status of the container, instead use the current value in the database + for the status. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasm-status + """ + data = ( + self.client(using) + .sessions.status(user_id=self.user.user_id, session_id=self.session_id, skip_agent_check=skip_agent_check) + .json() + ) + if "kasm" not in data: + return self._from_response_starting(using, self.user, data) + image = Image.get(data["kasm"]["image"]["image_id"], using=using) if data["kasm"].get("image") else None + data["kasm"]["url"] = data["kasm_url"] + return self._from_response_running(using, self.user, image, data["kasm"]) + + async def aupdate_status(self, skip_agent_check: bool = False, using: str | None = None) -> Self: + """Return an new instance with its status updated. + + This call also updates the session token for the user, creating a new + connection url and invalidating the old one. + + You should wait until the sessions reaches a `running` operational + status before directing the end user into the session. + + If the session is not in a `running` state, the `kasm` object may not be + provided, in which case the caller should interrogate the top level + `operational_status`. `operational_progress` and `operational_message` + may be used to provide context to the user. + + Use `skip_agent_check` to skip connecting out to the agent to verify + status of the container, instead use the current value in the database + for the status. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-kasm-status + """ + data = ( + await self.async_client(using).sessions.status( + user_id=self.user.user_id, session_id=self.session_id, skip_agent_check=skip_agent_check + ) + ).json() + if "kasm" not in data: + return self._from_response_starting(using, self.user, data) + image = ( + (await Image.aget(data["kasm"]["image"]["image_id"], using=using)) if data["kasm"].get("image") else None + ) + data["kasm"]["url"] = data["kasm_url"] + return self._from_response_running(using, self.user, image, data["kasm"]) + + def join(self, user: User | str, using: str | None = None) -> str | None: + """Return the join url to connect to the session as a view-only user. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#join-kasm + """ + if self.share_id is None: + return None + user_id = user.user_id if isinstance(user, User) else user + data = self.client(using=using).sessions.join(user_id=user_id, shared_id=self.share_id).json() + return urljoin(self.client(using=using).url, data["kasm_url"]) + + async def ajoin(self, user: User | str, using: str | None = None) -> str | None: + """Return the join url to connect to the session as a view-only user. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#join-kasm + """ + if self.share_id is None: + return None + user_id = user.user_id if isinstance(user, User) else user + data = (await self.async_client(using=using).sessions.join(user_id=user_id, shared_id=self.share_id)).json() + return urljoin(self.async_client(using=using).url, data["kasm_url"]) + + def destroy(self, using: str | None = None) -> Self: + """Destroy the Kasm session. + + The backend system my destroy the session immediately or queue the task + for further processing prior to deletion such as saving off persistent + profiles. + + If integrators wish to confirm the session is fully deleted, follow-up + calls to `status()` may be called. An error will be thrown to confirm + the session no longer exists. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#destroy-kasm + """ + self.client(using=using).sessions.destroy(user_id=self.user.user_id, session_id=self.session_id) + return self + + async def adestroy(self, using: str | None = None) -> Self: + """Destroy the Kasm session. + + The backend system my destroy the session immediately or queue the task + for further processing prior to deletion such as saving off persistent + profiles. + + If integrators wish to confirm the session is fully deleted, follow-up + calls to `status()` may be called. An error will be thrown to confirm + the session no longer exists. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#destroy-kasm + """ + await self.async_client(using=using).sessions.destroy(user_id=self.user.user_id, session_id=self.session_id) + return self + + def keepalive(self, using: str | None = None) -> Self: + """Return a new instance with the updated expiration time. + + The new expiration time will be updated to reflect the + `keepalive_expiration` Group Setting assigned to the Kasm's associated + user. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#keepalive + """ + self.client(using=using).sessions.keepalive(self.session_id) + return self.update_status(using=using) + + async def akeepalive(self, using: str | None = None) -> Self: + """Return a new instance with the updated expiration time. + + The new expiration time will be updated to reflect the + `keepalive_expiration` Group Setting assigned to the Kasm's associated + user. + + Permission Required: `Users Auth Session` and `User`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#keepalive + """ + await self.async_client(using=using).sessions.keepalive(self.session_id) + return await self.aupdate_status(using=using) + + def execute( + self, + command: str, + workdir: str, + environment: dict[str, str] = None, + privileged: bool = False, + user: str = None, + using: str | None = None, + ) -> None: + """Execute an arbitrary command inside a user's session. + + Parameters + ---------- + command: str + The command to execute inside the user's container. + workdir: str + The working directory path for the exec session inside the container. + environment: dict[str, str], optional + Environment variables to set for the exec session. + privileged: bool, optional + If `True`, runs the exec session with privileged permissions. + Default to `False`. + user: str, optional + The user to run the command as. By default, the session runs as the + sandboxed user. Set to "root" to run as root, which disables sandbox + protections. Any value other than "root" may cause the command to + fail. + """ + self.client(using=using).sessions.execute( + self.user.user_id, self.session_id, command, workdir, environment, privileged, user + ) + + async def aexecute( + self, + command: str, + workdir: str, + environment: dict[str, str] = None, + privileged: bool = False, + user: str = None, + using: str | None = None, + ) -> None: + """Execute an arbitrary command inside a user's session. + + Parameters + ---------- + command: str + The command to execute inside the user's container. + workdir: str + The working directory path for the exec session inside the container. + environment: dict[str, str], optional + Environment variables to set for the exec session. + privileged: bool, optional + If `True`, runs the exec session with privileged permissions. + Default to `False`. + user: str, optional + The user to run the command as. By default, the session runs as the + sandboxed user. Set to "root" to run as root, which disables sandbox + protections. Any value other than "root" may cause the command to + fail. + """ + await self.async_client(using=using).sessions.execute( + self.user.user_id, self.session_id, command, workdir, environment, privileged, user + ) diff --git a/kasm/status.py b/kasm/status.py new file mode 100644 index 0000000..6fe95cd --- /dev/null +++ b/kasm/status.py @@ -0,0 +1,26 @@ +class Status: + """Represent a Kasm session status.""" + + STARTING = "starting" + RUNNING = "running" + + def __init__(self, value: str, progress: int = None, message: str = None) -> None: + self.value = value + self.progress = progress + self.message = message + + def __hash__(self) -> int: + return hash(self.value) + + def __eq__(self, other: object) -> bool: + if isinstance(other, self.__class__): + return self.value == other.value + if isinstance(other, str): + return self.value == other + return NotImplemented + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.value}, {self.progress}, {self.message})" + + def __str__(self) -> str: + return self.value diff --git a/kasm/users.py b/kasm/users.py new file mode 100644 index 0000000..37ad809 --- /dev/null +++ b/kasm/users.py @@ -0,0 +1,521 @@ +import datetime +from typing import TYPE_CHECKING, Any, Self + +from kasm.abc import KasmObject +from kasm.attributes import Attribute +from kasm.connections import async_connections, connections +from kasm.groups import Group + +if TYPE_CHECKING: + from kasm.sessions import Session + + +class User(KasmObject): + """Represent a Kasm user.""" + + def __init__( + self, + using: str | None, + user_id: str, + username: str, + first_name: str | None = None, + last_name: str | None = None, + phone: str | None = None, + email: str | None = None, + notes: str | None = None, + realm: str | None = None, + organization: str | None = None, + city: str | None = None, + state: str | None = None, + country: str | None = None, + locked: bool | None = None, + disabled: bool | None = None, + two_factor: bool | None = None, + created: datetime.datetime | None = None, + password_set_date: datetime.datetime | None = None, + last_session: datetime.datetime | None = None, + program_id: str | None = None, + groups: list[Group] | None = None, + sessions: list[dict[str, Any]] | None = None, + assigned_servers: list | None = None, + ) -> None: + super().__init__(using) + self.user_id = self._normalize_id(user_id) + self.username = username + self.first_name = first_name + self.last_name = last_name + self.phone = phone + self.email = email + self.notes = notes + self.realm = realm + self.organization = organization + self.city = city + self.state = state + self.country = country + self.locked = locked + self.disabled = disabled + self.two_factor = two_factor + self.created = created + self.password_set_date = password_set_date + self.last_session = last_session + self.program_id = program_id + self.groups = groups or [] + self._sessions_lazy: list[dict[str, Any]] = sessions or [] + self._sessions: list["Session"] | None = None + self.assigned_servers = assigned_servers or [] + self._attributes: Attribute | None = None + + def __repr__(self) -> str: + return f"" + + @property + def attributes(self) -> Attribute: + """Lazy-load the user's attributes.""" + if self._attributes is None: + self._attributes = Attribute.get(self, using=self._using) + return self._attributes + + @property + def sessions(self) -> list["Session"]: + """Lazy-load the user's sessions.""" + from kasm.sessions import Session + + if self._sessions is None: + self._sessions = [Session(self._using, s["kasm_id"], self).update_status() for s in self._sessions_lazy] + return self._sessions + + @classmethod + def _from_response(cls, using: str | None, data: dict[str, Any]) -> Self: + """Create an instance from the typical API response structure. + + `data` must be the dictionary containing the instance information, e.g. + * `{ "user_id": "", ...}` + not + * `{ "user": { "user_id": "", ...}}`. + """ + groups = [Group(using=using, group_id=g["group_id"], name=g["name"]) for g in data.get("groups", [])] + if data.get("created") == "None": + data["created"] = None + if data.get("password_set_date") == "None": + data["password_set_date"] = None + if data.get("last_session") == "None": + data["last_session"] = None + return cls( + using=using, + user_id=data["user_id"], + username=data["username"], + first_name=data.get("first_name"), + last_name=data.get("last_name"), + phone=data.get("phone"), + email=data.get("email"), + notes=data.get("notes"), + realm=data.get("realm"), + organization=data.get("organization"), + city=data.get("city"), + state=data.get("state"), + country=data.get("country"), + locked=data.get("locked"), + disabled=data.get("disabled"), + two_factor=data.get("two_factor"), + created=data.get("created") and datetime.datetime.fromisoformat(data["created"]), + password_set_date=( + data.get("password_set_date") and datetime.datetime.fromisoformat(data["password_set_date"]) + ), + last_session=data.get("last_session") and datetime.datetime.fromisoformat(data["last_session"]), + program_id=data.get("program_id"), + groups=groups, + sessions=data.get("kasms"), + assigned_servers=data.get("assigned_servers"), + ) + + @classmethod + def create( + cls, + username: str, + password: str, + first_name: str | None = None, + last_name: str | None = None, + phone: str | None = None, + organization: str | None = None, + locked: bool = False, + disabled: bool = False, + using: str | None = None, + ) -> Self: + """Create a new user. + + Permission Required: `Users Create`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#create-user + """ + data = ( + connections.get_connection(using) + .users.create( + username=username, + password=password, + first_name=first_name, + last_name=last_name, + phone=phone, + organization=organization, + locked=locked, + disabled=disabled, + ) + .json() + ) + return cls._from_response(using, data["user"]) + + @classmethod + async def acreate( + cls, + username: str, + password: str, + first_name: str | None = None, + last_name: str | None = None, + phone: str | None = None, + organization: str | None = None, + locked: bool = False, + disabled: bool = False, + using: str | None = None, + ) -> Self: + """Create a new user. + + Permission Required: `Users Create`. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#create-user + """ + data = ( + await async_connections.get_connection(using).users.create( + username=username, + password=password, + first_name=first_name, + last_name=last_name, + phone=phone, + organization=organization, + locked=locked, + disabled=disabled, + ) + ).json() + return cls._from_response(using, data["user"]) + + @classmethod + def get(cls, *, user_id: str | None = None, username: str | None = None, using: str | None = None) -> Self: + """Retrieve a specific user. + + Permission Required: `Users View`. Additionally, `Servers View` to + receive assigned server information. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-user + """ + if user_id is None and username is None: + raise ValueError("Either 'user_id' or 'username' must be provided.") + data = connections.get_connection(using).users.get(user_id=user_id, username=username).json() + return cls._from_response(using, data["user"]) + + @classmethod + async def aget(cls, user_id: str | None = None, username: str | None = None, using: str | None = None) -> Self: + """Retrieve a specific user. + + Permission Required: `Users View`. Additionally, `Servers View` to + receive assigned server information. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-user + """ + if user_id is None and username is None: + raise ValueError("Either 'user_id' or 'username' must be provided.") + data = (await async_connections.get_connection(using).users.get(user_id=user_id, username=username)).json() + return cls._from_response(using, data["user"]) + + @classmethod + def all(cls, using: str | None = None) -> list[Self]: + """Retrieve all users. + + Permission Required: `Users View`. Additionally, `Servers View` to + receive assigned server information. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-users. + """ + data = connections.get_connection(using).users.get().json() + users = [] + for user in data["users"]: + users.append(cls._from_response(using, user)) + return users + + @classmethod + async def aall(cls, using: str | None = None) -> list[Self]: + """Retrieve all users. + + Permission Required: `Users View`. Additionally, `Servers View` to + receive assigned server information. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#get-users. + """ + data = (await async_connections.get_connection(using).users.get()).json() + users = [] + for user in data["users"]: + users.append(cls._from_response(using, user)) + return users + + def refresh(self) -> Self: + """Refresh the instance data from the API.""" + return self.get(user_id=self.user_id) + + async def arefresh(self) -> Self: + """Refresh the instance data from the API.""" + return await self.aget(user_id=self.user_id) + + def save(self, using: str | None = None) -> Self: + """Save the user and its attributes. + + Permission Required: `Users Modify` and `Users Modify Admin` to update + users with Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user + """ + self.client(using).users.update( + user_id=self.user_id, + username=self.username, + first_name=self.first_name, + last_name=self.last_name, + phone=self.phone, + organization=self.organization, + locked=self.locked, + disabled=self.disabled, + ) + if self._attributes is not None: + self.attributes.save(using) + return self + + async def asave(self, using: str | None = None) -> Self: + """Save the user and its attributes. + + Permission Required: `Users Modify` and `Users Modify Admin` to update + users with Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user + """ + await self.async_client(using).users.update( + user_id=self.user_id, + username=self.username, + first_name=self.first_name, + last_name=self.last_name, + phone=self.phone, + organization=self.organization, + locked=self.locked, + disabled=self.disabled, + ) + if self._attributes is not None: + await self.attributes.asave(using) + return self + + def set_password(self, password: str, using: str | None = None) -> Self: + """Set the user's password. + + Permission Required: `Users Modify` and `Users Modify Admin` to update + users with Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user + """ + self.client(using).users.update(self.user_id, username=self.username, password=password) + return self + + async def aset_password(self, password: str, using: str | None = None) -> Self: + """Set the user's password. + + Permission Required: `Users Modify` and `Users Modify Admin` to update + users with Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#update-user + """ + await self.async_client(using).users.update(self.user_id, username=self.username, password=password) + return self + + def delete(self, force: bool = False, using: str | None = None) -> None: + """Delete the user. + + If the user has any existing Kasm sessions, deletion will fail. Set the + `force` argument to `True` to delete the user's sessions and delete + the user. + + Permission Required: `Users Delete` and `Users Modify`, + `Users Modify Admin` is required to delete a user with Global Admin + permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#delete-user + """ + self.client(using).users.delete(self.user_id, force=force) + + async def adelete(self, force: bool = False, using: str | None = None) -> None: + """Delete the user. + + If the user has any existing Kasm sessions, deletion will fail. Set the + `force` argument to `True` to delete the user's sessions and delete + the user. + + Permission Required: `Users Delete` and `Users Modify`, + `Users Modify Admin` is required to delete a user with Global Admin + permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#delete-user + """ + await self.async_client(using).users.delete(self.user_id, force=force) + + def logout(self, using: str | None = None) -> Self: + """Logout all sessions for the user. + + Permission Required: `Users Auth Session`. + + For more information, see https://docs.kasm.com/docs/developers/developer_api#logout-user + """ + self.client(using).users.logout(self.user_id) + return self + + async def alogout(self, using: str | None = None) -> Self: + """Logout all sessions for an existing user. + + Permission Required: `Users Auth Session`. + + For more information, see https://docs.kasm.com/docs/developers/developer_api#logout-user + """ + await self.async_client(using).users.logout(self.user_id) + return self + + def add_to_group(self, group: str | Group, using: str | None = None) -> Self: + """Add the user to a group. + + Permission Required: `Users Modify` and `Groups Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission + or if adding to a group with the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#add-user-to-group + """ + group_id = group.group_id if isinstance(group, Group) else group + self.client(using).users.add_to_group(self.user_id, group_id) + if isinstance(group, Group): + self.groups.append(group) + else: + data = self.client(using).users.get(user_id=self.user_id).json() + self.groups = [ + Group(using=using, group_id=g["group_id"], name=g["name"]) for g in data["user"].get("groups", []) + ] + return self + + async def aadd_to_group(self, group: str | Group, using: str | None = None) -> Self: + """Add the user to a group. + + Permission Required: `Users Modify` and `Groups Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission + or if adding to a group with the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#add-user-to-group + """ + group_id = group.group_id if isinstance(group, Group) else group + await self.async_client(using).users.add_to_group(self.user_id, group_id) + if isinstance(group, Group): + self.groups.append(group) + else: + data = (await self.async_client(using).users.get(user_id=self.user_id)).json() + self.groups = [ + Group(using=using, group_id=g["group_id"], name=g["name"]) for g in data["user"].get("groups", []) + ] + return self + + def remove_from_group(self, group: str | Group, using: str | None = None) -> Self: + """Remove the user from a group. + + Permission Required: `Groups Modify`, `Groups Modify System` to remove + the user from the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#remove-user-from-group + """ + group_id = group.group_id if isinstance(group, Group) else group + self.client(using).users.remove_from_group(self.user_id, group_id) + if isinstance(group, Group): + self.groups = [g for g in self.groups if g.group_id != group_id] + else: + data = self.client(using).users.get(user_id=self.user_id).json() + self.groups = [ + Group(using=using, group_id=g["group_id"], name=g["name"]) for g in data["user"].get("groups", []) + ] + return self + + async def aremove_from_group(self, group: str | Group, using: str | None = None) -> Self: + """Remove the user from a group. + + Permission Required: `Groups Modify`, `Groups Modify System` to remove + the user from the built-in All Users or Administrator groups. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#remove-user-from-group + """ + group_id = group.group_id if isinstance(group, Group) else group + await self.async_client(using).users.remove_from_group(self.user_id, group_id) + if isinstance(group, Group): + self.groups = [g for g in self.groups if g.group_id != group_id] + else: + data = (await self.async_client(using).users.get(user_id=self.user_id)).json() + self.groups = [ + Group(using=using, group_id=g["group_id"], name=g["name"]) for g in data["user"].get("groups", []) + ] + return self + + def assign_server(self, server_id: str, using: str | None = None) -> Self: + """Assign a server to the user. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#assign-user-to-server + """ + self.client(using).users.assign_server(self.user_id, server_id) + return self + + async def aassign_server(self, server_id: str, using: str | None = None) -> Self: + """Assign a server to the user. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#assign-user-to-server + """ + await self.async_client(using).users.assign_server(self.user_id, server_id) + return self + + def unassign_server(self, server_id: str, using: str | None = None) -> Self: + """Unassign a server from the user. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#assign-user-to-server + """ + self.client(using).users.unassign_server(self.user_id, server_id) + return self + + async def aunassign_server(self, server_id: str, using: str | None = None) -> Self: + """Unassign a server from the user. + + Permission Required: `Servers Modify` and `Users Modify`. Also + `Users Modify Admin` if the target user has the Global Admin permission. + + For more information, see: + https://docs.kasm.com/docs/developers/developer_api#assign-user-to-server + """ + await self.async_client(using).users.unassign_server(self.user_id, server_id) + return self diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b878f31 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["setuptools>=77", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pykasm" +version = "0.1.0" +description = "Python client for the Kasm API." +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE*"] +authors = [{ name = "Quentin Coumes (Codoc)", email = "quentin@codoc.co" }] +keywords = ["kasm"] + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Natural Language :: English", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP", +] + +dependencies = [ + "aiohttp>=3.13.3,<4.0.0", + "requests>=2.33.0,<3.0.0", +] + +[dependency-groups] +dev = [ + "bandit>=1.9.2,<2.0.0", + "black>=25.12.0,<26.0.0", + "click==8.2.1", + "coverage>=7.13.1,<8.0.0", + "isort>=7.0.0,<8.0.0", + "mypy>=1.19.1,<2.0.0", + "pycodestyle>=2.14.0,<3.0.0", + "pydocstyle>=6.3.0,<7.0.0", + "pytest>=9.0.2,<10.0.0", + "pytest-cov>=7.0.0,<8.0.0", + "types-requests>=2.32.4.20260107,<3.0.0.0", + "pyflakes>=3.4.0,<4.0.0", + "autoflake>=2.3.1,<3.0.0", + "pytest-asyncio>=1.3.0,<2.0.0", + "mkdocs>=1.6.1,<2.0.0", +] + +[project.urls] +Homepage = "https://github.com/Codoc-os/pykasm" +Changelog = "https://github.com/Codoc-os/pykasm/blob/main/CHANGELOG.md" +Issues = "https://github.com/Codoc-os/pykasm/issues" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["kasm", "kasm.*"] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0219a88 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,45 @@ +######################## +### Checks settings #### +######################## +[pycodestyle] +count = True +max-line-length = 120 +max-doc-length = 100 +exclude = venv, .tox +ignore = W503, W504, W605, E121, E123, E126, E203, E501 +# W503: Line break occurred before a binary operator +# W504: Line break occurred after a binary operator +# W605: Invalid escape sequence +# E121: Continuation line under-indented for hanging indent +# E123: Closing bracket does not match indentation of opening bracket's line +# E126: Continuation line over-indented for hanging indent +# E203: Whitespace before ':' +# E501: Line too long + +[mypy] +ignore_missing_imports = True +no_implicit_optional = False +disable_error_code = attr-defined,index,union-attr,assignment,no-redef + +[pydocstyle] +convention = numpy +match-dir = (?!tests|migrations|\.).* +match = (?!test_|conftest|manage\.py).*\.py +add_ignore = D100, D104, D105, D106 +# D100: Missing docstring in public module +# D104: Missing docstring in public package +# D105: Missing docstring in magic method +# D106: Missing docstring in public nested class + +[tool:isort] +profile = black +line_length = 120 +src_paths = kasm + +[bandit] +targets = kasm +exclude = venv, .tox +recursive = True +quiet = True +format = custom +msg-template = {abspath}:{line} - {test_id} - {severity} - {msg}