smanager 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smanager-0.1.0/PKG-INFO +6 -0
- smanager-0.1.0/README.md +10 -0
- smanager-0.1.0/pyproject.toml +18 -0
- smanager-0.1.0/setup.cfg +4 -0
- smanager-0.1.0/smanager/__init__.py +13 -0
- smanager-0.1.0/smanager/cli/__init__.py +0 -0
- smanager-0.1.0/smanager/cli/commands/__init__.py +10 -0
- smanager-0.1.0/smanager/cli/commands/all.py +35 -0
- smanager-0.1.0/smanager/cli/commands/config.py +61 -0
- smanager-0.1.0/smanager/cli/commands/dependency.py +24 -0
- smanager-0.1.0/smanager/cli/commands/group.py +24 -0
- smanager-0.1.0/smanager/cli/commands/service.py +24 -0
- smanager-0.1.0/smanager/cli/commands/status.py +85 -0
- smanager-0.1.0/smanager/cli/config.py +75 -0
- smanager-0.1.0/smanager/cli/main.py +30 -0
- smanager-0.1.0/smanager/crawler/__init__.py +5 -0
- smanager-0.1.0/smanager/crawler/sd_finder.py +32 -0
- smanager-0.1.0/smanager/datatypes/__init__.py +19 -0
- smanager-0.1.0/smanager/datatypes/config.py +20 -0
- smanager-0.1.0/smanager/datatypes/sd_info_v2.py +134 -0
- smanager-0.1.0/smanager/datatypes/service_status.py +16 -0
- smanager-0.1.0/smanager/global_manager/__init__.py +7 -0
- smanager-0.1.0/smanager/global_manager/base_global_manager.py +81 -0
- smanager-0.1.0/smanager/global_manager/global_manager.py +103 -0
- smanager-0.1.0/smanager/service_managers/__init__.py +11 -0
- smanager-0.1.0/smanager/service_managers/base_manager.py +62 -0
- smanager-0.1.0/smanager/service_managers/base_sdv2_manager.py +45 -0
- smanager-0.1.0/smanager/service_managers/docker_manager.py +63 -0
- smanager-0.1.0/smanager/service_managers/local_manager.py +44 -0
- smanager-0.1.0/smanager/vpn/__init__.py +11 -0
- smanager-0.1.0/smanager/vpn/base_vpn.py +32 -0
- smanager-0.1.0/smanager/vpn/providers/__init__.py +5 -0
- smanager-0.1.0/smanager/vpn/providers/tailscale.py +51 -0
- smanager-0.1.0/smanager/vpn/registry.py +28 -0
- smanager-0.1.0/smanager.egg-info/PKG-INFO +6 -0
- smanager-0.1.0/smanager.egg-info/SOURCES.txt +38 -0
- smanager-0.1.0/smanager.egg-info/dependency_links.txt +1 -0
- smanager-0.1.0/smanager.egg-info/entry_points.txt +2 -0
- smanager-0.1.0/smanager.egg-info/requires.txt +2 -0
- smanager-0.1.0/smanager.egg-info/top_level.txt +1 -0
smanager-0.1.0/PKG-INFO
ADDED
smanager-0.1.0/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SManager
|
|
2
|
+
> A simple server manager for self-hosted services, with VPN integration and support for both local and Docker-based services.
|
|
3
|
+
|
|
4
|
+
## Features
|
|
5
|
+
- Manage local services and Docker containers with a unified interface.
|
|
6
|
+
- Integrate with Tailscale VPN for secure remote access.
|
|
7
|
+
- Monitor service status and logs in real-time.
|
|
8
|
+
- Easy configuration using YAML files.
|
|
9
|
+
- Extensible architecture for adding new service types and VPN providers.
|
|
10
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "smanager"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
requires-python = ">=3.9"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"PyYAML>=6.0",
|
|
11
|
+
"click>=8.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.setuptools.dynamic]
|
|
15
|
+
version = {attr = "smanager.__version__"}
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
smanager = "smanager.cli.main:cli"
|
smanager-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .global_manager import GlobalManager
|
|
4
|
+
from .datatypes import GlobalConfig, Service, ServiceStatus, MultiStatus
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"__version__",
|
|
8
|
+
"GlobalManager",
|
|
9
|
+
"GlobalConfig",
|
|
10
|
+
"Service",
|
|
11
|
+
"ServiceStatus",
|
|
12
|
+
"MultiStatus",
|
|
13
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ACTIONS = ("start", "stop", "restart", "enable", "disable")
|
|
2
|
+
|
|
3
|
+
from .service import service_cmd
|
|
4
|
+
from .group import group_cmd
|
|
5
|
+
from .dependency import dependency_cmd
|
|
6
|
+
from .all import all_cmd
|
|
7
|
+
from .status import status_cmd
|
|
8
|
+
from .config import config_cmd
|
|
9
|
+
|
|
10
|
+
__all__ = ["ACTIONS", "service_cmd", "group_cmd", "dependency_cmd", "all_cmd", "status_cmd", "config_cmd"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from . import ACTIONS
|
|
4
|
+
from ..config import get_manager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.command("all")
|
|
8
|
+
@click.argument("action", type=click.Choice(ACTIONS, case_sensitive=False))
|
|
9
|
+
@click.option(
|
|
10
|
+
"--yes",
|
|
11
|
+
is_flag=True,
|
|
12
|
+
default=False,
|
|
13
|
+
help="Confirm the operation on all services.",
|
|
14
|
+
)
|
|
15
|
+
@click.pass_context
|
|
16
|
+
def all_cmd(ctx: click.Context, action: str, yes: bool) -> None:
|
|
17
|
+
"""Perform ACTION on ALL managed services.
|
|
18
|
+
|
|
19
|
+
\b
|
|
20
|
+
Actions: start, stop, restart, enable, disable
|
|
21
|
+
|
|
22
|
+
Requires --yes to prevent accidental execution.
|
|
23
|
+
"""
|
|
24
|
+
if not yes:
|
|
25
|
+
raise click.UsageError(
|
|
26
|
+
f"This will run '{action}' on ALL services. Pass --yes to confirm."
|
|
27
|
+
)
|
|
28
|
+
manager = get_manager(ctx)
|
|
29
|
+
try:
|
|
30
|
+
manager.manage_all(action)
|
|
31
|
+
click.echo(f"[OK] '{action}' on all services.")
|
|
32
|
+
except ValueError as e:
|
|
33
|
+
raise click.ClickException(str(e))
|
|
34
|
+
except RuntimeError as e:
|
|
35
|
+
raise click.ClickException(str(e))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ..config import DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, resolve_config_path
|
|
6
|
+
|
|
7
|
+
_TEMPLATE = """\
|
|
8
|
+
global:
|
|
9
|
+
# Absolute path to the directory where service sd_info.json files live.
|
|
10
|
+
services_dir: "/path/to/your/services"
|
|
11
|
+
|
|
12
|
+
# VPN provider to use. Must match a registered provider (e.g. "tailscale").
|
|
13
|
+
vpn_provider: "tailscale"
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group("config")
|
|
18
|
+
def config_cmd() -> None:
|
|
19
|
+
"""Manage the smanager configuration file."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@config_cmd.command("init")
|
|
23
|
+
@click.option("--force", is_flag=True, help="Overwrite existing config.")
|
|
24
|
+
def init_cmd(force: bool) -> None:
|
|
25
|
+
"""Create a template config at the default location.
|
|
26
|
+
|
|
27
|
+
\b
|
|
28
|
+
Default: ~/.config/smanager/smanager.yaml
|
|
29
|
+
"""
|
|
30
|
+
if os.path.exists(DEFAULT_CONFIG_PATH) and not force:
|
|
31
|
+
raise click.ClickException(
|
|
32
|
+
f"Config already exists at {DEFAULT_CONFIG_PATH}. "
|
|
33
|
+
"Use --force to overwrite."
|
|
34
|
+
)
|
|
35
|
+
os.makedirs(DEFAULT_CONFIG_DIR, exist_ok=True)
|
|
36
|
+
with open(DEFAULT_CONFIG_PATH, "w") as f:
|
|
37
|
+
f.write(_TEMPLATE)
|
|
38
|
+
click.echo(f"Config created: {DEFAULT_CONFIG_PATH}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@config_cmd.command("edit")
|
|
42
|
+
@click.pass_context
|
|
43
|
+
def edit_cmd(ctx: click.Context) -> None:
|
|
44
|
+
"""Open the config file in $EDITOR (falls back to nano).
|
|
45
|
+
|
|
46
|
+
\b
|
|
47
|
+
Resolves the config path in the same order as all other commands:
|
|
48
|
+
1. --config PATH
|
|
49
|
+
2. $SMANAGER_CONFIG
|
|
50
|
+
3. ~/.config/smanager/smanager.yaml
|
|
51
|
+
4. /etc/smanager/smanager.yaml
|
|
52
|
+
5. ./smanager.yaml
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
path = resolve_config_path(ctx.obj.get("config_path"))
|
|
56
|
+
except click.ClickException:
|
|
57
|
+
raise click.ClickException(
|
|
58
|
+
"No config file found. Run 'smanager config init' first."
|
|
59
|
+
)
|
|
60
|
+
editor = os.environ.get("EDITOR", "nano")
|
|
61
|
+
click.edit(filename=path, editor=editor)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from . import ACTIONS
|
|
4
|
+
from ..config import get_manager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.command()
|
|
8
|
+
@click.argument("action", type=click.Choice(ACTIONS, case_sensitive=False))
|
|
9
|
+
@click.argument("dependency_name")
|
|
10
|
+
@click.pass_context
|
|
11
|
+
def dependency_cmd(ctx: click.Context, action: str, dependency_name: str) -> None:
|
|
12
|
+
"""Perform ACTION on all services that depend on DEPENDENCY_NAME.
|
|
13
|
+
|
|
14
|
+
\b
|
|
15
|
+
Actions: start, stop, restart, enable, disable
|
|
16
|
+
"""
|
|
17
|
+
manager = get_manager(ctx)
|
|
18
|
+
try:
|
|
19
|
+
manager.manage_by_dependency(dependency_name, action)
|
|
20
|
+
click.echo(f"[OK] '{action}' on services depending on '{dependency_name}'.")
|
|
21
|
+
except ValueError as e:
|
|
22
|
+
raise click.ClickException(str(e))
|
|
23
|
+
except RuntimeError as e:
|
|
24
|
+
raise click.ClickException(str(e))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from . import ACTIONS
|
|
4
|
+
from ..config import get_manager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.command()
|
|
8
|
+
@click.argument("action", type=click.Choice(ACTIONS, case_sensitive=False))
|
|
9
|
+
@click.argument("group_name")
|
|
10
|
+
@click.pass_context
|
|
11
|
+
def group_cmd(ctx: click.Context, action: str, group_name: str) -> None:
|
|
12
|
+
"""Perform ACTION on all services in GROUP_NAME.
|
|
13
|
+
|
|
14
|
+
\b
|
|
15
|
+
Actions: start, stop, restart, enable, disable
|
|
16
|
+
"""
|
|
17
|
+
manager = get_manager(ctx)
|
|
18
|
+
try:
|
|
19
|
+
manager.manage_group(group_name, action)
|
|
20
|
+
click.echo(f"[OK] '{action}' on group '{group_name}'.")
|
|
21
|
+
except ValueError as e:
|
|
22
|
+
raise click.ClickException(str(e))
|
|
23
|
+
except RuntimeError as e:
|
|
24
|
+
raise click.ClickException(str(e))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from . import ACTIONS
|
|
4
|
+
from ..config import get_manager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.command()
|
|
8
|
+
@click.argument("action", type=click.Choice(ACTIONS, case_sensitive=False))
|
|
9
|
+
@click.argument("service_id")
|
|
10
|
+
@click.pass_context
|
|
11
|
+
def service_cmd(ctx: click.Context, action: str, service_id: str) -> None:
|
|
12
|
+
"""Perform ACTION on a single service.
|
|
13
|
+
|
|
14
|
+
\b
|
|
15
|
+
Actions: start, stop, restart, enable, disable
|
|
16
|
+
"""
|
|
17
|
+
manager = get_manager(ctx)
|
|
18
|
+
try:
|
|
19
|
+
manager.manage_service(service_id, action)
|
|
20
|
+
click.echo(f"[OK] '{action}' on service '{service_id}'.")
|
|
21
|
+
except ValueError as e:
|
|
22
|
+
raise click.ClickException(str(e))
|
|
23
|
+
except RuntimeError as e:
|
|
24
|
+
raise click.ClickException(str(e))
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import json as json_module
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from ..config import get_manager
|
|
7
|
+
from ...datatypes import MultiStatus, ServiceStatus
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _print_service_status(status: ServiceStatus) -> None:
|
|
11
|
+
click.echo(f"Service: {status.id} [{status.type}]")
|
|
12
|
+
for s in status.statuses:
|
|
13
|
+
marker = "●" if s.status in ("running", "active") else "○"
|
|
14
|
+
click.echo(f" {marker} {s.name}: {s.status}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _print_multi_status(multi: MultiStatus) -> None:
|
|
18
|
+
for i, svc in enumerate(multi.services):
|
|
19
|
+
if i > 0:
|
|
20
|
+
click.echo("")
|
|
21
|
+
_print_service_status(svc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command()
|
|
25
|
+
@click.option("--service", "service_id", default=None, metavar="ID",
|
|
26
|
+
help="Status of a single service.")
|
|
27
|
+
@click.option("--group", "group_name", default=None, metavar="NAME",
|
|
28
|
+
help="Status of all services in a group.")
|
|
29
|
+
@click.option("--dependency", "dependency_name", default=None, metavar="NAME",
|
|
30
|
+
help="Status of services by dependency.")
|
|
31
|
+
@click.option("--all", "all_services", is_flag=True, default=False,
|
|
32
|
+
help="Status of all services.")
|
|
33
|
+
@click.option("--json", "as_json", is_flag=True, default=False,
|
|
34
|
+
help="Output as JSON.")
|
|
35
|
+
@click.pass_context
|
|
36
|
+
def status_cmd(
|
|
37
|
+
ctx: click.Context,
|
|
38
|
+
service_id: str | None,
|
|
39
|
+
group_name: str | None,
|
|
40
|
+
dependency_name: str | None,
|
|
41
|
+
all_services: bool,
|
|
42
|
+
as_json: bool,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Show the status of services.
|
|
45
|
+
|
|
46
|
+
\b
|
|
47
|
+
Provide exactly one scope:
|
|
48
|
+
--service ID single service
|
|
49
|
+
--group NAME all services in a group
|
|
50
|
+
--dependency NAME all services by dependency
|
|
51
|
+
--all every managed service
|
|
52
|
+
"""
|
|
53
|
+
scopes = [x for x in [service_id, group_name, dependency_name] if x] + (
|
|
54
|
+
[True] if all_services else []
|
|
55
|
+
)
|
|
56
|
+
if len(scopes) == 0:
|
|
57
|
+
raise click.UsageError(
|
|
58
|
+
"Provide one of: --service, --group, --dependency, --all"
|
|
59
|
+
)
|
|
60
|
+
if len(scopes) > 1:
|
|
61
|
+
raise click.UsageError(
|
|
62
|
+
"Only one of --service, --group, --dependency, --all can be used at a time."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
manager = get_manager(ctx)
|
|
66
|
+
try:
|
|
67
|
+
if service_id:
|
|
68
|
+
result = manager.get_status(service_id)
|
|
69
|
+
if as_json:
|
|
70
|
+
click.echo(json_module.dumps(dataclasses.asdict(result), indent=2))
|
|
71
|
+
else:
|
|
72
|
+
_print_service_status(result)
|
|
73
|
+
else:
|
|
74
|
+
if group_name:
|
|
75
|
+
result = manager.get_group_status(group_name)
|
|
76
|
+
elif dependency_name:
|
|
77
|
+
result = manager.get_dependency_status(dependency_name)
|
|
78
|
+
else:
|
|
79
|
+
result = manager.get_all_status()
|
|
80
|
+
if as_json:
|
|
81
|
+
click.echo(json_module.dumps(dataclasses.asdict(result), indent=2))
|
|
82
|
+
else:
|
|
83
|
+
_print_multi_status(result)
|
|
84
|
+
except ValueError as e:
|
|
85
|
+
raise click.ClickException(str(e))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config loading and GlobalManager initialisation for the CLI.
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ..datatypes import GlobalConfig
|
|
9
|
+
from ..global_manager import GlobalManager
|
|
10
|
+
from ..vpn import get_vpn
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Default paths (XDG-compliant)
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
_XDG_CONFIG_HOME = os.environ.get(
|
|
17
|
+
"XDG_CONFIG_HOME", os.path.expanduser("~/.config")
|
|
18
|
+
)
|
|
19
|
+
DEFAULT_CONFIG_DIR = os.path.join(_XDG_CONFIG_HOME, "smanager")
|
|
20
|
+
DEFAULT_CONFIG_PATH = os.path.join(DEFAULT_CONFIG_DIR, "smanager.yaml")
|
|
21
|
+
SYSTEM_CONFIG_PATH = "/etc/smanager/smanager.yaml"
|
|
22
|
+
|
|
23
|
+
# Ordered list of locations tried when no explicit path is given.
|
|
24
|
+
_SEARCH_PATHS = [
|
|
25
|
+
DEFAULT_CONFIG_PATH,
|
|
26
|
+
SYSTEM_CONFIG_PATH,
|
|
27
|
+
"smanager.yaml", # legacy local fallback
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_config_path(config_path: str | None) -> str:
|
|
32
|
+
"""Return the resolved config file path or raise ClickException."""
|
|
33
|
+
if config_path is not None:
|
|
34
|
+
return config_path
|
|
35
|
+
env = os.environ.get("SMANAGER_CONFIG")
|
|
36
|
+
if env:
|
|
37
|
+
return env
|
|
38
|
+
for path in _SEARCH_PATHS:
|
|
39
|
+
if os.path.isfile(path):
|
|
40
|
+
return path
|
|
41
|
+
raise click.ClickException(
|
|
42
|
+
f"No config file found. Run 'smanager config init' to create one at "
|
|
43
|
+
f"{DEFAULT_CONFIG_PATH}."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_manager(config_path: str | None) -> GlobalManager:
|
|
48
|
+
"""Resolve a config file and return an initialised GlobalManager."""
|
|
49
|
+
path = resolve_config_path(config_path)
|
|
50
|
+
with open(path) as f:
|
|
51
|
+
try:
|
|
52
|
+
config = GlobalConfig.from_yaml(f.read())
|
|
53
|
+
except ValueError as e:
|
|
54
|
+
raise click.ClickException(str(e))
|
|
55
|
+
if not config.services_dir:
|
|
56
|
+
raise click.ClickException("'services_dir' is not set in the config file.")
|
|
57
|
+
if not os.path.isdir(config.services_dir):
|
|
58
|
+
raise click.ClickException(
|
|
59
|
+
f"services_dir does not exist: {config.services_dir}"
|
|
60
|
+
)
|
|
61
|
+
try:
|
|
62
|
+
vpn_cls = get_vpn(config.vpn_provider)
|
|
63
|
+
vpn = vpn_cls()
|
|
64
|
+
except ValueError as e:
|
|
65
|
+
raise click.ClickException(
|
|
66
|
+
f"Unknown VPN provider '{config.vpn_provider}': {e}"
|
|
67
|
+
)
|
|
68
|
+
return GlobalManager(config.services_dir, config, vpn)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_manager(ctx: click.Context) -> GlobalManager:
|
|
72
|
+
"""Return the GlobalManager stored in *ctx*, building it lazily on first call."""
|
|
73
|
+
if ctx.obj.get("_manager") is None:
|
|
74
|
+
ctx.obj["_manager"] = build_manager(ctx.obj.get("config_path"))
|
|
75
|
+
return ctx.obj["_manager"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from .commands import all_cmd, dependency_cmd, group_cmd, service_cmd, status_cmd
|
|
4
|
+
from .commands import all_cmd, config_cmd, dependency_cmd, group_cmd, service_cmd, status_cmd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.group()
|
|
8
|
+
@click.option(
|
|
9
|
+
"--config",
|
|
10
|
+
"config_path",
|
|
11
|
+
envvar="SMANAGER_CONFIG",
|
|
12
|
+
default=None,
|
|
13
|
+
metavar="PATH",
|
|
14
|
+
help="Path to config file (or set SMANAGER_CONFIG). Default: ./smanager.yaml",
|
|
15
|
+
)
|
|
16
|
+
@click.version_option(package_name="smanager")
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def cli(ctx: click.Context, config_path: str | None) -> None:
|
|
19
|
+
"""SManager — manage self-hosted services and Docker containers."""
|
|
20
|
+
ctx.ensure_object(dict)
|
|
21
|
+
ctx.obj["config_path"] = config_path
|
|
22
|
+
ctx.obj["_manager"] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
cli.add_command(service_cmd, "service")
|
|
26
|
+
cli.add_command(group_cmd, "group")
|
|
27
|
+
cli.add_command(dependency_cmd, "dependency")
|
|
28
|
+
cli.add_command(all_cmd, "all")
|
|
29
|
+
cli.add_command(status_cmd, "status")
|
|
30
|
+
cli.add_command(config_cmd, "config")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Finds Service Discovery files in a given directory and parses them into Service objects.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from ..datatypes import ServiceMetadata, Service
|
|
7
|
+
|
|
8
|
+
class ServiceDiscoverer:
|
|
9
|
+
def __init__(self, directory: str, searched_filename: str = "sd_info.json"):
|
|
10
|
+
self.directory = directory
|
|
11
|
+
self.searched_filename = searched_filename
|
|
12
|
+
|
|
13
|
+
def discover_services(self) -> list[Service]:
|
|
14
|
+
"""Walks through the directory to find SDConfig files, returns a list
|
|
15
|
+
of Service objects.
|
|
16
|
+
"""
|
|
17
|
+
services = []
|
|
18
|
+
for root, _, files in os.walk(self.directory):
|
|
19
|
+
if self.searched_filename in files:
|
|
20
|
+
sd_abspath = os.path.join(root, self.searched_filename)
|
|
21
|
+
with open(sd_abspath, 'r') as f:
|
|
22
|
+
try:
|
|
23
|
+
metadata = ServiceMetadata.from_json(f.read())
|
|
24
|
+
service = Service(
|
|
25
|
+
metadata=metadata,
|
|
26
|
+
service_abspath=root,
|
|
27
|
+
metadata_filename=self.searched_filename
|
|
28
|
+
)
|
|
29
|
+
services.append(service)
|
|
30
|
+
except Exception as e:
|
|
31
|
+
print(f"Error parsing {sd_abspath}: {e}")
|
|
32
|
+
return services
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .sd_info_v2 import (
|
|
2
|
+
ServiceMetadata, RuntimeSection, PortSection,
|
|
3
|
+
DockerSection, LocalSection, Service
|
|
4
|
+
)
|
|
5
|
+
from .config import GlobalConfig
|
|
6
|
+
from .service_status import ServiceStatus, SingleStatus, MultiStatus
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ServiceMetadata",
|
|
10
|
+
"RuntimeSection",
|
|
11
|
+
"PortSection",
|
|
12
|
+
"DockerSection",
|
|
13
|
+
"LocalSection",
|
|
14
|
+
"GlobalConfig",
|
|
15
|
+
"Service",
|
|
16
|
+
"ServiceStatus",
|
|
17
|
+
"SingleStatus",
|
|
18
|
+
"MultiStatus"
|
|
19
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class GlobalConfig:
|
|
6
|
+
services_dir: str
|
|
7
|
+
vpn_provider: str = field(default="tailscale")
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def from_yaml(cls, yaml_str: str) -> 'GlobalConfig':
|
|
11
|
+
"""Parses a YAML string into a GlobalConfig object."""
|
|
12
|
+
try:
|
|
13
|
+
data = yaml.safe_load(yaml_str)
|
|
14
|
+
global_data = data.get('global', {})
|
|
15
|
+
return cls(
|
|
16
|
+
services_dir=global_data.get('services_dir', ''),
|
|
17
|
+
vpn_provider=global_data.get('vpn_provider', 'tailscale'),
|
|
18
|
+
)
|
|
19
|
+
except yaml.YAMLError as e:
|
|
20
|
+
raise ValueError(f"Error parsing YAML: {e}")
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the data structures for the SDConfig v2
|
|
3
|
+
"""
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PortSection:
|
|
12
|
+
webui: int
|
|
13
|
+
|
|
14
|
+
# Dinamically loads dict keys as attributes
|
|
15
|
+
def __post_init__(self):
|
|
16
|
+
for key, value in self.__dict__.items():
|
|
17
|
+
setattr(self, key, value)
|
|
18
|
+
|
|
19
|
+
if not hasattr(self, 'webui'):
|
|
20
|
+
raise ValueError("PortSection must have a 'webui' key.")
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RuntimeSection:
|
|
24
|
+
type: str
|
|
25
|
+
ports: PortSection
|
|
26
|
+
sys_user: str
|
|
27
|
+
sys_group: str
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
if not self.type:
|
|
31
|
+
raise ValueError("RuntimeSection must have a 'type' field.")
|
|
32
|
+
if not self.ports:
|
|
33
|
+
raise ValueError("RuntimeSection must have a 'ports' field.")
|
|
34
|
+
if not self.sys_user:
|
|
35
|
+
raise ValueError("RuntimeSection must have a 'sys_user' field.")
|
|
36
|
+
if not self.sys_group:
|
|
37
|
+
raise ValueError("RuntimeSection must have a 'sys_group' field.")
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class DockerSection:
|
|
41
|
+
containers: Optional[list] = None
|
|
42
|
+
compose_file: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class LocalSection:
|
|
46
|
+
installation_path: Optional[str] = None
|
|
47
|
+
app_path: Optional[str] = None
|
|
48
|
+
systemd_services: Optional[list] = None
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ServiceMetadata:
|
|
52
|
+
name: str
|
|
53
|
+
description: str
|
|
54
|
+
schema_version: int
|
|
55
|
+
id: str
|
|
56
|
+
active: bool
|
|
57
|
+
runtime: RuntimeSection
|
|
58
|
+
groups: Optional[list] = None
|
|
59
|
+
docker: Optional[DockerSection] = None
|
|
60
|
+
local: Optional[LocalSection] = None
|
|
61
|
+
depends_on: Optional[list] = None
|
|
62
|
+
|
|
63
|
+
def __post_init__(self):
|
|
64
|
+
# Validate required fields
|
|
65
|
+
if not self.name:
|
|
66
|
+
raise ValueError("SDConfig must have a 'name' field.")
|
|
67
|
+
if not self.description:
|
|
68
|
+
raise ValueError("SDConfig must have a 'description' field.")
|
|
69
|
+
if not self.schema_version:
|
|
70
|
+
raise ValueError("SDConfig must have a 'schema_version' field.")
|
|
71
|
+
if not self.id:
|
|
72
|
+
raise ValueError("SDConfig must have an 'id' field.")
|
|
73
|
+
if self.active is None:
|
|
74
|
+
raise ValueError("SDConfig must have an 'active' field.")
|
|
75
|
+
if not self.runtime:
|
|
76
|
+
raise ValueError("SDConfig must have a 'runtime' field.")
|
|
77
|
+
|
|
78
|
+
# Validate at least one of docker or local is provided
|
|
79
|
+
if not self.docker and not self.local:
|
|
80
|
+
raise ValueError("SDConfig must have at least one of 'docker' or 'local' sections.")
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_dict(cls, data: dict) -> 'ServiceMetadata':
|
|
84
|
+
runtime_data = data.get('runtime')
|
|
85
|
+
if not runtime_data:
|
|
86
|
+
raise ValueError("SDConfig must have a 'runtime' section.")
|
|
87
|
+
|
|
88
|
+
runtime = RuntimeSection(
|
|
89
|
+
type=runtime_data.get('type'),
|
|
90
|
+
ports=PortSection(**runtime_data.get('ports', {})),
|
|
91
|
+
sys_user=runtime_data.get('sys_user'),
|
|
92
|
+
sys_group=runtime_data.get('sys_group')
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
docker_data = data.get('docker')
|
|
96
|
+
docker = DockerSection(**docker_data) if docker_data else None
|
|
97
|
+
|
|
98
|
+
local_data = data.get('local')
|
|
99
|
+
local = LocalSection(**local_data) if local_data else None
|
|
100
|
+
|
|
101
|
+
return cls(
|
|
102
|
+
name=data.get('name'),
|
|
103
|
+
description=data.get('description'),
|
|
104
|
+
schema_version=data.get('schema_version'),
|
|
105
|
+
id=data.get('id'),
|
|
106
|
+
groups=data.get('groups'),
|
|
107
|
+
active=data.get('active'),
|
|
108
|
+
runtime=runtime,
|
|
109
|
+
docker=docker,
|
|
110
|
+
local=local,
|
|
111
|
+
depends_on=data.get('depends_on')
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_json(cls, json_str: str) -> 'ServiceMetadata':
|
|
116
|
+
return cls.from_dict(json.loads(json_str))
|
|
117
|
+
|
|
118
|
+
def write_to_json(self, file_path: str):
|
|
119
|
+
with open(file_path, 'w') as f:
|
|
120
|
+
json.dump(self.__dict__, f, default=lambda o: o.__dict__, indent=4)
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class Service:
|
|
124
|
+
metadata: ServiceMetadata
|
|
125
|
+
service_abspath: str
|
|
126
|
+
metadata_filename: str
|
|
127
|
+
|
|
128
|
+
def __post_init__(self):
|
|
129
|
+
if not os.path.isabs(self.service_abspath):
|
|
130
|
+
raise ValueError("Service 'service_abspath' must be an absolute path.")
|
|
131
|
+
if not os.path.exists(self.service_abspath):
|
|
132
|
+
raise ValueError(f"Service 'service_abspath' does not exist: {self.service_abspath}")
|
|
133
|
+
if not self.metadata_filename:
|
|
134
|
+
raise ValueError("Service must have a 'metadata_filename' field.")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
@dataclass
|
|
4
|
+
class SingleStatus:
|
|
5
|
+
name: str
|
|
6
|
+
status: str
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ServiceStatus:
|
|
10
|
+
id: str
|
|
11
|
+
type: str # e.g. "docker", "local"
|
|
12
|
+
statuses: list[SingleStatus]
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MultiStatus:
|
|
16
|
+
services: list[ServiceStatus]
|