Compare commits
21 commits
Author | SHA1 | Date | |
---|---|---|---|
ac982668c7 | |||
ed24d59a92 | |||
401e446059 | |||
fc7ecdcd77 | |||
09da354b0e | |||
de234133f9 | |||
ca5803bccc | |||
003204afb1 | |||
44a0409798 | |||
34cb6e5dc7 | |||
360aca36c5 | |||
f5ffe07e77 | |||
5628e4f062 | |||
c00aa232fc | |||
58aea47921 | |||
d79a01cfb1 | |||
68f05a0c0c | |||
a99b81b0c7 | |||
be932ea9c4 | |||
f309a19d5e | |||
e44fd8a7a5 |
25 changed files with 494 additions and 193 deletions
.forgejo/workflows
DockerfileREADME.mddockge_cli
pyproject.toml
|
@ -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 }}
|
34
.forgejo/workflows/check.yaml
Normal file
34
.forgejo/workflows/check.yaml
Normal file
|
@ -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 .
|
|
@ -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
|
4
Dockerfile
Normal file
4
Dockerfile
Normal file
|
@ -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}
|
64
README.md
64
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)
|
||||
|
|
1
dockge_cli/.gitignore
vendored
1
dockge_cli/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
.temp/*
|
2
dockge_cli/__main__.py
Normal file
2
dockge_cli/__main__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .dockge_cli import cli
|
||||
cli()
|
|
@ -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
|
||||
}
|
||||
]
|
|
@ -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 })
|
4
dockge_cli/client/commands/__init__.py
Normal file
4
dockge_cli/client/commands/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from ...models import Command
|
||||
from .mappings import mapping
|
||||
|
||||
commands: dict[str, Command] = { c.cmd: c for c in mapping }
|
|
@ -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")
|
90
dockge_cli/client/commands/mappings.py
Normal file
90
dockge_cli/client/commands/mappings.py
Normal file
|
@ -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
|
||||
)
|
||||
]
|
|
@ -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",)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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']}")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
from .codes import StackStatus
|
||||
from .command import Command
|
||||
from .parser import Arguments, Credentials
|
||||
|
|
|
@ -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
|
||||
|
|
12
dockge_cli/models/command.py
Normal file
12
dockge_cli/models/command.py
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
1
dockge_cli/service/.gitignore
vendored
Normal file
1
dockge_cli/service/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.storage/*
|
|
@ -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()
|
|
@ -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()
|
||||
|
|
|
@ -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"]
|
||||
|
|
Loading…
Add table
Reference in a new issue