smanager 0.2.0__tar.gz → 0.2.2__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.2.0 → smanager-0.2.2}/PKG-INFO +1 -1
- smanager-0.2.2/README.md +60 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/__init__.py +1 -1
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/metadata.py +16 -1
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/metadata_wizard.py +164 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/service_managers/local_manager.py +11 -2
- {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/PKG-INFO +1 -1
- {smanager-0.2.0 → smanager-0.2.2}/tests/test_metadata_wizard.py +67 -1
- {smanager-0.2.0 → smanager-0.2.2}/tests/test_service_manager_events.py +16 -1
- smanager-0.2.0/README.md +0 -15
- {smanager-0.2.0 → smanager-0.2.2}/pyproject.toml +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/setup.cfg +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/__init__.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/__init__.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/all.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/config.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/dependency.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/group.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/service.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/status.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/config.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/events.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/formatters/__init__.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/formatters/status_table.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/main.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/crawler/__init__.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/crawler/sd_finder.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/datatypes/__init__.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/datatypes/config.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/datatypes/sd_info_v2.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/datatypes/service_status.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/events.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/global_manager/__init__.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/global_manager/base_global_manager.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/global_manager/global_manager.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/service_managers/__init__.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/service_managers/base_manager.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/service_managers/base_sdv2_manager.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/service_managers/docker_manager.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/vpn/__init__.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/vpn/base_vpn.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/vpn/providers/__init__.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/vpn/providers/tailscale.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager/vpn/registry.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/SOURCES.txt +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/dependency_links.txt +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/entry_points.txt +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/requires.txt +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/top_level.txt +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/tests/test_cli_events.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/tests/test_events.py +0 -0
- {smanager-0.2.0 → smanager-0.2.2}/tests/test_status_output.py +0 -0
smanager-0.2.2/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
- Unified service management for local/systemd and Docker runtimes.
|
|
6
|
+
- VPN integration (Tailscale provider support).
|
|
7
|
+
- Interactive metadata creation and editing.
|
|
8
|
+
- YAML-based global configuration.
|
|
9
|
+
|
|
10
|
+
## CLI Usage
|
|
11
|
+
Use this command pattern:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
smanager <group> <command> [options]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Discover available commands at any level:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
smanager --help
|
|
21
|
+
smanager <group> --help
|
|
22
|
+
smanager <group> <command> --help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Metadata Commands
|
|
26
|
+
The `metadata` group provides interactive workflows for service metadata files.
|
|
27
|
+
|
|
28
|
+
### Create Metadata
|
|
29
|
+
```bash
|
|
30
|
+
smanager metadata init
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Optional output override:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
smanager metadata init --output /path/to/sd_info.json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Edit Existing Metadata
|
|
40
|
+
```bash
|
|
41
|
+
smanager metadata edit
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Edit a specific file directly:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
smanager metadata edit --file /path/to/sd_info.json
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Behavior summary:
|
|
51
|
+
- Interactive prompts with inline defaults.
|
|
52
|
+
- Runtime-aware sections (Docker vs Local).
|
|
53
|
+
- Multi-select support for `groups` and `depends_on`.
|
|
54
|
+
- Validation before writing changes.
|
|
55
|
+
|
|
56
|
+
## Notes
|
|
57
|
+
- `metadata init` creates a new file.
|
|
58
|
+
- `metadata edit` modifies an existing file.
|
|
59
|
+
- Use command-specific `--help` for the latest options and examples.
|
|
60
|
+
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import click
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
-
from ..metadata_wizard import MetadataWizard
|
|
4
|
+
from ..metadata_wizard import MetadataEditor, MetadataWizard
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
@click.group("metadata")
|
|
@@ -21,3 +21,18 @@ def init_cmd(output: Path | None) -> None:
|
|
|
21
21
|
"""Create a service metadata file using an interactive guided wizard."""
|
|
22
22
|
wizard = MetadataWizard(output_override=output)
|
|
23
23
|
wizard.run()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@metadata_cmd.command("edit")
|
|
27
|
+
@click.option(
|
|
28
|
+
"--file",
|
|
29
|
+
"metadata_file",
|
|
30
|
+
type=click.Path(path_type=Path, dir_okay=False, exists=False),
|
|
31
|
+
default=None,
|
|
32
|
+
metavar="PATH",
|
|
33
|
+
help="Path to an existing metadata file. If omitted, choose interactively.",
|
|
34
|
+
)
|
|
35
|
+
def edit_cmd(metadata_file: Path | None) -> None:
|
|
36
|
+
"""Edit an existing service metadata file interactively."""
|
|
37
|
+
editor = MetadataEditor(metadata_path=metadata_file)
|
|
38
|
+
editor.run()
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import re
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Any, Iterable
|
|
@@ -409,3 +410,166 @@ class MetadataWizard:
|
|
|
409
410
|
if len(values) <= 3:
|
|
410
411
|
return ", ".join(values)
|
|
411
412
|
return f"{len(values)} selected"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class MetadataEditor(MetadataWizard):
|
|
416
|
+
def __init__(self, metadata_path: Path | None = None, console: Console | None = None):
|
|
417
|
+
super().__init__(output_override=None, console=console)
|
|
418
|
+
self.metadata_path = metadata_path
|
|
419
|
+
|
|
420
|
+
def run(self) -> Path:
|
|
421
|
+
target_path = self._resolve_metadata_path()
|
|
422
|
+
metadata_dict = self._load_metadata_dict(target_path)
|
|
423
|
+
ServiceMetadata.from_dict(metadata_dict)
|
|
424
|
+
original_json = json.dumps(metadata_dict, sort_keys=True)
|
|
425
|
+
|
|
426
|
+
self.console.print(
|
|
427
|
+
Panel.fit(
|
|
428
|
+
f"[bold cyan]Metadata Editor[/]\n"
|
|
429
|
+
f"[dim]Editing {target_path}. Choose sections and save when ready.[/]",
|
|
430
|
+
title="smanager metadata edit",
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
while True:
|
|
435
|
+
section = self._ask_select(
|
|
436
|
+
"Edit section",
|
|
437
|
+
[
|
|
438
|
+
"Identity",
|
|
439
|
+
"Runtime",
|
|
440
|
+
"Runtime details",
|
|
441
|
+
"Groups",
|
|
442
|
+
"Depends on",
|
|
443
|
+
"VPN",
|
|
444
|
+
"Active",
|
|
445
|
+
"Save and exit",
|
|
446
|
+
"Exit without saving",
|
|
447
|
+
],
|
|
448
|
+
"Identity",
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if section == "Identity":
|
|
452
|
+
self._edit_identity(metadata_dict)
|
|
453
|
+
elif section == "Runtime":
|
|
454
|
+
self._edit_runtime(metadata_dict)
|
|
455
|
+
elif section == "Runtime details":
|
|
456
|
+
self._edit_runtime_details(metadata_dict)
|
|
457
|
+
elif section == "Groups":
|
|
458
|
+
self._edit_groups(metadata_dict, target_path)
|
|
459
|
+
elif section == "Depends on":
|
|
460
|
+
self._edit_dependencies(metadata_dict, target_path)
|
|
461
|
+
elif section == "VPN":
|
|
462
|
+
self._edit_vpn(metadata_dict)
|
|
463
|
+
elif section == "Active":
|
|
464
|
+
metadata_dict["active"] = self._ask_confirm("Enable service", bool(metadata_dict.get("active", True)))
|
|
465
|
+
elif section == "Save and exit":
|
|
466
|
+
break
|
|
467
|
+
elif section == "Exit without saving":
|
|
468
|
+
self.console.print("[yellow]No changes written.[/]")
|
|
469
|
+
return target_path
|
|
470
|
+
|
|
471
|
+
metadata = ServiceMetadata.from_dict(metadata_dict)
|
|
472
|
+
self._render_review(metadata, target_path.parent, target_path.name)
|
|
473
|
+
|
|
474
|
+
if not self._ask_confirm("Save changes", True):
|
|
475
|
+
raise click.ClickException("Cancelled by user.")
|
|
476
|
+
|
|
477
|
+
if json.dumps(metadata_dict, sort_keys=True) == original_json:
|
|
478
|
+
self.console.print("[yellow]No changes detected.[/]")
|
|
479
|
+
return target_path
|
|
480
|
+
|
|
481
|
+
metadata.write_to_json(str(target_path))
|
|
482
|
+
self.console.print(f"[green]✓ Metadata saved to {target_path}[/]")
|
|
483
|
+
return target_path
|
|
484
|
+
|
|
485
|
+
def _resolve_metadata_path(self) -> Path:
|
|
486
|
+
if self.metadata_path is not None:
|
|
487
|
+
target_path = self.metadata_path.expanduser()
|
|
488
|
+
else:
|
|
489
|
+
candidates = self._discover_metadata_files(Path.cwd())
|
|
490
|
+
if not candidates:
|
|
491
|
+
raise click.ClickException("No metadata files found (expected sd_info.*).")
|
|
492
|
+
if len(candidates) == 1:
|
|
493
|
+
target_path = candidates[0]
|
|
494
|
+
else:
|
|
495
|
+
default_choice = str(candidates[0])
|
|
496
|
+
selected = self._ask_select("Metadata file", [str(path) for path in candidates], default_choice)
|
|
497
|
+
target_path = Path(selected)
|
|
498
|
+
|
|
499
|
+
if not target_path.is_file():
|
|
500
|
+
raise click.ClickException(f"Metadata file not found: {target_path}")
|
|
501
|
+
return target_path
|
|
502
|
+
|
|
503
|
+
def _discover_metadata_files(self, root: Path) -> list[Path]:
|
|
504
|
+
return sorted(path for path in root.rglob("sd_info.*") if path.is_file())
|
|
505
|
+
|
|
506
|
+
def _load_metadata_dict(self, path: Path) -> dict[str, Any]:
|
|
507
|
+
try:
|
|
508
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
509
|
+
except json.JSONDecodeError as exc:
|
|
510
|
+
raise click.ClickException(f"Invalid JSON metadata file: {path}") from exc
|
|
511
|
+
|
|
512
|
+
def _edit_identity(self, metadata_dict: dict[str, Any]) -> None:
|
|
513
|
+
metadata_dict["name"] = self._ask_text("Service name", str(metadata_dict.get("name", "")))
|
|
514
|
+
metadata_dict["description"] = self._ask_text("Description", str(metadata_dict.get("description", "")))
|
|
515
|
+
metadata_dict["id"] = self._ask_text("Service id", str(metadata_dict.get("id", "")))
|
|
516
|
+
|
|
517
|
+
def _edit_runtime(self, metadata_dict: dict[str, Any]) -> None:
|
|
518
|
+
runtime = metadata_dict.setdefault("runtime", {})
|
|
519
|
+
runtime_type = self._ask_select("Runtime type", ["docker", "local"], str(runtime.get("type", "local")))
|
|
520
|
+
runtime["type"] = runtime_type
|
|
521
|
+
|
|
522
|
+
ports = runtime.setdefault("ports", {})
|
|
523
|
+
ports["webui"] = self._ask_int("Web UI port", int(ports.get("webui", 8080)))
|
|
524
|
+
runtime["sys_user"] = self._ask_text("System user", str(runtime.get("sys_user", metadata_dict.get("id", "service"))))
|
|
525
|
+
runtime["sys_group"] = self._ask_text("System group", str(runtime.get("sys_group", metadata_dict.get("id", "service"))))
|
|
526
|
+
|
|
527
|
+
if runtime_type == "docker":
|
|
528
|
+
metadata_dict.pop("local", None)
|
|
529
|
+
metadata_dict.setdefault("docker", {"compose_file": "compose.yaml", "containers": None})
|
|
530
|
+
else:
|
|
531
|
+
metadata_dict.pop("docker", None)
|
|
532
|
+
service_id = str(metadata_dict.get("id", "service"))
|
|
533
|
+
metadata_dict.setdefault(
|
|
534
|
+
"local",
|
|
535
|
+
{
|
|
536
|
+
"installation_path": f"/opt/{service_id}",
|
|
537
|
+
"appdata_path": f"/var/lib/{service_id}",
|
|
538
|
+
"systemd_services": [service_id],
|
|
539
|
+
},
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def _edit_runtime_details(self, metadata_dict: dict[str, Any]) -> None:
|
|
543
|
+
runtime_type = metadata_dict.get("runtime", {}).get("type", "local")
|
|
544
|
+
if runtime_type == "docker":
|
|
545
|
+
docker = metadata_dict.setdefault("docker", {})
|
|
546
|
+
docker["compose_file"] = self._ask_text("Compose file", str(docker.get("compose_file", "compose.yaml")))
|
|
547
|
+
containers = docker.get("containers") or []
|
|
548
|
+
containers_csv = ", ".join(containers)
|
|
549
|
+
parsed = parse_csv(self._ask_text("Containers (comma-separated)", containers_csv))
|
|
550
|
+
docker["containers"] = parsed or None
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
local = metadata_dict.setdefault("local", {})
|
|
554
|
+
service_id = str(metadata_dict.get("id", "service"))
|
|
555
|
+
local["installation_path"] = self._ask_text("Installation path", str(local.get("installation_path", f"/opt/{service_id}")))
|
|
556
|
+
local["appdata_path"] = self._ask_text("Appdata path", str(local.get("appdata_path", f"/var/lib/{service_id}")))
|
|
557
|
+
services = local.get("systemd_services") or [service_id]
|
|
558
|
+
parsed_services = parse_csv(self._ask_text("Systemd services (comma-separated)", ", ".join(services)))
|
|
559
|
+
local["systemd_services"] = parsed_services or [service_id]
|
|
560
|
+
|
|
561
|
+
def _edit_groups(self, metadata_dict: dict[str, Any], target_path: Path) -> None:
|
|
562
|
+
current = metadata_dict.get("groups") or []
|
|
563
|
+
available = sorted(set(self._existing_groups(target_path.parent) + current))
|
|
564
|
+
metadata_dict["groups"] = self._ask_groups(available) or None
|
|
565
|
+
|
|
566
|
+
def _edit_dependencies(self, metadata_dict: dict[str, Any], target_path: Path) -> None:
|
|
567
|
+
service_id = str(metadata_dict.get("id", ""))
|
|
568
|
+
current = metadata_dict.get("depends_on") or []
|
|
569
|
+
available = sorted(set(self._existing_dependencies(target_path.parent, service_id) + current) - {service_id})
|
|
570
|
+
metadata_dict["depends_on"] = self._ask_dependencies(available) or None
|
|
571
|
+
|
|
572
|
+
def _edit_vpn(self, metadata_dict: dict[str, Any]) -> None:
|
|
573
|
+
vpn = metadata_dict.setdefault("vpn", {})
|
|
574
|
+
vpn["serve_https"] = self._ask_confirm("Serve HTTPS through VPN", bool(vpn.get("serve_https", False)))
|
|
575
|
+
vpn["https_restart_delay"] = self._ask_int("HTTPS restart delay", int(vpn.get("https_restart_delay", 5)))
|
|
@@ -2,6 +2,7 @@ from . import BaseSDV2Manager
|
|
|
2
2
|
from ..datatypes import Service
|
|
3
3
|
from ..vpn import VPNService
|
|
4
4
|
import time
|
|
5
|
+
import subprocess
|
|
5
6
|
from ..datatypes import ServiceStatus, SingleStatus
|
|
6
7
|
|
|
7
8
|
class LocalManager(BaseSDV2Manager):
|
|
@@ -105,10 +106,18 @@ class LocalManager(BaseSDV2Manager):
|
|
|
105
106
|
"""Check the status of the local systemd service and return a ServiceStatus object."""
|
|
106
107
|
service_statuses = []
|
|
107
108
|
for service_name in self._get_systemd_services():
|
|
108
|
-
result =
|
|
109
|
+
result = subprocess.run(
|
|
110
|
+
["systemctl", "is-active", service_name],
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
check=False,
|
|
114
|
+
)
|
|
115
|
+
status_text = result.stdout.strip() or result.stderr.strip()
|
|
116
|
+
if not status_text:
|
|
117
|
+
status_text = "active" if result.returncode == 0 else "inactive"
|
|
109
118
|
status = SingleStatus(
|
|
110
119
|
name=service_name,
|
|
111
|
-
status=
|
|
120
|
+
status=status_text
|
|
112
121
|
)
|
|
113
122
|
service_statuses.append(status)
|
|
114
123
|
return ServiceStatus(
|
|
@@ -8,7 +8,7 @@ from unittest.mock import patch
|
|
|
8
8
|
|
|
9
9
|
import click
|
|
10
10
|
|
|
11
|
-
from smanager.cli.metadata_wizard import ADD_NEW_GROUP_LABEL, MetadataWizard
|
|
11
|
+
from smanager.cli.metadata_wizard import ADD_NEW_GROUP_LABEL, MetadataEditor, MetadataWizard
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class MetadataWizardTests(unittest.TestCase):
|
|
@@ -208,5 +208,71 @@ class MetadataWizardTests(unittest.TestCase):
|
|
|
208
208
|
self.assertEqual(groups, ["media", "new-group"])
|
|
209
209
|
|
|
210
210
|
|
|
211
|
+
class MetadataEditorTests(unittest.TestCase):
|
|
212
|
+
def _metadata_payload(self) -> dict:
|
|
213
|
+
return {
|
|
214
|
+
"name": "Sonarr",
|
|
215
|
+
"description": "Sonarr service",
|
|
216
|
+
"schema_version": 2,
|
|
217
|
+
"id": "sonarr",
|
|
218
|
+
"active": True,
|
|
219
|
+
"runtime": {
|
|
220
|
+
"type": "local",
|
|
221
|
+
"ports": {"webui": 8989},
|
|
222
|
+
"sys_user": "sonarr",
|
|
223
|
+
"sys_group": "sonarr",
|
|
224
|
+
},
|
|
225
|
+
"groups": ["media"],
|
|
226
|
+
"depends_on": ["qbittorrent"],
|
|
227
|
+
"local": {
|
|
228
|
+
"installation_path": "/opt/sonarr",
|
|
229
|
+
"appdata_path": "/var/lib/sonarr",
|
|
230
|
+
"systemd_services": ["sonarr"],
|
|
231
|
+
},
|
|
232
|
+
"vpn": {"serve_https": False, "https_restart_delay": 5},
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def test_metadata_edit_updates_identity_and_saves(self):
|
|
236
|
+
with TemporaryDirectory() as tmpdir:
|
|
237
|
+
metadata_path = Path(tmpdir) / "sd_info.json"
|
|
238
|
+
metadata_path.write_text(json.dumps(self._metadata_payload()), encoding="utf-8")
|
|
239
|
+
editor = MetadataEditor(metadata_path=metadata_path)
|
|
240
|
+
|
|
241
|
+
with patch.object(editor, "_ask_select", side_effect=["Identity", "Save and exit"]), patch.object(
|
|
242
|
+
editor,
|
|
243
|
+
"_ask_text",
|
|
244
|
+
side_effect=["Sonarr Updated", "Updated description", "sonarr-updated"],
|
|
245
|
+
), patch.object(editor, "_ask_confirm", return_value=True):
|
|
246
|
+
result = editor.run()
|
|
247
|
+
|
|
248
|
+
self.assertEqual(result, metadata_path)
|
|
249
|
+
updated = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
250
|
+
self.assertEqual(updated["name"], "Sonarr Updated")
|
|
251
|
+
self.assertEqual(updated["description"], "Updated description")
|
|
252
|
+
self.assertEqual(updated["id"], "sonarr-updated")
|
|
253
|
+
|
|
254
|
+
def test_metadata_edit_can_exit_without_saving(self):
|
|
255
|
+
with TemporaryDirectory() as tmpdir:
|
|
256
|
+
metadata_path = Path(tmpdir) / "sd_info.json"
|
|
257
|
+
original = self._metadata_payload()
|
|
258
|
+
metadata_path.write_text(json.dumps(original), encoding="utf-8")
|
|
259
|
+
editor = MetadataEditor(metadata_path=metadata_path)
|
|
260
|
+
|
|
261
|
+
with patch.object(editor, "_ask_select", return_value="Exit without saving"), patch.object(
|
|
262
|
+
editor, "_ask_confirm"
|
|
263
|
+
) as confirm_mock:
|
|
264
|
+
result = editor.run()
|
|
265
|
+
|
|
266
|
+
self.assertEqual(result, metadata_path)
|
|
267
|
+
self.assertEqual(json.loads(metadata_path.read_text(encoding="utf-8")), original)
|
|
268
|
+
confirm_mock.assert_not_called()
|
|
269
|
+
|
|
270
|
+
def test_metadata_edit_raises_when_file_is_missing(self):
|
|
271
|
+
editor = MetadataEditor(metadata_path=Path("/tmp/does-not-exist-sd-info.json"))
|
|
272
|
+
|
|
273
|
+
with self.assertRaises(click.ClickException):
|
|
274
|
+
editor.run()
|
|
275
|
+
|
|
276
|
+
|
|
211
277
|
if __name__ == "__main__":
|
|
212
278
|
unittest.main()
|
|
@@ -4,7 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
from tempfile import TemporaryDirectory
|
|
5
5
|
from types import SimpleNamespace
|
|
6
6
|
import unittest
|
|
7
|
-
from unittest.mock import Mock
|
|
7
|
+
from unittest.mock import Mock, patch
|
|
8
8
|
|
|
9
9
|
from smanager.datatypes import (
|
|
10
10
|
DockerSection,
|
|
@@ -161,3 +161,18 @@ class LocalManagerEventTests(unittest.TestCase):
|
|
|
161
161
|
manager.stop()
|
|
162
162
|
|
|
163
163
|
self.assertEqual(events, ["service.stop.before", "service.stop.after"])
|
|
164
|
+
|
|
165
|
+
def test_status_returns_inactive_when_systemctl_reports_inactive(self):
|
|
166
|
+
with TemporaryDirectory() as tmpdir:
|
|
167
|
+
service_dir = Path(tmpdir) / "local"
|
|
168
|
+
service_dir.mkdir()
|
|
169
|
+
service = make_local_service(service_dir, active=True)
|
|
170
|
+
|
|
171
|
+
manager = LocalManager(service, DummyVPN())
|
|
172
|
+
|
|
173
|
+
fake_result = SimpleNamespace(stdout="inactive\n", stderr="", returncode=4)
|
|
174
|
+
with patch("smanager.service_managers.local_manager.subprocess.run", return_value=fake_result) as run_mock:
|
|
175
|
+
result = manager.status()
|
|
176
|
+
|
|
177
|
+
self.assertEqual(result.statuses[0].status, "inactive")
|
|
178
|
+
run_mock.assert_called_once()
|
smanager-0.2.0/README.md
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
|
|
11
|
-
## Interactive Metadata Wizard
|
|
12
|
-
- Create a new service metadata file with `smanager metadata init`.
|
|
13
|
-
- The wizard is interactive, shows defaults in context, and only asks Docker-specific or local-specific fields depending on the runtime you choose.
|
|
14
|
-
- It uses Rich for a guided terminal experience and validates the generated metadata before saving it.
|
|
15
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|