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.
Files changed (40) hide show
  1. smanager-0.1.0/PKG-INFO +6 -0
  2. smanager-0.1.0/README.md +10 -0
  3. smanager-0.1.0/pyproject.toml +18 -0
  4. smanager-0.1.0/setup.cfg +4 -0
  5. smanager-0.1.0/smanager/__init__.py +13 -0
  6. smanager-0.1.0/smanager/cli/__init__.py +0 -0
  7. smanager-0.1.0/smanager/cli/commands/__init__.py +10 -0
  8. smanager-0.1.0/smanager/cli/commands/all.py +35 -0
  9. smanager-0.1.0/smanager/cli/commands/config.py +61 -0
  10. smanager-0.1.0/smanager/cli/commands/dependency.py +24 -0
  11. smanager-0.1.0/smanager/cli/commands/group.py +24 -0
  12. smanager-0.1.0/smanager/cli/commands/service.py +24 -0
  13. smanager-0.1.0/smanager/cli/commands/status.py +85 -0
  14. smanager-0.1.0/smanager/cli/config.py +75 -0
  15. smanager-0.1.0/smanager/cli/main.py +30 -0
  16. smanager-0.1.0/smanager/crawler/__init__.py +5 -0
  17. smanager-0.1.0/smanager/crawler/sd_finder.py +32 -0
  18. smanager-0.1.0/smanager/datatypes/__init__.py +19 -0
  19. smanager-0.1.0/smanager/datatypes/config.py +20 -0
  20. smanager-0.1.0/smanager/datatypes/sd_info_v2.py +134 -0
  21. smanager-0.1.0/smanager/datatypes/service_status.py +16 -0
  22. smanager-0.1.0/smanager/global_manager/__init__.py +7 -0
  23. smanager-0.1.0/smanager/global_manager/base_global_manager.py +81 -0
  24. smanager-0.1.0/smanager/global_manager/global_manager.py +103 -0
  25. smanager-0.1.0/smanager/service_managers/__init__.py +11 -0
  26. smanager-0.1.0/smanager/service_managers/base_manager.py +62 -0
  27. smanager-0.1.0/smanager/service_managers/base_sdv2_manager.py +45 -0
  28. smanager-0.1.0/smanager/service_managers/docker_manager.py +63 -0
  29. smanager-0.1.0/smanager/service_managers/local_manager.py +44 -0
  30. smanager-0.1.0/smanager/vpn/__init__.py +11 -0
  31. smanager-0.1.0/smanager/vpn/base_vpn.py +32 -0
  32. smanager-0.1.0/smanager/vpn/providers/__init__.py +5 -0
  33. smanager-0.1.0/smanager/vpn/providers/tailscale.py +51 -0
  34. smanager-0.1.0/smanager/vpn/registry.py +28 -0
  35. smanager-0.1.0/smanager.egg-info/PKG-INFO +6 -0
  36. smanager-0.1.0/smanager.egg-info/SOURCES.txt +38 -0
  37. smanager-0.1.0/smanager.egg-info/dependency_links.txt +1 -0
  38. smanager-0.1.0/smanager.egg-info/entry_points.txt +2 -0
  39. smanager-0.1.0/smanager.egg-info/requires.txt +2 -0
  40. smanager-0.1.0/smanager.egg-info/top_level.txt +1 -0
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: smanager
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.9
5
+ Requires-Dist: PyYAML>=6.0
6
+ Requires-Dist: click>=8.0
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ from .sd_finder import ServiceDiscoverer
2
+
3
+ __all__ = [
4
+ "ServiceDiscoverer"
5
+ ]
@@ -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,7 @@
1
+ from .base_global_manager import BaseGlobalManager
2
+ from .global_manager import GlobalManager
3
+
4
+ __all__ = [
5
+ "BaseGlobalManager",
6
+ "GlobalManager",
7
+ ]