diff --git a/.forgejo/workflows/build-release.yaml b/.forgejo/workflows/build-release.yaml index 1170c10..48e5e92 100644 --- a/.forgejo/workflows/build-release.yaml +++ b/.forgejo/workflows/build-release.yaml @@ -6,7 +6,7 @@ on: - '[0-9]+\.[0-9]+\.[0-9]' jobs: - backend-pylint: + lint-and-typing: runs-on: docker container: nikolaik/python-nodejs:python3.11-nodejs21 steps: @@ -14,16 +14,19 @@ jobs: uses: https://code.forgejo.org/actions/checkout@v3 - name: Install packages run: | - pip install -e . -q + pip install -e .[lint,typing] -q --disable-pip-version-check -q python -m pip list --format=columns --disable-pip-version-check - pip install pylint~=2.17.7 --disable-pip-version-check -q - name: Run pylint run: | pylint --version pylint **/*.py --exit-zero --rc-file pyproject.toml + - name: Run mypy + run: | + mypy --version + mypy . build-artifacts: - needs: ["backend-pylint"] + needs: ["lint-and-typing"] runs-on: docker container: nikolaik/python-nodejs:python3.11-nodejs21 steps: @@ -53,3 +56,37 @@ jobs: run: pip install twine - name: Upload package to registry run: python -m twine upload --repository-url ${{ secrets.REPOSITORY_URL }} -u ${{ secrets.TWINE_DEPLOY_USER }} -p ${{ secrets.TWINE_DEPLOY_PASSWORD }} dist/* + + build-and-push-container: + needs: [ "publish-artifacts" ] + runs-on: dind + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log into Docker Package Registry + uses: docker/login-action@v3 + with: + registry: forgejo.neshweb.net + username: ${{ secrets.FORGEJO_USERNAME }} + password: ${{ secrets.FORGEJO_TOKEN }} + - name: Build and push to Docker Package Registry + uses: docker/build-push-action@v5 + with: + build-args: | + PACKAGE_VERSION=${{ github.ref_name }} + push: true + tags: forgejo.neshweb.net/firq/dockge-cli:${{ github.ref_name }} + + release: + needs: [ build-and-push-container, publish-artifacts ] + if: success() + runs-on: docker + steps: + - name: Release New Version + uses: actions/forgejo-release@v1 + with: + direction: upload + url: https://forgejo.neshweb.net + release-dir: release + token: ${{ secrets.FORGEJO_TOKEN }} + tag: ${{ github.ref_name }} \ No newline at end of file diff --git a/.forgejo/workflows/check.yaml b/.forgejo/workflows/check.yaml new file mode 100644 index 0000000..55de02a --- /dev/null +++ b/.forgejo/workflows/check.yaml @@ -0,0 +1,34 @@ +on: + push: + branches: "**" + +jobs: + pylint: + runs-on: docker + container: nikolaik/python-nodejs:python3.11-nodejs21 + steps: + - name: Checkout source code + uses: https://code.forgejo.org/actions/checkout@v3 + - name: Install packages + run: | + pip install -e .[lint] -q --disable-pip-version-check -q + python -m pip list --format=columns --disable-pip-version-check + - name: Run pylint + run: | + pylint --version + pylint **/*.py --exit-zero --rc-file pyproject.toml + + mypy: + runs-on: docker + container: nikolaik/python-nodejs:python3.11-nodejs21 + steps: + - name: Checkout source code + uses: https://code.forgejo.org/actions/checkout@v3 + - name: Install packages + run: | + pip install -e .[typing] -q --disable-pip-version-check -q + python -m pip list --format=columns --disable-pip-version-check + - name: Run mypy + run: | + mypy --version + mypy . diff --git a/.forgejo/workflows/lint.yaml b/.forgejo/workflows/lint.yaml deleted file mode 100644 index a88449c..0000000 --- a/.forgejo/workflows/lint.yaml +++ /dev/null @@ -1,20 +0,0 @@ -on: - push: - branches: "**" - -jobs: - backend-pylint: - runs-on: docker - container: nikolaik/python-nodejs:python3.11-nodejs21 - steps: - - name: Checkout source code - uses: https://code.forgejo.org/actions/checkout@v3 - - name: Install packages - run: | - pip install -e . -q - python -m pip list --format=columns --disable-pip-version-check - pip install pylint~=2.17.7 --disable-pip-version-check -q - - name: Run pylint - run: | - pylint --version - pylint **/*.py --exit-zero --rc-file pyproject.toml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..88e754d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11 + +ARG PACKAGE_VERSION=0.1.0 +RUN pip install dockge-cli==${PACKAGE_VERSION} diff --git a/README.md b/README.md index 4c51468..2f946e3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,67 @@ # dockge-cli A simple CLI application written in Python for communicating with Dockge using websockets + +## Background + +Dockge (spoken dock-ge or dockage) is a tool to manage docker-compose stacks from a web ui. It is developed by louislam, who also develops UptimeKuma. + +Dockge itself doesn't offer any kind of API or programmatic access, as it is just intended for managing stacks via UI. + +My current deployment solution for firq.dev and fgo-ta.com is based on Dockge, and I was over it always having to reload the stack whenever I pushed an update. Instead, I wanted to have this as a separate CI step, automatically redeploying a givens stack. + +As Dockge is using a websocket-based system under the hood, it was easy to take a look at how communication occurs. In general, communication is achieved by leveraging socket.io for the data. Since Python already offers a solution for socket.io, it is just a matter of emulating the calls the webui sends and receives. + +In the end, this is the current result that works pretty well for my understanding. I am still trying to improve upon some issues (login times out, stability, features), but in general this works as a fine solution for automatic stack updating. + +## Installation + +Install it from the custom package index using + +```shell +pip install --extra-index-url https://forgejo.neshweb.net/api/packages/Firq/pypi/simple/ dockge-cli +``` + +Alternativly, install it using this repository. When installing for development, make sure to install with the additional dependencies + +```shell +pip install -e .[lint,typing] +``` + +## Usage + +Call the CLI using `dockge-cli` or `dockge`. + +```shell +usage: dockge_cli [-h] [--version] {host,login,logout,list,status,restart,start,stop,down,update,exit,help} + +CLI interface for interacting with Dockge + +positional arguments: + {host,login,logout,list,status,restart,start,stop,down,update,exit,help} + +options: + -h, --help show this help message and exit + --version show program's version number and exit +``` + +Help for each individual command can be invoked by calling `dockge-cli help <command>` + +## The magic behind this + +Generally, this makes use of the underlying Websockets API that the Dockge frontend uses to communicate with the server. By analyzing the traffic and looking into the codebase, I was able to reverse most of the packets that are being sent. This allows me to then contruct, send and receive my own packets, making the whole thing work. + +There are some things that need to be taken into account for this: For one, dockge uses socket.io for the websocket communication. This meant I had to find the corresponding socket.io version to get the correct version of python-socketio. In addition, I had to find out how the authorization mechanism behind this works. + +After finishing up the first prototype, the workings are as follows: + +1. A websocket session is established using socket.io - this happens automatically +2. After the session is ready, the `login` command is sent together with a provided username and password +3. Once the CLI is authorized, the selected command is sent +4. The CLI waits for any response values and exits once the command has executed successfully + +To provide a smooth experience, both the credentials and the remote host URI are stored on disk. just like the `docker` cli, the credentials are not encrypted, meaning it is advised to either clear the credentials after use OR to use the `--username` and `--password` parameters. This is especially recommended for CI applications. + +## Known issues + +This CLI does not work when Mullvad is used, as Mullvad actively blocks port forwarding (which python-socketio uses) diff --git a/dockge_cli/.gitignore b/dockge_cli/.gitignore deleted file mode 100644 index d36a0d4..0000000 --- a/dockge_cli/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.temp/* \ No newline at end of file diff --git a/dockge_cli/__main__.py b/dockge_cli/__main__.py new file mode 100644 index 0000000..660e368 --- /dev/null +++ b/dockge_cli/__main__.py @@ -0,0 +1,2 @@ +from .dockge_cli import cli +cli() diff --git a/dockge_cli/client/commandprovider/__init__.py b/dockge_cli/client/commandprovider/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dockge_cli/client/commandprovider/descriptors.py b/dockge_cli/client/commandprovider/descriptors.py deleted file mode 100644 index cd346fd..0000000 --- a/dockge_cli/client/commandprovider/descriptors.py +++ /dev/null @@ -1,88 +0,0 @@ -from .bindings import ExecutionCommands - -command_mappings = [ - { - "command": "host", - "description": "Sets and gets the URI of the dockge instance. Remove any unnecessary subdomains/protocols from the URI", - "args": 1, - "optional": True, - "binding": ExecutionCommands.host - }, - { - "command": "login", - "description": "Logs into a given dockge account, either with an interactive dialogue or by passing --user and --password", - "args": 2, - "optional": True, - "binding": ExecutionCommands.login - }, - { - "command": "logout", - "description": "Removes the credentials from the local storage.", - "args": 0, - "optional": False, - "binding": ExecutionCommands.logout - }, - { - "command": "list", - "description": "Lists all available stacks with their status", - "args": 0, - "optional": False, - "binding": ExecutionCommands.list - }, - { - "command": "status", - "description": "Returns the status of one stack", - "args": 1, - "optional": False, - "binding": ExecutionCommands.status - }, - { - "command": "restart", - "description": "Restarts a given stack", - "args": 1, - "optional": False, - "binding": ExecutionCommands.restart - }, - { - "command": "start", - "description": "Starts a given stack", - "args": 1, - "optional": False, - "binding": ExecutionCommands.start - }, - { - "command": "stop", - "description": "Stops a given stack", - "args": 1, - "optional": False, - "binding": ExecutionCommands.stop - }, - { - "command": "down", - "description": "Stop & Downs a given stack", - "args": 1, - "optional": False, - "binding": ExecutionCommands.down - }, - { - "command": "update", - "description": "Updates a stack", - "args": 1, - "optional": False, - "binding": ExecutionCommands.update - }, - { - "command": "exit", - "description": "Exits the CLI - this will reset all settings, including credentials and host", - "args": 0, - "optional": False, - "binding": ExecutionCommands.exit - }, - { - "command": "help", - "description": "Displays helping hints for commands", - "args": 1, - "optional": True, - "binding": ExecutionCommands.help - } -] diff --git a/dockge_cli/client/commandprovider/factory.py b/dockge_cli/client/commandprovider/factory.py deleted file mode 100644 index 4bfe95a..0000000 --- a/dockge_cli/client/commandprovider/factory.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import List, Callable -from pydantic import BaseModel -from .descriptors import command_mappings - -class Command(BaseModel): - command: str - description: str - args: int - optional: bool - binding: Callable - -commands: dict[str, Command] = {} -descriptors: List[dict[str, object]] = command_mappings - -for descriptor in descriptors: - c = Command(**descriptor) # type: ignore - commands.update({ c.command: c }) diff --git a/dockge_cli/client/commands/__init__.py b/dockge_cli/client/commands/__init__.py new file mode 100644 index 0000000..61eb6fc --- /dev/null +++ b/dockge_cli/client/commands/__init__.py @@ -0,0 +1,4 @@ +from ...models import Command +from .mappings import mapping + +commands: dict[str, Command] = { c.cmd: c for c in mapping } diff --git a/dockge_cli/client/commandprovider/bindings.py b/dockge_cli/client/commands/functions.py similarity index 50% rename from dockge_cli/client/commandprovider/bindings.py rename to dockge_cli/client/commands/functions.py index 6e813de..3ee695d 100644 --- a/dockge_cli/client/commandprovider/bindings.py +++ b/dockge_cli/client/commands/functions.py @@ -1,33 +1,50 @@ from urllib.parse import urlparse from getpass import getpass +import re -from ...models.parser import Credentials +from ...models import Credentials from ...service import storage -from ..utils import stack_formatter, status_formatter, generic_formatter, get_credential_parser -from ...service.communicate import DockgeConnection +from ...service.connection import DockgeConnection +from ..utils import stack_formatter, status_formatter, generic_formatter, credential_parser_factory -class ExecutionCommands(): +class FunctionBindings(): + """ + Helper class that provides all the static methods in an organized way + This is an abstraction layer of the CLI, as those functions only do little preprocessing before calling the actural DockgeConnection + """ @staticmethod def __setup(): + """ + Creates a connection and logs into Dockge + """ con = DockgeConnection() con.connect_and_login() return con @staticmethod def host(extra_args): + """ + host command binding + """ if len(extra_args) > 0: - res = urlparse(extra_args[0]) + mat = re.search(r"((\w+\.)?\w+\.\w+(\/.+)?)", extra_args[0], re.IGNORECASE) + if mat is None: + raise ValueError("Given host did not match regex") + res = urlparse(f"https://{mat[0]}") if all([res.scheme, res.netloc]): - host = extra_args[0].rstrip("/").replace("https://", "").replace("wss://", "") - storage.put("host", host) + storage.put("host", mat[0]) else: raise ValueError(f"Malformed URL {extra_args[0]}") print(storage.get("host")) @staticmethod def login(extra_args): + """ + login command binding + """ + print(f"WARNING! These credentials will be saved unencrypted in {storage._file.absolute()}") if len(extra_args) > 0: - credentials = get_credential_parser().parse_args(extra_args, namespace=Credentials) + credentials = credential_parser_factory().parse_args(extra_args, namespace=Credentials) storage.put("username", credentials.username, encoded=True) storage.put("password", credentials.password, encoded=True) return @@ -36,67 +53,85 @@ class ExecutionCommands(): @staticmethod def logout(_): + """ + logout command binding + """ storage.remove("username") storage.remove("password") @staticmethod def exit(_): + """ + exit command binding + """ storage.clear() @staticmethod def list(_): - con = ExecutionCommands.__setup() + """ + list command binding + """ + con = FunctionBindings.__setup() stack_formatter(con.list_stacks()) con.disconnect() @staticmethod def status(extra_args): - if extra_args is None: - raise ValueError - con = ExecutionCommands.__setup() + """ + status command binding + """ + con = FunctionBindings.__setup() status_formatter(con.list_stack(extra_args[0])) con.disconnect() @staticmethod def restart(extra_args): - if extra_args is None: - raise ValueError - con = ExecutionCommands.__setup() + """ + restart command binding + """ + con = FunctionBindings.__setup() generic_formatter(con.restart(extra_args[0])) con.disconnect() @staticmethod def update(extra_args): - if extra_args is None: - raise ValueError - con = ExecutionCommands.__setup() + """ + update command binding + """ + con = FunctionBindings.__setup() generic_formatter(con.update(extra_args[0])) con.disconnect() @staticmethod def stop(extra_args): - if extra_args is None: - raise ValueError - con = ExecutionCommands.__setup() + """ + stop command binding + """ + con = FunctionBindings.__setup() generic_formatter(con.stop(extra_args[0])) con.disconnect() @staticmethod def start(extra_args): - if extra_args is None: - raise ValueError - con = ExecutionCommands.__setup() + """ + start command binding + """ + con = FunctionBindings.__setup() generic_formatter(con.start(extra_args[0])) con.disconnect() @staticmethod def down(extra_args): - if extra_args is None: - raise ValueError - con = ExecutionCommands.__setup() + """ + down command binding + """ + con = FunctionBindings.__setup() generic_formatter(con.down(extra_args[0])) con.disconnect() @staticmethod def help(): + """ + exit command binding - This should never be invoked + """ print("WTF") diff --git a/dockge_cli/client/commands/mappings.py b/dockge_cli/client/commands/mappings.py new file mode 100644 index 0000000..a6ca52f --- /dev/null +++ b/dockge_cli/client/commands/mappings.py @@ -0,0 +1,90 @@ +from typing import List +from ...models import Command +from .functions import FunctionBindings + +mapping: List[Command] = [ + Command( + cmd="host", + description="Sets and gets the URI of the dockge instance. Remove any unnecessary subdomains/protocols from the URI", + args=1, + optional=True, + func=FunctionBindings.host + ), + Command( + cmd="login", + description="Logs into a given dockge account, either with an interactive dialogue or by passing --user and --password", + args=2, + optional=True, + func=FunctionBindings.login + ), + Command( + cmd="logout", + description="Removes the credentials from the local storage.", + args=0, + optional=False, + func=FunctionBindings.logout + ), + Command( + cmd="list", + description="Lists all available stacks with their status", + args=0, + optional=False, + func=FunctionBindings.list + ), + Command( + cmd="status", + description="Returns the status of one stack", + args=1, + optional=False, + func=FunctionBindings.status + ), + Command( + cmd="restart", + description="Restarts a given stack", + args=1, + optional=False, + func=FunctionBindings.restart + ), + Command( + cmd="start", + description="Starts a given stack", + args=1, + optional=False, + func=FunctionBindings.start + ), + Command( + cmd="stop", + description="Stops a given stack", + args=1, + optional=False, + func=FunctionBindings.stop + ), + Command( + cmd="down", + description="Stop & Downs a given stack", + args=1, + optional=False, + func=FunctionBindings.down + ), + Command( + cmd="update", + description="Updates a stack", + args=1, + optional=False, + func=FunctionBindings.update + ), + Command( + cmd="exit", + description="Exits the CLI - this will reset all settings, including credentials and host", + args=0, + optional=False, + func=FunctionBindings.exit + ), + Command( + cmd="help", + description="Displays helping hints for commands", + args=1, + optional=True, + func=FunctionBindings.help + ) +] diff --git a/dockge_cli/client/parser.py b/dockge_cli/client/parser.py index 2efbc30..f84628b 100644 --- a/dockge_cli/client/parser.py +++ b/dockge_cli/client/parser.py @@ -2,10 +2,14 @@ import argparse import sys from .. import __version__ -from ..models.parser import Arguments -from .commandprovider.factory import commands +from ..models import Arguments +from .commands import commands def parse_arguments(): + """ + Create a parser and parse the arguments of sys.argv based on the Arguments Namespace + Returns arguments and extra arguments separately + """ parser = argparse.ArgumentParser( prog="dockge_cli", description="CLI interface for interacting with Dockge",) diff --git a/dockge_cli/client/run.py b/dockge_cli/client/run.py index 105f380..22d0a2a 100644 --- a/dockge_cli/client/run.py +++ b/dockge_cli/client/run.py @@ -1,6 +1,9 @@ -from .commandprovider.factory import commands +from .commands import commands def display_help(extra_args): + """ + Display help dialogues for each command + """ if not extra_args: print(f"{commands['help'].description}") return @@ -11,6 +14,10 @@ def display_help(extra_args): print(f"{commands[extra_args[0]].description}") def run(command, args): + """ + Runs a given command with the provided args + Alsso automatically maps the given command string to the correct Command class + """ if command not in commands: raise ValueError("Invalid Command") @@ -25,4 +32,4 @@ def run(command, args): display_help(args) return - c.binding(args) + c.func(args) diff --git a/dockge_cli/client/utils.py b/dockge_cli/client/utils.py index 6e9fa87..ebddf26 100644 --- a/dockge_cli/client/utils.py +++ b/dockge_cli/client/utils.py @@ -2,7 +2,10 @@ import argparse from tabulate import tabulate from ..models import StackStatus -def get_credential_parser(): +def credential_parser_factory(): + """ + Creates a new parser for login credentials + """ credentialparser = argparse.ArgumentParser( prog="login", description="Subparser for login credentials provided by CI" @@ -12,21 +15,30 @@ def get_credential_parser(): return credentialparser def stack_formatter(stacks): + """ + Prints a given stack list formatted as a table + """ if not stacks["ok"]: raise RuntimeError("Stack GET didn't work") table, headers = [], ["Stackname", "Status"] for key, val in stacks["stackList"].items(): - table.append([key, StackStatus(val['status']).name]) + table.append([key, StackStatus(val['status']).name.lower()]) print(tabulate(table, headers=headers, tablefmt="github"), "\n") def status_formatter(status): + """ + Prints the status for a given stack + """ print(f"Is Stack Ok? {'Yes' if status['ok'] else 'No'}") headers = ["Container", "Status"] table = [[k, v] for k, v in status["serviceStatusList"].items()] print(tabulate(table, headers=headers, tablefmt="github"), "\n") def generic_formatter(status): + """ + Prints a generic dockge message + """ print(f"Is Ok? {'Yes' if status['ok'] else 'No'}") print(f"Stack status: {status['msg']}") diff --git a/dockge_cli/dockge_cli.py b/dockge_cli/dockge_cli.py index 25c437f..af77054 100644 --- a/dockge_cli/dockge_cli.py +++ b/dockge_cli/dockge_cli.py @@ -2,5 +2,8 @@ from .client.parser import parse_arguments from .client.run import run def cli(): + """ + main function for cli invocation + """ command, args= parse_arguments() run(command.command, args) diff --git a/dockge_cli/models/__init__.py b/dockge_cli/models/__init__.py index ef15c9b..ac7af80 100644 --- a/dockge_cli/models/__init__.py +++ b/dockge_cli/models/__init__.py @@ -1 +1,3 @@ from .codes import StackStatus +from .command import Command +from .parser import Arguments, Credentials diff --git a/dockge_cli/models/codes.py b/dockge_cli/models/codes.py index b8f9a07..569ce1d 100644 --- a/dockge_cli/models/codes.py +++ b/dockge_cli/models/codes.py @@ -1,7 +1,9 @@ from enum import Enum class StackStatus(Enum): - # pylint: disable=invalid-name - inactive = 1 - running = 3 - exited = 4 + """ + mapping for plaintext vs statuscode + """ + INACTIVE = 1 + RUNNING = 3 + EXITED = 4 diff --git a/dockge_cli/models/command.py b/dockge_cli/models/command.py new file mode 100644 index 0000000..61a13b3 --- /dev/null +++ b/dockge_cli/models/command.py @@ -0,0 +1,12 @@ +from typing import Callable +from pydantic import BaseModel + +class Command(BaseModel): + """ + Basic command structure for the CLI to automatically generate valid commands + """ + cmd: str + func: Callable + args: int + optional: bool + description: str diff --git a/dockge_cli/models/parser.py b/dockge_cli/models/parser.py index c836e62..e590ce9 100644 --- a/dockge_cli/models/parser.py +++ b/dockge_cli/models/parser.py @@ -2,9 +2,15 @@ import argparse # pylint: disable=too-few-public-methods class Arguments(argparse.Namespace): + """ + Default Arguments when calling the CLI + """ command: str # pylint: disable=too-few-public-methods class Credentials(argparse.Namespace): + """ + Special Argument Namespace for login credentials of the login commands + """ username: str password: str diff --git a/dockge_cli/service/.gitignore b/dockge_cli/service/.gitignore new file mode 100644 index 0000000..9e62042 --- /dev/null +++ b/dockge_cli/service/.gitignore @@ -0,0 +1 @@ +.storage/* \ No newline at end of file diff --git a/dockge_cli/service/communicate.py b/dockge_cli/service/connection.py similarity index 69% rename from dockge_cli/service/communicate.py rename to dockge_cli/service/connection.py index 4eb645d..78e7582 100644 --- a/dockge_cli/service/communicate.py +++ b/dockge_cli/service/connection.py @@ -5,8 +5,14 @@ import socketio.exceptions from . import storage class DockgeConnection: + """ + Provider class for Dockge + Provides all the functionality for connecting, logging in and executing commands + """ class LoginException(BaseException): - pass + """ + Special exception when login fails too often + """ _sio: socketio.Client _host: str @@ -26,7 +32,7 @@ class DockgeConnection: def _init_events(self): @self._sio.event def connect(): - self.connect() + self.login() print("Connected!") @self._sio.event @@ -57,20 +63,34 @@ class DockgeConnection: success = True else: print("Issue with login procedure") + print(data) return success # Functions def connect_and_login(self): - self._sio.connect(f"https://{self._host}/socket.io/", transports=['websocket']) - self.connect() + """ + Connect to the websocket + """ + # Dockge uses Socket.io for the websockets, so this URI and params are always the same + self._sio.connect(f"https://{self._host}/socket.io/") + self.login() - def connect(self): + def login(self): + """ + Log into dockge using basicauth + Retries 5 times when timeouts occur + """ if self._logged_in: return data = None retry, count = True, 0 + if not storage.exists("username"): + raise ValueError("Missing username") + if not storage.exists("password"): + raise ValueError("Missing password") + while retry and count < 5: try: data = self._sio.call( @@ -78,12 +98,13 @@ class DockgeConnection: { "username": storage.get("username", encoded=True), "password": storage.get("password", encoded=True), - "token":"" + "token": "" }, - timeout=5 + timeout=10 ) retry = False except socketio.exceptions.TimeoutError: + print("Reached timeout for login, retrying ...") retry = True count += 1 @@ -92,6 +113,9 @@ class DockgeConnection: self._logged_in = True def list_stacks(self): + """ + Requests stack list from dockge, returns list when event was sent + """ self._sio.emit("agent", ("", "requestStackList")) while self._stacklist is None: time.sleep(0.5) @@ -100,29 +124,50 @@ class DockgeConnection: return retval def list_stack(self, name: str): - ret = self._sio.call("agent", ("", "serviceStatusList", name), timeout=5) + """ + Lists status for a stack + """ + ret = self._sio.call("agent", ("", "serviceStatusList", name), timeout=10) return ret def restart(self, name): - ret = self._sio.call("agent", ("", "restartStack", name), timeout=10) + """ + Restarts a given stack + """ + ret = self._sio.call("agent", ("", "restartStack", name), timeout=30) return ret def update(self, name): - ret = self._sio.call("agent", ("", "updateStack", name), timeout=10) + """ + Updates a given stack + """ + ret = self._sio.call("agent", ("", "updateStack", name), timeout=30) return ret def stop(self, name): - ret = self._sio.call("agent", ("", "stopStack", name), timeout=10) + """ + Stops a given stack + """ + ret = self._sio.call("agent", ("", "stopStack", name), timeout=30) return ret def start(self, name): - ret = self._sio.call("agent", ("", "startStack", name), timeout=10) + """ + Starts a given stack + """ + ret = self._sio.call("agent", ("", "startStack", name), timeout=30) return ret def down(self, name): - ret = self._sio.call("agent", ("", "downStack", name), timeout=10) + """ + Stops and downs a given stack + """ + ret = self._sio.call("agent", ("", "downStack", name), timeout=30) return ret def disconnect(self): + """ + Logs out of dockge + """ self._sio.emit("logout") self._sio.disconnect() diff --git a/dockge_cli/service/storage.py b/dockge_cli/service/storage.py index 0a3a0f8..3edf415 100644 --- a/dockge_cli/service/storage.py +++ b/dockge_cli/service/storage.py @@ -3,42 +3,82 @@ import pathlib import base64 import yaml -_storagepath = pathlib.Path(__file__).parents[1] / ".temp" +_storagepath = pathlib.Path(__file__).parent / ".storage" +_storagepath.mkdir(exist_ok=True, parents=True) _file = _storagepath / "storage.yaml" -_storagepath.mkdir(exist_ok=True, parents=True) +def create_file_when_missing(): + """ + Checks if storage file does exist, creates it when necessary + """ + if _file.exists(): + return + with open(_file, 'a', encoding="utf-8"): + os.utime(_file, None) -def fileexists(): +def exists(key: str) -> bool: + """ + Checks if a given key exists in the storage file + """ if not _file.exists(): - with open(_file, 'a', encoding="utf-8"): - os.utime(_file, None) + return False + + with open(_file, "r", encoding="utf-8") as file: + content: dict[str, str] = yaml.load(file, Loader=yaml.SafeLoader) + + return key in content + def put(key: str, value: str, encoded=False): - fileexists() - with open(_file, "r+", encoding="utf-8") as file: + """ + Puts a given value with a given key into the storage file + Encodes the data as base64 when encoded is set to true + """ + if not _file.exists(): + create_file_when_missing() + + with open(_file, "r", encoding="utf-8") as file: content: dict[str, str] = yaml.load(file, Loader=yaml.SafeLoader) or {} - content.update({ key: str(base64.b64encode(value.encode())) if encoded else value }) + content.update({ key: str(base64.b64encode(value.encode()), "utf-8") if encoded else value }) + with open(_file, "w+", encoding="utf-8") as file: yaml.dump(content, file, Dumper=yaml.SafeDumper) def remove(key: str): - fileexists() + """ + Removed a given key from the storage file + """ + if not _file.exists(): + create_file_when_missing() + with open(_file, "r", encoding="utf-8") as file: content: dict[str, str] = yaml.load(file, Loader=yaml.SafeLoader) or {} content.pop(key, None) + with open(_file, "w+", encoding="utf-8") as file: yaml.dump(content, file, Dumper=yaml.SafeDumper) def get(key: str, encoded=False): + """ + Retrieves a value for a given key from the storage file + If the value was encoded, encoded needs to be set True to decode it again + """ value: str | None = None if not _file.exists(): return None + with open(_file, "r", encoding="utf-8") as file: content: dict[str, str] = yaml.load(file, Loader=yaml.SafeLoader) value = content.get(key, None) + if value is None: return None - return base64.b64decode(value).decode() if encoded else value + return base64.b64decode(value.encode()).decode() if encoded else value def clear(): + """ + Deletes the storage file + """ + if not _file.exists(): + return _file.unlink() diff --git a/pyproject.toml b/pyproject.toml index 50b898b..b706b37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,10 @@ [project] name = "dockge_cli" -version = "0.0.1-c.1" +version = "0.1.2" dependencies = [ "pyyaml~=6.0.1", "pydantic~=2.8.0", + "requests~=2.32.3", "python-socketio~=5.11.3", "websocket-client~=1.8.0", "tabulate ~=0.9.0", @@ -11,7 +12,7 @@ dependencies = [ requires-python = ">= 3.10" authors = [{name = "Firq", email = "firelp42@gmail.com"}] maintainers = [{name = "Firq", email = "firelp42@gmail.com"}] -description = "CLi for interacting with dockge" +description = "CLI for interacting with dockge" classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", @@ -31,8 +32,30 @@ include = ["dockge_cli*"] [tool.setuptools.package-data] "*" = ["py.typed"] +[project.optional-dependencies] +lint = [ + "pylint~=3.2.5", +] +typing = [ + "mypy~=1.10.1", + "types-PyYAML~=6.0.12.20240311", + "types-tabulate~=0.9.0.20240106", +] + [tool.pylint."MAIN"] -disable = [ "line-too-long", "missing-module-docstring", "missing-function-docstring", "missing-class-docstring" ] +disable = [ + "line-too-long", + "missing-module-docstring", +] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true + +[[tool.mypy.overrides]] +module = 'socketio.*' +ignore_missing_imports = true [build-system] requires = ["setuptools >= 61.0"]