Skip to content

Commit

Permalink
Merge pull request #17 from jonykalavera/dokli-1
Browse files Browse the repository at this point in the history
dokli-1: add api_key_cmd. add tests for config.
  • Loading branch information
jonykalavera authored Feb 18, 2025
2 parents 049c37d + e50889a commit 9bdec3c
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 18 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
test:
pytest -vv --cov dokli
pytest -vv --cov dokli --blockage

format:
ruff format dokli/
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,19 @@ Create the configuration file at `~/.config/dokli/dokli.yaml`. Example:
```yaml
connections:
- name: test-env
url: https://test.examle.com
url: https://test.example.com
api_key: ****************************************
notes: "Our test environment. Handle with care!"
- name: prod-env
url: https://prod.examle.com
api_key: ****************************************
url: https://prod.example.com
api_key_cmd: "secret-tool lookup dokli prodEnvApikey"
notes: "Our prod environment. Handle with even more care!"
```
You can use `api_key_cmd` to load the API key from a command such as [secret-tool](https://manpages.org/secret-tool) instead of entering it in the config file. This is highly recommended for security reasons.

Configuration uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) which means it can also be set via [environment variables](https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values) using the `DOKLI_` prefix.

## CLI

### Features
Expand All @@ -90,7 +94,7 @@ connections:
$ dokly
Usage: python -m dokli [OPTIONS] COMMAND [ARGS]...
Usage: dokli [OPTIONS] COMMAND [ARGS]...
Magical Dokploy CLI/TUI.
Expand Down
2 changes: 1 addition & 1 deletion dokli/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def __init__(self, connection: ConnectionConfig, cache_path: str | None = None,
self.cache_path = Path(cache_path) if cache_path else Path.home() / ".config/dokli/cache"
self.base_url = f"{_base_url}/api/"
self.headers = {
"Authorization": f"Bearer {connection.api_key.get_secret_value()}",
"Authorization": f"Bearer {connection.get_api_key()}",
"accept": "application/json",
}
self.session = httpx.Client(verify=False, follow_redirects=True)
Expand Down
26 changes: 22 additions & 4 deletions dokli/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Configuration model."""

import json
import subprocess
from typing import Any

from pydantic import BaseModel, Field, HttpUrl, SecretStr, field_serializer
from pydantic import BaseModel, Field, HttpUrl, SecretStr, field_serializer, model_validator
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, YamlConfigSettingsSource


Expand All @@ -18,12 +19,13 @@ class ConnectionConfig(BaseModel):
description="A name for the connection.",
)
url: HttpUrl = Field(..., description="The URL of the dokploy instance.")
api_key: SecretStr = Field(
...,
api_key: SecretStr | None = Field(
None,
min_length=40,
max_length=40,
description="The API key for the dokploy instance.",
description="An API key for the dokploy instance.",
)
api_key_cmd: str | None = Field(None, description="A command to get the API key.")
notes: str = Field(default="", description="Notes about the connection.")

@field_serializer("api_key", when_used="json")
Expand All @@ -35,6 +37,22 @@ def model_dump_clear(self, **kwargs) -> dict[str, Any]:
"""Allows dumping the config with clear secrets."""
return json.loads(self.model_dump_json(**kwargs))

@model_validator(mode="after")
def check_api_key_or_cmd(self) -> "ConnectionConfig":
"""Validate api_key or api_key_cmd is provided."""
if not self.api_key and not self.api_key_cmd:
raise ValueError("Must provide api_key or api_key_cmd.")
return self

def get_api_key(self) -> str:
"""Returns the API key for the connection."""
if self.api_key is not None:
return self.api_key.get_secret_value()
assert self.api_key_cmd, "Must provide api_key or api_key_cmd."
raw_output = subprocess.check_output(self.api_key_cmd.split())
output = raw_output.decode("utf-8").strip().strip("\n")
return output


class Config(BaseSettings):
"""Dokli config."""
Expand Down
30 changes: 30 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,35 @@ convention = "google"
[tool.ruff.lint.isort]
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]

#############################################################################
# COVERAGE SETTINGS #
##############################################################################
[tool.coverage.run]
branch = true
omit = [
"*tests.py",
"*test_*.py",
"*_tests.py",
]
source = [
"dokli/"
]

[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"logger = logging.getLogger.__name__.",
"[A-Z_][A-Z0-9_]* = .*",
"from .* import .*",
"import .*",
"if __name__ == .__main__.:",
"collections.namedtuple"
]
skip_covered = true
ignore_errors = true
show_missing = true

##############################################################################
# POETRY SETTINGS #
##############################################################################
Expand Down Expand Up @@ -86,6 +115,7 @@ datamodel-code-generator = "^0.25.8"
pytest-mock = "^3.14.0"
pytest-randomly = "^3.16.0"
polyfactory = "^2.19.0"
pytest-blockage = "^0.2.4"

[tool.poetry.extras]
tui = ["textual"]
Expand Down
12 changes: 4 additions & 8 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
"""Dokli CLI tests."""

import pytest
from dokli.cli import app, main

from dokli.cli import app, state


@pytest.mark.skip
def test_loads_api_commands(mocker):
"""We expect the CLI to load API commands."""
register_connections = mocker.patch("dokli.cli.register_connections", return_value=None)

register_connections.assert_called_once_with(app, state["config"])
assert app.registered_groups[0].typer_instance.info.name == "api"


def test_loads_tui_command():
"""We expect the CLI to load TUI command."""
assert app.registered_commands[-1].name == "tui"
assert "tui" in [cmd.name for cmd in app.registered_commands]


def test_app_has_main_callback():
"""We expect the CLI to have a main callback."""
assert app.registered_callback.callback is main
assert app.registered_callback.no_args_is_help
63 changes: 63 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Config tests."""

import pytest
from polyfactory.factories.pydantic_factory import ModelFactory
from pydantic import ValidationError

from dokli.config import Config, ConnectionConfig


class ConnectionConfigFactory(ModelFactory[ConnectionConfig]):
"""Connection factory."""

api_key = None
api_key_cmd = "echo 'my api key from cmd'"


class ConfigFactory(ModelFactory[Config]):
"""Config factory."""

connections = [ConnectionConfigFactory.build()]


class TestConfig:
"""Config model tests."""

def test_config(self):
"""We expect to be able to declare an api with connections."""
config = ConfigFactory.build()
assert config.connections

def test_get_connection(self):
"""We expect to be able to get a connection by name."""
connection = ConnectionConfigFactory.build(name="dokploy")
config = ConfigFactory.build(connections=[connection])
assert config.get_connection("dokploy")


class TestConnectionConfig:
"""Connection config model tests."""

def test_must_provide_api_key_or_cmd(self):
"""We expect to raise an error if no api key or cmd is provided."""
with pytest.raises(ValidationError):
ConnectionConfig(name="dokli", url="https://dokli.example.com")

def test_connection_with_api_key(self):
"""We expect to be able to declare a connection with an API key."""
config = ConnectionConfigFactory.build(api_key="*" * 40)
assert config.get_api_key() == config.api_key.get_secret_value()

def test_connection_with_api_key_cmd(self, mocker):
"""We expect to be able to declare a connection with an API key command."""
config = ConnectionConfigFactory.build()
assert config.api_key is None
check_output = mocker.patch("dokli.config.subprocess.check_output", return_value=b"my api key from cmd")
assert config.get_api_key() == "my api key from cmd"
check_output.assert_called_once_with(config.api_key_cmd.split())

def test_model_dump_clear_prints_clear_secrets(self):
"""We expect to be able to dump the config with clear secrets."""
config = ConnectionConfigFactory.build(api_key="*" * 40)
result = config.model_dump_clear()
assert result["api_key"] == "*" * 40

0 comments on commit 9bdec3c

Please sign in to comment.