smanager 0.1.0__py3-none-any.whl
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/__init__.py +13 -0
- smanager/cli/__init__.py +0 -0
- smanager/cli/commands/__init__.py +10 -0
- smanager/cli/commands/all.py +35 -0
- smanager/cli/commands/config.py +61 -0
- smanager/cli/commands/dependency.py +24 -0
- smanager/cli/commands/group.py +24 -0
- smanager/cli/commands/service.py +24 -0
- smanager/cli/commands/status.py +85 -0
- smanager/cli/config.py +75 -0
- smanager/cli/main.py +30 -0
- smanager/crawler/__init__.py +5 -0
- smanager/crawler/sd_finder.py +32 -0
- smanager/datatypes/__init__.py +19 -0
- smanager/datatypes/config.py +20 -0
- smanager/datatypes/sd_info_v2.py +134 -0
- smanager/datatypes/service_status.py +16 -0
- smanager/global_manager/__init__.py +7 -0
- smanager/global_manager/base_global_manager.py +81 -0
- smanager/global_manager/global_manager.py +103 -0
- smanager/service_managers/__init__.py +11 -0
- smanager/service_managers/base_manager.py +62 -0
- smanager/service_managers/base_sdv2_manager.py +45 -0
- smanager/service_managers/docker_manager.py +63 -0
- smanager/service_managers/local_manager.py +44 -0
- smanager/vpn/__init__.py +11 -0
- smanager/vpn/base_vpn.py +32 -0
- smanager/vpn/providers/__init__.py +5 -0
- smanager/vpn/providers/tailscale.py +51 -0
- smanager/vpn/registry.py +28 -0
- smanager-0.1.0.dist-info/METADATA +6 -0
- smanager-0.1.0.dist-info/RECORD +35 -0
- smanager-0.1.0.dist-info/WHEEL +5 -0
- smanager-0.1.0.dist-info/entry_points.txt +2 -0
- smanager-0.1.0.dist-info/top_level.txt +1 -0
smanager/__init__.py
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
|
+
]
|
smanager/cli/__init__.py
ADDED
|
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))
|
smanager/cli/config.py
ADDED
|
@@ -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"]
|
smanager/cli/main.py
ADDED
|
@@ -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]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from ..service_managers import BaseManager
|
|
2
|
+
from ..datatypes import Service, GlobalConfig, ServiceStatus, MultiStatus
|
|
3
|
+
from ..crawler import ServiceDiscoverer
|
|
4
|
+
from ..vpn import VPNService
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
|
|
7
|
+
class BaseGlobalManager:
|
|
8
|
+
"""BaseGlobalManager is an abstract class that defines the interface for a global manager.
|
|
9
|
+
It provides methods for searching and managing services, but does not implement the
|
|
10
|
+
actual logic. Subclasses should implement the specific logic for managing services.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, services_dir: str, global_config: GlobalConfig, vpn: VPNService):
|
|
14
|
+
self.services_dir = services_dir
|
|
15
|
+
self.global_config = global_config
|
|
16
|
+
self.vpn = vpn
|
|
17
|
+
self.crawler = ServiceDiscoverer(services_dir)
|
|
18
|
+
self.services = self.discover_services()
|
|
19
|
+
|
|
20
|
+
def discover_services(self) -> list[Service]:
|
|
21
|
+
"""Discover services in the specified services directory."""
|
|
22
|
+
return self.crawler.discover_services()
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def get_service_manager(self, service: Service) -> BaseManager:
|
|
26
|
+
"""Get the appropriate service manager for the given service."""
|
|
27
|
+
raise NotImplementedError("get_service_manager method must be implemented by subclasses.")
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def get_group(self, group_name: str) -> list[Service]:
|
|
31
|
+
"""Get all services that belong to the specified group."""
|
|
32
|
+
raise NotImplementedError("get_group method must be implemented by subclasses.")
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def get_services_by_dependency(self, dependency_name: str) -> list[Service]:
|
|
36
|
+
"""Get all services that depend on the specified dependency."""
|
|
37
|
+
raise NotImplementedError("get_services_by_dependency method must be implemented by subclasses.")
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def manage_service(self, service_id: str, action: str):
|
|
41
|
+
"""Manage a service by performing the specified action (start, stop, restart, enable, disable)."""
|
|
42
|
+
raise NotImplementedError("manage_service method must be implemented by subclasses.")
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def manage_group(self, group_name: str, action: str):
|
|
46
|
+
"""Manage all services in a group by performing the specified action
|
|
47
|
+
(start, stop, restart).
|
|
48
|
+
"""
|
|
49
|
+
raise NotImplementedError("manage_group method must be implemented by subclasses.")
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def manage_by_dependency(self, dependency_name: str, action: str):
|
|
53
|
+
"""Manage all services that depend on a specific dependency by performing the
|
|
54
|
+
specified action (start, stop, restart, enable, disable).
|
|
55
|
+
"""
|
|
56
|
+
raise NotImplementedError("manage_by_dependency method must be implemented by subclasses.")
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def manage_all(self, action: str):
|
|
60
|
+
"""Manage all services by performing the specified action (start, stop, restart, enable, disable)."""
|
|
61
|
+
raise NotImplementedError("manage_all method must be implemented by subclasses.")
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def get_status(self, service_id: str) -> ServiceStatus:
|
|
65
|
+
"""Get the status of a specific service by its ID."""
|
|
66
|
+
raise NotImplementedError("get_status method must be implemented by subclasses.")
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def get_group_status(self, group_name: str) -> MultiStatus:
|
|
70
|
+
"""Get the status of all services in a group."""
|
|
71
|
+
raise NotImplementedError("get_group_status method must be implemented by subclasses.")
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def get_dependency_status(self, dependency_name: str) -> MultiStatus:
|
|
75
|
+
"""Get the status of all services that depend on a specific dependency."""
|
|
76
|
+
raise NotImplementedError("get_dependency_status method must be implemented by subclasses.")
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def get_all_status(self) -> MultiStatus:
|
|
80
|
+
"""Get the status of all services."""
|
|
81
|
+
raise NotImplementedError("get_all_status method must be implemented by subclasses.")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from ..service_managers import DockerManager, LocalManager, BaseManager
|
|
2
|
+
from ..datatypes import Service, GlobalConfig, ServiceStatus, MultiStatus
|
|
3
|
+
from ..vpn import VPNService
|
|
4
|
+
from . import BaseGlobalManager
|
|
5
|
+
|
|
6
|
+
class GlobalManager(BaseGlobalManager):
|
|
7
|
+
"""GlobalManager is responsible for searching and managing all services found in the
|
|
8
|
+
specified services directory. It uses the appropriate service manager (DockerManager
|
|
9
|
+
or LocalManager) based on the service metadata.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, services_dir: str, global_config: GlobalConfig, vpn: VPNService):
|
|
13
|
+
super().__init__(services_dir, global_config, vpn)
|
|
14
|
+
|
|
15
|
+
def _find_service_by_id(self, service_id: str) -> Service:
|
|
16
|
+
"""Helper method to find a service by its unique identifier."""
|
|
17
|
+
for service in self.services:
|
|
18
|
+
if service.metadata.id == service_id:
|
|
19
|
+
return service
|
|
20
|
+
raise ValueError(f"Service with ID '{service_id}' not found.")
|
|
21
|
+
|
|
22
|
+
def get_service_manager(self, service: Service, restart_delay: int = 5) -> BaseManager:
|
|
23
|
+
"""Get the appropriate service manager for the given service based on its metadata."""
|
|
24
|
+
if service.metadata.runtime.type == "docker":
|
|
25
|
+
return DockerManager(service, self.vpn, restart_delay=restart_delay)
|
|
26
|
+
elif service.metadata.runtime.type == "local":
|
|
27
|
+
return LocalManager(service, vpn=self.vpn, restart_delay=restart_delay)
|
|
28
|
+
else:
|
|
29
|
+
raise ValueError(f"Unsupported runtime type: {service.metadata.runtime.type}")
|
|
30
|
+
|
|
31
|
+
def get_group(self, group_name: str) -> list[Service]:
|
|
32
|
+
"""Get all services that belong to the specified group."""
|
|
33
|
+
return [s for s in self.services if s.metadata.groups and group_name in s.metadata.groups]
|
|
34
|
+
|
|
35
|
+
def get_services_by_dependency(self, dependency_name: str) -> list[Service]:
|
|
36
|
+
"""Get all services that depend on the specified dependency."""
|
|
37
|
+
return [s for s in self.services if s.metadata.depends_on and dependency_name in s.metadata.depends_on]
|
|
38
|
+
|
|
39
|
+
def manage_service(self, service_id: str, action: str):
|
|
40
|
+
"""Manage a service by performing the specified action (start, stop, restart,
|
|
41
|
+
enable, disable).
|
|
42
|
+
"""
|
|
43
|
+
service = self._find_service_by_id(service_id)
|
|
44
|
+
manager = self.get_service_manager(service)
|
|
45
|
+
|
|
46
|
+
if action == "start":
|
|
47
|
+
manager.start()
|
|
48
|
+
elif action == "stop":
|
|
49
|
+
manager.stop()
|
|
50
|
+
elif action == "restart":
|
|
51
|
+
manager.restart()
|
|
52
|
+
elif action == "enable":
|
|
53
|
+
manager.enable()
|
|
54
|
+
elif action == "disable":
|
|
55
|
+
manager.disable()
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError(f"Unsupported action: {action}")
|
|
58
|
+
|
|
59
|
+
def manage_group(self, group_name: str, action: str):
|
|
60
|
+
"""Manage all services in a group by performing the specified action
|
|
61
|
+
(start, stop, restart, enable, disable).
|
|
62
|
+
"""
|
|
63
|
+
services = self.get_group(group_name)
|
|
64
|
+
for service in services:
|
|
65
|
+
self.manage_service(service.metadata.id, action)
|
|
66
|
+
|
|
67
|
+
def manage_by_dependency(self, dependency_name: str, action: str):
|
|
68
|
+
"""Manage all services that depend on a specific dependency by performing the
|
|
69
|
+
specified action (start, stop, restart, enable, disable).
|
|
70
|
+
"""
|
|
71
|
+
services = self.get_services_by_dependency(dependency_name)
|
|
72
|
+
for service in services:
|
|
73
|
+
self.manage_service(service.metadata.id, action)
|
|
74
|
+
|
|
75
|
+
def manage_all(self, action: str):
|
|
76
|
+
"""Manage all services by performing the specified action (start, stop, restart,
|
|
77
|
+
enable, disable)."""
|
|
78
|
+
for service in self.services:
|
|
79
|
+
self.manage_service(service.metadata.id, action)
|
|
80
|
+
|
|
81
|
+
def get_status(self, service_id: str) -> ServiceStatus:
|
|
82
|
+
"""Get the status of a specific service by its ID."""
|
|
83
|
+
service = self._find_service_by_id(service_id)
|
|
84
|
+
manager = self.get_service_manager(service)
|
|
85
|
+
return manager.status()
|
|
86
|
+
|
|
87
|
+
def get_group_status(self, group_name: str) -> MultiStatus:
|
|
88
|
+
"""Get the status of all services in a group."""
|
|
89
|
+
services = self.get_group(group_name)
|
|
90
|
+
statuses = [self.get_status(service.metadata.id) for service in services]
|
|
91
|
+
return MultiStatus(services=statuses)
|
|
92
|
+
|
|
93
|
+
def get_dependency_status(self, dependency_name: str) -> MultiStatus:
|
|
94
|
+
"""Get the status of all services that depend on a specific dependency."""
|
|
95
|
+
services = self.get_services_by_dependency(dependency_name)
|
|
96
|
+
statuses = [self.get_status(service.metadata.id) for service in services]
|
|
97
|
+
return MultiStatus(services=statuses)
|
|
98
|
+
|
|
99
|
+
def get_all_status(self) -> MultiStatus:
|
|
100
|
+
"""Get the status of all services."""
|
|
101
|
+
statuses = [self.get_status(service.metadata.id) for service in self.services]
|
|
102
|
+
return MultiStatus(services=statuses)
|
|
103
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .base_manager import BaseManager
|
|
2
|
+
from .base_sdv2_manager import BaseSDV2Manager
|
|
3
|
+
from .docker_manager import DockerManager
|
|
4
|
+
from .local_manager import LocalManager
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"BaseManager",
|
|
8
|
+
"BaseSDV2Manager",
|
|
9
|
+
"DockerManager",
|
|
10
|
+
"LocalManager"
|
|
11
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class for all simple service managers.
|
|
3
|
+
Defines the interface and common functionality for managing a single service.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import abstractmethod
|
|
7
|
+
from ..datatypes import ServiceStatus
|
|
8
|
+
|
|
9
|
+
class BaseManager:
|
|
10
|
+
def __init__(self, config):
|
|
11
|
+
self.config = config
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def start(self):
|
|
15
|
+
"""Start the service."""
|
|
16
|
+
raise NotImplementedError("Start method must be implemented by subclasses.")
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def stop(self):
|
|
20
|
+
"""Stop the service."""
|
|
21
|
+
raise NotImplementedError("Stop method must be implemented by subclasses.")
|
|
22
|
+
|
|
23
|
+
def restart(self):
|
|
24
|
+
"""Restart the service."""
|
|
25
|
+
self.stop()
|
|
26
|
+
self.start()
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def status(self)-> ServiceStatus:
|
|
30
|
+
"""Check the status of the service."""
|
|
31
|
+
raise NotImplementedError("Status method must be implemented by subclasses.")
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def enable(self):
|
|
35
|
+
"""Enable the service to be managed (start, stop, restart)."""
|
|
36
|
+
raise NotImplementedError("Enable method must be implemented by subclasses.")
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def disable(self):
|
|
40
|
+
"""Disable the service from being managed (start, stop, restart)."""
|
|
41
|
+
raise NotImplementedError("Disable method must be implemented by subclasses.")
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def get_groups(self):
|
|
45
|
+
"""Get the groups associated with this service."""
|
|
46
|
+
raise NotImplementedError("get_groups method must be implemented by subclasses.")
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def get_dependencies(self):
|
|
50
|
+
"""Get the dependencies of this service."""
|
|
51
|
+
raise NotImplementedError("get_dependencies method must be implemented by subclasses.")
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def get_id(self):
|
|
55
|
+
"""Get the unique identifier of this service."""
|
|
56
|
+
raise NotImplementedError("get_id method must be implemented by subclasses.")
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def is_active(self):
|
|
60
|
+
"""Check if the service is active."""
|
|
61
|
+
raise NotImplementedError("is_active method must be implemented by subclasses.")
|
|
62
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The bass manager for SDV2 services.
|
|
3
|
+
It extends the BaseManager with SDV2-specific logic, such as handling the SDConfig
|
|
4
|
+
file and integrating with VPN services.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from . import BaseManager
|
|
8
|
+
from ..datatypes import Service
|
|
9
|
+
from ..vpn import VPNService
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
class BaseSDV2Manager(BaseManager):
|
|
13
|
+
def __init__(self, service: Service, vpn: VPNService, restart_delay: int = 5):
|
|
14
|
+
super().__init__(service)
|
|
15
|
+
self.service = service
|
|
16
|
+
self.vpn = vpn
|
|
17
|
+
self.restart_delay = restart_delay
|
|
18
|
+
|
|
19
|
+
def enable(self):
|
|
20
|
+
"""Change the JSON config to set the service as active."""
|
|
21
|
+
self.service.metadata.active = True
|
|
22
|
+
output_path = os.path.join(self.service.service_abspath, self.service.metadata_filename)
|
|
23
|
+
self.service.metadata.write_to_json(output_path)
|
|
24
|
+
|
|
25
|
+
def disable(self):
|
|
26
|
+
"""Change the JSON config to set the service as inactive."""
|
|
27
|
+
self.service.metadata.active = False
|
|
28
|
+
output_path = os.path.join(self.service.service_abspath, self.service.metadata_filename)
|
|
29
|
+
self.service.metadata.write_to_json(output_path)
|
|
30
|
+
|
|
31
|
+
def get_groups(self):
|
|
32
|
+
"""Get the groups associated with this service."""
|
|
33
|
+
return self.service.metadata.groups
|
|
34
|
+
|
|
35
|
+
def get_dependencies(self):
|
|
36
|
+
"""Get the dependencies of this service."""
|
|
37
|
+
return self.service.metadata.depends_on
|
|
38
|
+
|
|
39
|
+
def get_id(self):
|
|
40
|
+
"""Get the unique identifier of this service."""
|
|
41
|
+
return self.service.metadata.id
|
|
42
|
+
|
|
43
|
+
def is_active(self):
|
|
44
|
+
"""Check if the service is active."""
|
|
45
|
+
return self.service.metadata.active
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from . import BaseSDV2Manager
|
|
2
|
+
from ..datatypes import Service
|
|
3
|
+
import subprocess
|
|
4
|
+
import os
|
|
5
|
+
from ..vpn import VPNService
|
|
6
|
+
import time
|
|
7
|
+
from ..datatypes import ServiceStatus, SingleStatus
|
|
8
|
+
|
|
9
|
+
class DockerManager(BaseSDV2Manager):
|
|
10
|
+
def __init__(self, service: Service, vpn: VPNService, restart_delay: int = 5):
|
|
11
|
+
super().__init__()
|
|
12
|
+
self._compose_path = self.service.metadata.docker.compose_file
|
|
13
|
+
|
|
14
|
+
def _get_compose_file_path(self) -> str:
|
|
15
|
+
"""Returns the path to the docker-compose.yml file for this service."""
|
|
16
|
+
return os.path.join(
|
|
17
|
+
self.service.service_abspath,
|
|
18
|
+
self.service.metadata.docker.compose_file
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def start(self):
|
|
22
|
+
"""Start the Docker service using docker-compose."""
|
|
23
|
+
compose_file = self._get_compose_file_path()
|
|
24
|
+
subprocess.run(["docker-compose", "-f", compose_file, "up", "-d"], check=True)
|
|
25
|
+
|
|
26
|
+
def stop(self):
|
|
27
|
+
"""Stop the Docker service using docker-compose."""
|
|
28
|
+
compose_file = self._get_compose_file_path()
|
|
29
|
+
subprocess.run(["docker-compose", "-f", compose_file, "down"], check=True)
|
|
30
|
+
|
|
31
|
+
def restart(self):
|
|
32
|
+
"""Restart the Docker service using docker-compose."""
|
|
33
|
+
self.stop()
|
|
34
|
+
self.vpn.stop_serving_https(self.service.metadata.runtime.ports.webui)
|
|
35
|
+
self.start()
|
|
36
|
+
time.sleep(self.restart_delay)
|
|
37
|
+
self.vpn.serve_https(self.service.metadata.runtime.ports.webui)
|
|
38
|
+
|
|
39
|
+
def status(self)-> ServiceStatus:
|
|
40
|
+
"""Check the status of all containers in the Docker service and return a
|
|
41
|
+
ServiceStatus object.
|
|
42
|
+
"""
|
|
43
|
+
compose_file = self._get_compose_file_path()
|
|
44
|
+
result = subprocess.run(["docker-compose", "-f", compose_file, "ps", "--services", "--filter", "status=running"], capture_output=True, text=True)
|
|
45
|
+
running_services = set(result.stdout.strip().splitlines())
|
|
46
|
+
|
|
47
|
+
all_services_result = subprocess.run(["docker-compose", "-f", compose_file, "config", "--services"], capture_output=True, text=True)
|
|
48
|
+
all_services = set(all_services_result.stdout.strip().splitlines())
|
|
49
|
+
|
|
50
|
+
service_statuses = []
|
|
51
|
+
for service_name in all_services:
|
|
52
|
+
status = SingleStatus(
|
|
53
|
+
name=service_name,
|
|
54
|
+
status="running" if service_name in running_services else "stopped"
|
|
55
|
+
)
|
|
56
|
+
service_statuses.append(status)
|
|
57
|
+
|
|
58
|
+
return ServiceStatus(
|
|
59
|
+
id=self.get_id(),
|
|
60
|
+
type=self.service.metadata.runtime.type,
|
|
61
|
+
services=service_statuses
|
|
62
|
+
)
|
|
63
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from . import BaseSDV2Manager
|
|
2
|
+
from ..datatypes import Service
|
|
3
|
+
import subprocess
|
|
4
|
+
from ..vpn import VPNService
|
|
5
|
+
import time
|
|
6
|
+
from ..datatypes import ServiceStatus, SingleStatus
|
|
7
|
+
|
|
8
|
+
class LocalManager(BaseSDV2Manager):
|
|
9
|
+
def __init__(self, service: Service, vpn: VPNService, restart_delay: int = 5):
|
|
10
|
+
super().__init__()
|
|
11
|
+
|
|
12
|
+
def start(self):
|
|
13
|
+
"""Start the local systemd service."""
|
|
14
|
+
for service_name in self.service.metadata.local.systemd_services:
|
|
15
|
+
subprocess.run(["systemctl", "start", service_name], check=True)
|
|
16
|
+
|
|
17
|
+
def stop(self):
|
|
18
|
+
"""Stop the local systemd service."""
|
|
19
|
+
for service_name in self.service.metadata.local.systemd_services:
|
|
20
|
+
subprocess.run(["systemctl", "stop", service_name], check=True)
|
|
21
|
+
|
|
22
|
+
def restart(self):
|
|
23
|
+
"""Restart the local systemd service."""
|
|
24
|
+
self.stop()
|
|
25
|
+
self.vpn.stop_serving_https(self.service.metadata.runtime.ports.webui)
|
|
26
|
+
self.start()
|
|
27
|
+
time.sleep(self.restart_delay)
|
|
28
|
+
self.vpn.serve_https(self.service.metadata.runtime.ports.webui)
|
|
29
|
+
|
|
30
|
+
def status(self)-> ServiceStatus:
|
|
31
|
+
"""Check the status of the local systemd service and return a ServiceStatus object."""
|
|
32
|
+
service_statuses = []
|
|
33
|
+
for service_name in self.service.metadata.local.systemd_services:
|
|
34
|
+
result = subprocess.run(["systemctl", "is-active", service_name], capture_output=True, text=True)
|
|
35
|
+
status = SingleStatus(
|
|
36
|
+
name=service_name,
|
|
37
|
+
status=result.stdout.strip()
|
|
38
|
+
)
|
|
39
|
+
service_statuses.append(status)
|
|
40
|
+
return ServiceStatus(
|
|
41
|
+
id=self.get_id(),
|
|
42
|
+
type=self.service.metadata.runtime.type,
|
|
43
|
+
services=service_statuses
|
|
44
|
+
)
|
smanager/vpn/__init__.py
ADDED
smanager/vpn/base_vpn.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defines the base VPN service interface that all VPN implementations must follow.
|
|
3
|
+
"""
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
|
|
6
|
+
class VPNService:
|
|
7
|
+
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def connect(self):
|
|
10
|
+
"""Connect to the VPN."""
|
|
11
|
+
raise NotImplementedError("Connect method must be implemented by subclasses.")
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def disconnect(self):
|
|
15
|
+
"""Disconnect from the VPN."""
|
|
16
|
+
raise NotImplementedError("Disconnect method must be implemented by subclasses.")
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def is_connected(self) -> bool:
|
|
20
|
+
"""Check if the VPN is currently connected."""
|
|
21
|
+
raise NotImplementedError("is_connected method must be implemented by subclasses.")
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def serve_https(self, port: int):
|
|
25
|
+
"""Serve the VPN connection over HTTPS on the specified port."""
|
|
26
|
+
raise NotImplementedError("serve_https method must be implemented by subclasses.")
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def stop_serving_https(self, port: int):
|
|
30
|
+
"""Stop serving the VPN connection over HTTPS on the specified port."""
|
|
31
|
+
raise NotImplementedError("stop_serving_https method must be implemented by subclasses.")
|
|
32
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implements the Tailscale VPN service.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .. import VPNService
|
|
6
|
+
from ..registry import register_vpn
|
|
7
|
+
import subprocess
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
@register_vpn("tailscale")
|
|
11
|
+
class TailscaleVPN(VPNService):
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.connected = self.is_connected()
|
|
14
|
+
|
|
15
|
+
def connect(self):
|
|
16
|
+
"""Connect to the Tailscale VPN."""
|
|
17
|
+
try:
|
|
18
|
+
subprocess.run(["sudo", "tailscale", "up"], check=True)
|
|
19
|
+
except subprocess.CalledProcessError as e:
|
|
20
|
+
raise RuntimeError("Failed to connect to Tailscale VPN") from e
|
|
21
|
+
self.connected = self.is_connected()
|
|
22
|
+
|
|
23
|
+
def disconnect(self):
|
|
24
|
+
"""Disconnect from the Tailscale VPN."""
|
|
25
|
+
try:
|
|
26
|
+
subprocess.run(["sudo", "tailscale", "down"], check=True)
|
|
27
|
+
except subprocess.CalledProcessError as e:
|
|
28
|
+
raise RuntimeError("Failed to disconnect from Tailscale VPN") from e
|
|
29
|
+
self.connected = self.is_connected()
|
|
30
|
+
|
|
31
|
+
def is_connected(self) -> bool:
|
|
32
|
+
"""Check if the VPN is currently connected."""
|
|
33
|
+
try:
|
|
34
|
+
status = subprocess.run(["tailscale", "status", "--json"], capture_output=True, text=True, check=True)
|
|
35
|
+
return json.loads(status.stdout).get("BackendState", "") == "Running"
|
|
36
|
+
except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError):
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
def serve_https(self, port: int):
|
|
40
|
+
"""Serve the VPN connection over HTTPS on the specified port."""
|
|
41
|
+
try:
|
|
42
|
+
subprocess.run(["sudo", "tailscale", "serve", "--bg", f"--https={port}", f"http://localhost:{port}"], check=True)
|
|
43
|
+
except subprocess.CalledProcessError as e:
|
|
44
|
+
raise RuntimeError(f"Failed to serve HTTPS on port {port} via Tailscale") from e
|
|
45
|
+
|
|
46
|
+
def stop_serving_https(self, port: int):
|
|
47
|
+
"""Stop serving the VPN connection over HTTPS on the specified port."""
|
|
48
|
+
try:
|
|
49
|
+
subprocess.run(["sudo", "tailscale", "serve", f"--https={port}", "off"], check=True)
|
|
50
|
+
except subprocess.CalledProcessError as e:
|
|
51
|
+
raise RuntimeError(f"Failed to stop serving HTTPS on port {port} via Tailscale") from e
|
smanager/vpn/registry.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module implements a registry for VPN providers.
|
|
3
|
+
It allows for dynamic registration and retrieval of VPN provider classes by name.
|
|
4
|
+
"""
|
|
5
|
+
from . import VPNService
|
|
6
|
+
from typing import Dict, Type
|
|
7
|
+
|
|
8
|
+
# Global registry for VPN providers
|
|
9
|
+
_VPN_REGISTRY: Dict[str, Type[VPNService]] = {}
|
|
10
|
+
|
|
11
|
+
def register_vpn(name: str):
|
|
12
|
+
"""Decorator to register a model class with a given name."""
|
|
13
|
+
def decorator(cls: Type[VPNService]):
|
|
14
|
+
if name in _VPN_REGISTRY:
|
|
15
|
+
raise ValueError(f"VPN provider \"{name}\" is already registered.")
|
|
16
|
+
_VPN_REGISTRY[name] = cls
|
|
17
|
+
return cls
|
|
18
|
+
return decorator
|
|
19
|
+
|
|
20
|
+
def get_vpn(name: str) -> Type[VPNService]:
|
|
21
|
+
"""Retrieves a VPN provider class by name."""
|
|
22
|
+
if name not in _VPN_REGISTRY:
|
|
23
|
+
raise ValueError(f"VPN provider \"{name}\" is not registered.")
|
|
24
|
+
return _VPN_REGISTRY[name]
|
|
25
|
+
|
|
26
|
+
def list_vpns() -> list[str]:
|
|
27
|
+
"""Returns a list of registered VPN provider names."""
|
|
28
|
+
return list(_VPN_REGISTRY.keys())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
smanager/__init__.py,sha256=pR6K9zQ-1X496aTgEGgwyIq9tWTd_cmXENJlpmQVf_o,268
|
|
2
|
+
smanager/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
smanager/cli/config.py,sha256=CddeZD9yDMd9Ik80leSInD83R77-0nnk2SSFGj0vxyU,2544
|
|
4
|
+
smanager/cli/main.py,sha256=GzFKCvQ7czhGcyztLMuBBcChC-FHLDfFoBmzbxVrKu0,945
|
|
5
|
+
smanager/cli/commands/__init__.py,sha256=QyeykJElRQtJvQQ8iy_ih0FEpoHzeHH2NMlWVq3Z09w,357
|
|
6
|
+
smanager/cli/commands/all.py,sha256=IDwHFMoZA2Ku61beTczXNikVqAavNqjqv-DwAm2ybVw,952
|
|
7
|
+
smanager/cli/commands/config.py,sha256=U2Q78tNFgMGbjNbbpu6WqCdChruskFHMl2LOZGedluY,1811
|
|
8
|
+
smanager/cli/commands/dependency.py,sha256=aOOAr9duhJr5kkZVv1dCF3zhip2dOjb9RFNX1E9kO1A,770
|
|
9
|
+
smanager/cli/commands/group.py,sha256=833lFSyCTzlEplzVvKk2SRssLmNVGL6Fk3A6YQ-GAu0,704
|
|
10
|
+
smanager/cli/commands/service.py,sha256=R252wqAdUTou9NrW7yvAhvgTgq1PNKfiSlcawywJUbA,700
|
|
11
|
+
smanager/cli/commands/status.py,sha256=d0v0mbh9vfNDQTTzirSTp8EG8RHOSh31V6ncKs3nTU8,2912
|
|
12
|
+
smanager/crawler/__init__.py,sha256=6eJqh3Q-sAsCAaQVIWlmjLhGtJhrJ_VH82OdpACIr5I,79
|
|
13
|
+
smanager/crawler/sd_finder.py,sha256=YfDmUCbJVnB-DxTTO_fWPoT7mPR3cdC2i9jREa-gvLM,1284
|
|
14
|
+
smanager/datatypes/__init__.py,sha256=LA3Eqml_0-O_XBCiFw5UiHLpnnTiOn_Pyend2D58oHU,435
|
|
15
|
+
smanager/datatypes/config.py,sha256=hQnLWtX_AeSDy6xItKL50q7Xz_76YEP9zpPPYpZ-EW4,673
|
|
16
|
+
smanager/datatypes/sd_info_v2.py,sha256=w1hbIAuoGg3k_s0criTUZexDK1UNg_ObCebbk-wXEjs,4446
|
|
17
|
+
smanager/datatypes/service_status.py,sha256=8a24YGFFDt0UkjsMYRHq06gpr2xAxFs5rvjO4-uK0CY,278
|
|
18
|
+
smanager/global_manager/__init__.py,sha256=Eog1ycSHPTWMSNC4J2iSa2TofzAnUYzu-BC1yWbTyNw,153
|
|
19
|
+
smanager/global_manager/base_global_manager.py,sha256=zcGsAo0dFhEMykLKrS5AjPTCR2F7HHCniLjOPhXQr4w,3955
|
|
20
|
+
smanager/global_manager/global_manager.py,sha256=YvPmnxgA-HDYjNw4wodbYMlYhnqt2DdDLq8MgNOw2VA,4889
|
|
21
|
+
smanager/service_managers/__init__.py,sha256=-0dqDXBRbUk7N7NN5-Ab0MZRdcEAr0IjLA1kwKDLvBs,263
|
|
22
|
+
smanager/service_managers/base_manager.py,sha256=jnwFMPMVFsTSFtYIHdzr4ghnhTkpZBPB-qinaeBhLCg,2090
|
|
23
|
+
smanager/service_managers/base_sdv2_manager.py,sha256=uPHhsSPaYpPB86HDsFI_xJ9w75B9bC4N3i3GIOyf4aE,1606
|
|
24
|
+
smanager/service_managers/docker_manager.py,sha256=GmjyYV1tqJuVIB2aCZfa4OsKJlmKL96Pw8pE2sQPrf4,2561
|
|
25
|
+
smanager/service_managers/local_manager.py,sha256=Hkx7eFGDAMEk5DTyXlccHou7XCVqGOfC4FyVPNrJ_Bs,1753
|
|
26
|
+
smanager/vpn/__init__.py,sha256=81bcFe0b2s1TBprRqNakXnoLSwBY0TzNHkJNutU_hug,227
|
|
27
|
+
smanager/vpn/base_vpn.py,sha256=IzumtdKQmdexfSJICHZV3fTdheDc9hfkA7K9T532T5k,1169
|
|
28
|
+
smanager/vpn/registry.py,sha256=UvtHLGYs-XFvIaGmSdieiI5Krl__1kBpLQKlmspgVJs,967
|
|
29
|
+
smanager/vpn/providers/__init__.py,sha256=s3yqGMrWc4vIqmI6Q4fpaLjew1WN5ml5yfNV0KQ05rg,69
|
|
30
|
+
smanager/vpn/providers/tailscale.py,sha256=FZNx5V3frQoD3KnPoKQOjJf7AdP7bEXYNIu4_70slow,2126
|
|
31
|
+
smanager-0.1.0.dist-info/METADATA,sha256=kWigLYhl7omwfbBcKi8MPe9jAV7bMX458OMjKFVqzgY,128
|
|
32
|
+
smanager-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
33
|
+
smanager-0.1.0.dist-info/entry_points.txt,sha256=1X7ZL8LtCbTmVZsJRYKUQeo9Cfp0BJWH9olFuCFSYaU,51
|
|
34
|
+
smanager-0.1.0.dist-info/top_level.txt,sha256=VPRTgGpVmdkozEZNG7ZibXzDZVe8MSmUM3ZdR0N0E84,9
|
|
35
|
+
smanager-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
smanager
|