local-dev-composer 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.
- ldc/__init__.py +3 -0
- ldc/__main__.py +5 -0
- ldc/adapters/__init__.py +0 -0
- ldc/adapters/config/__init__.py +0 -0
- ldc/adapters/config/yaml_reader.py +172 -0
- ldc/adapters/git/__init__.py +0 -0
- ldc/adapters/git/subprocess_client.py +64 -0
- ldc/adapters/health/__init__.py +0 -0
- ldc/adapters/health/command_checker.py +41 -0
- ldc/adapters/health/composite_checker.py +39 -0
- ldc/adapters/health/http_checker.py +31 -0
- ldc/adapters/health/process_checker.py +28 -0
- ldc/adapters/health/tcp_checker.py +33 -0
- ldc/adapters/prerequisites/__init__.py +0 -0
- ldc/adapters/prerequisites/system_checker.py +256 -0
- ldc/adapters/process/__init__.py +0 -0
- ldc/adapters/process/windows_runner.py +143 -0
- ldc/adapters/reporting/__init__.py +0 -0
- ldc/adapters/reporting/rich_reporter.py +211 -0
- ldc/adapters/state/__init__.py +0 -0
- ldc/adapters/state/json_store.py +64 -0
- ldc/application/__init__.py +1 -0
- ldc/application/commands/__init__.py +0 -0
- ldc/application/commands/bootstrap.py +91 -0
- ldc/application/commands/check.py +65 -0
- ldc/application/commands/clone.py +77 -0
- ldc/application/commands/doctor.py +115 -0
- ldc/application/commands/down.py +61 -0
- ldc/application/commands/env.py +94 -0
- ldc/application/commands/install.py +61 -0
- ldc/application/commands/logs.py +74 -0
- ldc/application/commands/rebuild.py +86 -0
- ldc/application/commands/restart.py +65 -0
- ldc/application/commands/status.py +36 -0
- ldc/application/commands/up.py +229 -0
- ldc/application/container.py +83 -0
- ldc/application/env_resolver.py +61 -0
- ldc/application/installer_service.py +41 -0
- ldc/cli.py +242 -0
- ldc/domain/__init__.py +1 -0
- ldc/domain/exceptions.py +29 -0
- ldc/domain/graph.py +149 -0
- ldc/domain/models.py +172 -0
- ldc/ports/__init__.py +1 -0
- ldc/ports/config_reader.py +11 -0
- ldc/ports/git_client.py +22 -0
- ldc/ports/health_checker.py +28 -0
- ldc/ports/installer.py +24 -0
- ldc/ports/prerequisite_checker.py +23 -0
- ldc/ports/process_runner.py +39 -0
- ldc/ports/reporter.py +43 -0
- ldc/ports/state_store.py +20 -0
- local_dev_composer-0.1.0.dist-info/METADATA +88 -0
- local_dev_composer-0.1.0.dist-info/RECORD +57 -0
- local_dev_composer-0.1.0.dist-info/WHEEL +5 -0
- local_dev_composer-0.1.0.dist-info/entry_points.txt +2 -0
- local_dev_composer-0.1.0.dist-info/top_level.txt +1 -0
ldc/__init__.py
ADDED
ldc/__main__.py
ADDED
ldc/adapters/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adapter: reads a composer.yml file and produces a WorkspaceConfig.
|
|
3
|
+
|
|
4
|
+
YAML schema is deliberately flat and human-friendly; this adapter maps it
|
|
5
|
+
to the rich domain model.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from ldc.domain.exceptions import ConfigValidationError
|
|
15
|
+
from ldc.domain.models import (
|
|
16
|
+
Group,
|
|
17
|
+
HealthCheckConfig,
|
|
18
|
+
HealthCheckType,
|
|
19
|
+
InstallConfig,
|
|
20
|
+
Prerequisites,
|
|
21
|
+
Runtime,
|
|
22
|
+
Service,
|
|
23
|
+
StartConfig,
|
|
24
|
+
WorkspaceConfig,
|
|
25
|
+
)
|
|
26
|
+
from ldc.ports.config_reader import IConfigReader
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class YamlConfigReader(IConfigReader):
|
|
30
|
+
|
|
31
|
+
def read(self, path: Path) -> WorkspaceConfig:
|
|
32
|
+
if not path.exists():
|
|
33
|
+
raise ConfigValidationError(f"Config file not found: {path}")
|
|
34
|
+
|
|
35
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
36
|
+
raw = yaml.safe_load(fh)
|
|
37
|
+
|
|
38
|
+
if not isinstance(raw, dict):
|
|
39
|
+
raise ConfigValidationError("composer.yml must be a YAML mapping")
|
|
40
|
+
|
|
41
|
+
workspace_raw = raw.get("workspace", {})
|
|
42
|
+
root = workspace_raw.get("root", "./services")
|
|
43
|
+
log_dir = workspace_raw.get("log_dir", "./logs")
|
|
44
|
+
|
|
45
|
+
services_raw: Dict[str, Any] = raw.get("services", {})
|
|
46
|
+
groups_raw: Dict[str, Any] = raw.get("groups", {})
|
|
47
|
+
|
|
48
|
+
services = {
|
|
49
|
+
name: self._parse_service(name, svc_raw)
|
|
50
|
+
for name, svc_raw in services_raw.items()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
groups = {
|
|
54
|
+
name: self._parse_group(name, grp_raw)
|
|
55
|
+
for name, grp_raw in groups_raw.items()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return WorkspaceConfig(
|
|
59
|
+
root=root,
|
|
60
|
+
log_dir=log_dir,
|
|
61
|
+
services=services,
|
|
62
|
+
groups=groups,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Private helpers
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _parse_service(self, name: str, raw: Dict[str, Any]) -> Service:
|
|
70
|
+
runtime_str = raw.get("runtime", "custom")
|
|
71
|
+
try:
|
|
72
|
+
runtime = Runtime(runtime_str.lower())
|
|
73
|
+
except ValueError:
|
|
74
|
+
raise ConfigValidationError(
|
|
75
|
+
f"Service '{name}': unknown runtime '{runtime_str}'. "
|
|
76
|
+
f"Valid values: {[r.value for r in Runtime]}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return Service(
|
|
80
|
+
name=name,
|
|
81
|
+
runtime=runtime,
|
|
82
|
+
depends_on=raw.get("depends_on", []),
|
|
83
|
+
repo=raw.get("repo"),
|
|
84
|
+
branch=raw.get("branch", "main"),
|
|
85
|
+
dir=raw.get("dir"),
|
|
86
|
+
env=raw.get("env", {}),
|
|
87
|
+
env_files=self._parse_env_files(raw),
|
|
88
|
+
requires=self._parse_prerequisites(raw.get("requires")),
|
|
89
|
+
install=self._parse_install(raw.get("install")),
|
|
90
|
+
start=self._parse_start(raw.get("start")),
|
|
91
|
+
health_check=self._parse_health_check(name, raw.get("health_check")),
|
|
92
|
+
labels=raw.get("labels", {}),
|
|
93
|
+
description=raw.get("description", ""),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _parse_env_files(self, raw: Dict[str, Any]) -> list:
|
|
97
|
+
if "env_files" in raw:
|
|
98
|
+
value = raw["env_files"]
|
|
99
|
+
return [value] if isinstance(value, str) else list(value)
|
|
100
|
+
if "env_file" in raw:
|
|
101
|
+
value = raw["env_file"]
|
|
102
|
+
return [value] if value else []
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
def _parse_prerequisites(self, raw: Optional[Dict]) -> Optional[Prerequisites]:
|
|
106
|
+
if not raw:
|
|
107
|
+
return None
|
|
108
|
+
return Prerequisites(
|
|
109
|
+
java=raw.get("java"),
|
|
110
|
+
python=raw.get("python"),
|
|
111
|
+
node=raw.get("node"),
|
|
112
|
+
dotnet=raw.get("dotnet"),
|
|
113
|
+
commands=raw.get("commands", []),
|
|
114
|
+
env_vars=raw.get("env_vars", []),
|
|
115
|
+
folders=raw.get("folders", []),
|
|
116
|
+
files=raw.get("files", []),
|
|
117
|
+
ports_free=raw.get("ports_free", []),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _parse_install(self, raw: Optional[Any]) -> Optional[InstallConfig]:
|
|
121
|
+
if not raw:
|
|
122
|
+
return None
|
|
123
|
+
if isinstance(raw, str):
|
|
124
|
+
return InstallConfig(command=raw)
|
|
125
|
+
return InstallConfig(
|
|
126
|
+
command=raw["command"],
|
|
127
|
+
working_dir=raw.get("working_dir", "."),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _parse_start(self, raw: Optional[Any]) -> Optional[StartConfig]:
|
|
131
|
+
if not raw:
|
|
132
|
+
return None
|
|
133
|
+
if isinstance(raw, str):
|
|
134
|
+
return StartConfig(command=raw)
|
|
135
|
+
return StartConfig(
|
|
136
|
+
command=raw["command"],
|
|
137
|
+
args=raw.get("args", []),
|
|
138
|
+
working_dir=raw.get("working_dir", "."),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _parse_health_check(
|
|
142
|
+
self, service_name: str, raw: Optional[Dict]
|
|
143
|
+
) -> Optional[HealthCheckConfig]:
|
|
144
|
+
if not raw:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
type_str = raw.get("type", "process")
|
|
148
|
+
try:
|
|
149
|
+
hc_type = HealthCheckType(type_str.lower())
|
|
150
|
+
except ValueError:
|
|
151
|
+
raise ConfigValidationError(
|
|
152
|
+
f"Service '{service_name}': unknown health_check type '{type_str}'"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return HealthCheckConfig(
|
|
156
|
+
type=hc_type,
|
|
157
|
+
url=raw.get("url"),
|
|
158
|
+
host=raw.get("host", "localhost"),
|
|
159
|
+
port=raw.get("port"),
|
|
160
|
+
command=raw.get("command"),
|
|
161
|
+
expected_output=raw.get("expected_output"),
|
|
162
|
+
interval_seconds=raw.get("interval", 5),
|
|
163
|
+
timeout_seconds=raw.get("timeout", 60),
|
|
164
|
+
retries=raw.get("retries", 12),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _parse_group(self, name: str, raw: Dict[str, Any]) -> Group:
|
|
168
|
+
return Group(
|
|
169
|
+
name=name,
|
|
170
|
+
description=raw.get("description", ""),
|
|
171
|
+
services=raw.get("services", []),
|
|
172
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adapter: git operations via the system git binary (via subprocess).
|
|
3
|
+
Works on Windows with Git for Windows / GitBash.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ldc.ports.git_client import IGitClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SubprocessGitClient(IGitClient):
|
|
14
|
+
|
|
15
|
+
def clone(self, repo_url: str, dest: Path, branch: str = "main") -> None:
|
|
16
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
self._run(
|
|
18
|
+
["git", "clone", "--branch", branch, "--depth", "1", repo_url, dest.name],
|
|
19
|
+
cwd=str(dest.parent),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def pull(self, repo_dir: Path, branch: str = "main") -> None:
|
|
23
|
+
self._run(["git", "fetch", "origin"], cwd=str(repo_dir))
|
|
24
|
+
self._run(
|
|
25
|
+
["git", "checkout", branch], cwd=str(repo_dir)
|
|
26
|
+
)
|
|
27
|
+
self._run(
|
|
28
|
+
["git", "pull", "origin", branch, "--ff-only"],
|
|
29
|
+
cwd=str(repo_dir),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def is_cloned(self, dest: Path) -> bool:
|
|
33
|
+
git_dir = dest / ".git"
|
|
34
|
+
return git_dir.exists()
|
|
35
|
+
|
|
36
|
+
def current_branch(self, repo_dir: Path) -> str:
|
|
37
|
+
result = self._run(
|
|
38
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
39
|
+
cwd=str(repo_dir),
|
|
40
|
+
capture=True,
|
|
41
|
+
)
|
|
42
|
+
return result.stdout.strip()
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def _run(
|
|
47
|
+
self,
|
|
48
|
+
cmd: list,
|
|
49
|
+
cwd: str,
|
|
50
|
+
capture: bool = False,
|
|
51
|
+
) -> subprocess.CompletedProcess:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
cmd,
|
|
54
|
+
cwd=cwd,
|
|
55
|
+
capture_output=capture,
|
|
56
|
+
text=True,
|
|
57
|
+
check=False,
|
|
58
|
+
)
|
|
59
|
+
if result.returncode != 0:
|
|
60
|
+
stderr = result.stderr.strip() if capture else ""
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"git command failed (exit {result.returncode}): {' '.join(cmd)}\n{stderr}"
|
|
63
|
+
)
|
|
64
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Adapter: health check via arbitrary command exit code / output."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from ldc.domain.models import HealthCheckConfig, HealthCheckType
|
|
8
|
+
from ldc.ports.health_checker import IHealthChecker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CommandHealthChecker(IHealthChecker):
|
|
12
|
+
|
|
13
|
+
def supports(self, config: HealthCheckConfig) -> bool:
|
|
14
|
+
return config.type == HealthCheckType.COMMAND
|
|
15
|
+
|
|
16
|
+
def check(self, config: HealthCheckConfig) -> bool:
|
|
17
|
+
if not config.command:
|
|
18
|
+
return False
|
|
19
|
+
try:
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
config.command,
|
|
22
|
+
shell=True,
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
timeout=10,
|
|
26
|
+
)
|
|
27
|
+
if result.returncode != 0:
|
|
28
|
+
return False
|
|
29
|
+
if config.expected_output:
|
|
30
|
+
return config.expected_output in (result.stdout + result.stderr)
|
|
31
|
+
return True
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def wait_healthy(self, config: HealthCheckConfig) -> bool:
|
|
36
|
+
deadline = time.monotonic() + config.timeout_seconds
|
|
37
|
+
while time.monotonic() < deadline:
|
|
38
|
+
if self.check(config):
|
|
39
|
+
return True
|
|
40
|
+
time.sleep(config.interval_seconds)
|
|
41
|
+
return False
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adapter: dispatches health checks to the appropriate concrete checker
|
|
3
|
+
based on HealthCheckConfig.type.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from ldc.domain.models import HealthCheckConfig
|
|
11
|
+
from ldc.ports.health_checker import IHealthChecker
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CompositeHealthChecker(IHealthChecker):
|
|
15
|
+
"""Routes to the first registered checker that supports the config type."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, checkers: List[IHealthChecker]) -> None:
|
|
18
|
+
self._checkers = checkers
|
|
19
|
+
|
|
20
|
+
def supports(self, config: HealthCheckConfig) -> bool:
|
|
21
|
+
return any(c.supports(config) for c in self._checkers)
|
|
22
|
+
|
|
23
|
+
def check(self, config: HealthCheckConfig) -> bool:
|
|
24
|
+
checker = self._find(config)
|
|
25
|
+
if checker is None:
|
|
26
|
+
return False
|
|
27
|
+
return checker.check(config)
|
|
28
|
+
|
|
29
|
+
def wait_healthy(self, config: HealthCheckConfig) -> bool:
|
|
30
|
+
checker = self._find(config)
|
|
31
|
+
if checker is None:
|
|
32
|
+
return False
|
|
33
|
+
return checker.wait_healthy(config)
|
|
34
|
+
|
|
35
|
+
def _find(self, config: HealthCheckConfig) -> "IHealthChecker | None":
|
|
36
|
+
for c in self._checkers:
|
|
37
|
+
if c.supports(config):
|
|
38
|
+
return c
|
|
39
|
+
return None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Adapter: HTTP/HTTPS health check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
import urllib.request
|
|
6
|
+
import urllib.error
|
|
7
|
+
|
|
8
|
+
from ldc.domain.models import HealthCheckConfig, HealthCheckType
|
|
9
|
+
from ldc.ports.health_checker import IHealthChecker
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HttpHealthChecker(IHealthChecker):
|
|
13
|
+
|
|
14
|
+
def supports(self, config: HealthCheckConfig) -> bool:
|
|
15
|
+
return config.type == HealthCheckType.HTTP
|
|
16
|
+
|
|
17
|
+
def check(self, config: HealthCheckConfig) -> bool:
|
|
18
|
+
try:
|
|
19
|
+
req = urllib.request.Request(config.url)
|
|
20
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
21
|
+
return 200 <= resp.status < 400
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
def wait_healthy(self, config: HealthCheckConfig) -> bool:
|
|
26
|
+
deadline = time.monotonic() + config.timeout_seconds
|
|
27
|
+
while time.monotonic() < deadline:
|
|
28
|
+
if self.check(config):
|
|
29
|
+
return True
|
|
30
|
+
time.sleep(config.interval_seconds)
|
|
31
|
+
return False
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Adapter: health check that simply verifies the process is alive."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import psutil
|
|
7
|
+
|
|
8
|
+
from ldc.domain.models import HealthCheckConfig, HealthCheckType
|
|
9
|
+
from ldc.ports.health_checker import IHealthChecker
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProcessHealthChecker(IHealthChecker):
|
|
13
|
+
|
|
14
|
+
def __init__(self, pid_provider) -> None:
|
|
15
|
+
# pid_provider: callable(service_name) -> Optional[int]
|
|
16
|
+
self._pid_provider = pid_provider
|
|
17
|
+
|
|
18
|
+
def supports(self, config: HealthCheckConfig) -> bool:
|
|
19
|
+
return config.type == HealthCheckType.PROCESS
|
|
20
|
+
|
|
21
|
+
def check(self, config: HealthCheckConfig) -> bool:
|
|
22
|
+
# For PROCESS type we just need any live process — the caller supplies PID
|
|
23
|
+
# via the provider; config carries no PID directly.
|
|
24
|
+
# This checker is used as a fallback by the orchestrator passing PID explicitly.
|
|
25
|
+
return True # orchestrator checks is_alive() directly for PROCESS type
|
|
26
|
+
|
|
27
|
+
def wait_healthy(self, config: HealthCheckConfig) -> bool:
|
|
28
|
+
return True
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Adapter: TCP port health check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from ldc.domain.models import HealthCheckConfig, HealthCheckType
|
|
8
|
+
from ldc.ports.health_checker import IHealthChecker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TcpHealthChecker(IHealthChecker):
|
|
12
|
+
|
|
13
|
+
def supports(self, config: HealthCheckConfig) -> bool:
|
|
14
|
+
return config.type == HealthCheckType.TCP
|
|
15
|
+
|
|
16
|
+
def check(self, config: HealthCheckConfig) -> bool:
|
|
17
|
+
host = config.host or "localhost"
|
|
18
|
+
port = config.port
|
|
19
|
+
if not port:
|
|
20
|
+
return False
|
|
21
|
+
try:
|
|
22
|
+
with socket.create_connection((host, port), timeout=3):
|
|
23
|
+
return True
|
|
24
|
+
except (OSError, socket.timeout):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
def wait_healthy(self, config: HealthCheckConfig) -> bool:
|
|
28
|
+
deadline = time.monotonic() + config.timeout_seconds
|
|
29
|
+
while time.monotonic() < deadline:
|
|
30
|
+
if self.check(config):
|
|
31
|
+
return True
|
|
32
|
+
time.sleep(config.interval_seconds)
|
|
33
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adapter: checks host prerequisites (runtimes, commands, env vars, folders, files, ports).
|
|
3
|
+
|
|
4
|
+
Every check produces a CheckResult with a human-readable fix_hint so the
|
|
5
|
+
user always knows exactly what to do when something is missing.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import socket
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from ldc.domain.models import CheckResult, Prerequisites, PrerequisiteReport
|
|
18
|
+
from ldc.ports.prerequisite_checker import IPrerequisiteChecker
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SystemPrerequisiteChecker(IPrerequisiteChecker):
|
|
22
|
+
|
|
23
|
+
def check(self, service_name: str, prereqs: Prerequisites) -> PrerequisiteReport:
|
|
24
|
+
results: List[CheckResult] = []
|
|
25
|
+
|
|
26
|
+
if prereqs.java:
|
|
27
|
+
results.append(self._check_runtime("java", prereqs.java))
|
|
28
|
+
if prereqs.python:
|
|
29
|
+
results.append(self._check_runtime("python", prereqs.python))
|
|
30
|
+
if prereqs.node:
|
|
31
|
+
results.append(self._check_runtime("node", prereqs.node))
|
|
32
|
+
if prereqs.dotnet:
|
|
33
|
+
results.append(self._check_runtime("dotnet", prereqs.dotnet))
|
|
34
|
+
|
|
35
|
+
for cmd in prereqs.commands:
|
|
36
|
+
results.append(self._check_command(cmd))
|
|
37
|
+
|
|
38
|
+
for var in prereqs.env_vars:
|
|
39
|
+
results.append(self._check_env_var(var))
|
|
40
|
+
|
|
41
|
+
for folder in prereqs.folders:
|
|
42
|
+
results.append(self._check_folder(folder))
|
|
43
|
+
|
|
44
|
+
for filepath in prereqs.files:
|
|
45
|
+
results.append(self._check_file(filepath))
|
|
46
|
+
|
|
47
|
+
for port in prereqs.ports_free:
|
|
48
|
+
results.append(self._check_port_free(port))
|
|
49
|
+
|
|
50
|
+
return PrerequisiteReport(service_name=service_name, checks=results)
|
|
51
|
+
|
|
52
|
+
def auto_fix(self, service_name: str, prereqs: Prerequisites) -> PrerequisiteReport:
|
|
53
|
+
"""Only auto-fixable action: create missing folders."""
|
|
54
|
+
for folder in prereqs.folders:
|
|
55
|
+
try:
|
|
56
|
+
Path(folder).mkdir(parents=True, exist_ok=True)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
return self.check(service_name, prereqs)
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Individual checks
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def _check_runtime(self, runtime: str, constraint: str) -> CheckResult:
|
|
66
|
+
binary_map = {
|
|
67
|
+
"java": ("java", "-version"),
|
|
68
|
+
"python": ("python", "--version"),
|
|
69
|
+
"node": ("node", "--version"),
|
|
70
|
+
"dotnet": ("dotnet", "--version"),
|
|
71
|
+
}
|
|
72
|
+
binary, flag = binary_map[runtime]
|
|
73
|
+
installed_ver = self._get_version(binary, flag)
|
|
74
|
+
|
|
75
|
+
if installed_ver is None:
|
|
76
|
+
return CheckResult(
|
|
77
|
+
name=f"{runtime} runtime",
|
|
78
|
+
passed=False,
|
|
79
|
+
message=f"'{binary}' not found on PATH",
|
|
80
|
+
fix_hint=self._runtime_install_hint(runtime),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
satisfied, reason = self._version_satisfies(installed_ver, constraint)
|
|
84
|
+
return CheckResult(
|
|
85
|
+
name=f"{runtime} runtime",
|
|
86
|
+
passed=satisfied,
|
|
87
|
+
message=f"{runtime} {installed_ver} — required: {constraint}",
|
|
88
|
+
fix_hint=None if satisfied else self._runtime_install_hint(runtime),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _check_command(self, cmd: str) -> CheckResult:
|
|
92
|
+
found = shutil.which(cmd) is not None
|
|
93
|
+
return CheckResult(
|
|
94
|
+
name=f"command '{cmd}'",
|
|
95
|
+
passed=found,
|
|
96
|
+
message=f"'{cmd}' {'found on PATH' if found else 'NOT found on PATH'}",
|
|
97
|
+
fix_hint=(
|
|
98
|
+
None
|
|
99
|
+
if found
|
|
100
|
+
else f"Install '{cmd}' and ensure it is on your PATH. "
|
|
101
|
+
f"On Windows you can check: where {cmd}"
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _check_env_var(self, var: str) -> CheckResult:
|
|
106
|
+
value = os.environ.get(var)
|
|
107
|
+
present = value is not None and value.strip() != ""
|
|
108
|
+
return CheckResult(
|
|
109
|
+
name=f"env var '{var}'",
|
|
110
|
+
passed=present,
|
|
111
|
+
message=f"${var} {'is set' if present else 'is NOT set'}",
|
|
112
|
+
fix_hint=(
|
|
113
|
+
None
|
|
114
|
+
if present
|
|
115
|
+
else f"Set the environment variable: setx {var} \"<value>\" "
|
|
116
|
+
f"(system-wide) or add it to your .env file"
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _check_folder(self, folder: str) -> CheckResult:
|
|
121
|
+
path = Path(folder)
|
|
122
|
+
exists = path.exists() and path.is_dir()
|
|
123
|
+
return CheckResult(
|
|
124
|
+
name=f"folder '{folder}'",
|
|
125
|
+
passed=exists,
|
|
126
|
+
message=f"'{folder}' {'exists' if exists else 'does NOT exist'}",
|
|
127
|
+
fix_hint=(
|
|
128
|
+
None
|
|
129
|
+
if exists
|
|
130
|
+
else f"Create the folder: mkdir -p \"{folder}\" "
|
|
131
|
+
f"(or run: ldc check --fix to auto-create)"
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _check_file(self, filepath: str) -> CheckResult:
|
|
136
|
+
path = Path(filepath)
|
|
137
|
+
exists = path.exists() and path.is_file()
|
|
138
|
+
return CheckResult(
|
|
139
|
+
name=f"file '{filepath}'",
|
|
140
|
+
passed=exists,
|
|
141
|
+
message=f"'{filepath}' {'exists' if exists else 'does NOT exist'}",
|
|
142
|
+
fix_hint=(
|
|
143
|
+
None
|
|
144
|
+
if exists
|
|
145
|
+
else f"Create or copy the required file to: {filepath}"
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _check_port_free(self, port: int) -> CheckResult:
|
|
150
|
+
in_use = self._is_port_in_use(port)
|
|
151
|
+
return CheckResult(
|
|
152
|
+
name=f"port {port} free",
|
|
153
|
+
passed=not in_use,
|
|
154
|
+
message=f"Port {port} is {'in use' if in_use else 'free'}",
|
|
155
|
+
fix_hint=(
|
|
156
|
+
None
|
|
157
|
+
if not in_use
|
|
158
|
+
else f"Port {port} is occupied. Find the process: "
|
|
159
|
+
f"netstat -ano | findstr :{port} "
|
|
160
|
+
f"then terminate it or change the service port."
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Version utilities
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def _get_version(self, binary: str, flag: str) -> Optional[str]:
|
|
169
|
+
try:
|
|
170
|
+
result = subprocess.run(
|
|
171
|
+
[binary, flag],
|
|
172
|
+
capture_output=True,
|
|
173
|
+
text=True,
|
|
174
|
+
timeout=10,
|
|
175
|
+
)
|
|
176
|
+
# java -version prints to stderr; others to stdout
|
|
177
|
+
output = (result.stdout + result.stderr).strip()
|
|
178
|
+
return self._extract_version(output)
|
|
179
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
_VERSION_RE = re.compile(r"(\d+)(?:\.(\d+))?(?:\.(\d+))?")
|
|
183
|
+
|
|
184
|
+
def _extract_version(self, text: str) -> Optional[str]:
|
|
185
|
+
m = self._VERSION_RE.search(text)
|
|
186
|
+
if not m:
|
|
187
|
+
return None
|
|
188
|
+
parts = [m.group(i) or "0" for i in (1, 2, 3)]
|
|
189
|
+
return ".".join(parts)
|
|
190
|
+
|
|
191
|
+
def _version_satisfies(
|
|
192
|
+
self, installed: str, constraint: str
|
|
193
|
+
) -> Tuple[bool, str]:
|
|
194
|
+
"""Evaluate simple constraints like '>=17', '>11', '==3.9'."""
|
|
195
|
+
m = re.match(r"^(>=|>|<=|<|==)(\d+(?:\.\d+)?(?:\.\d+)?)$", constraint.strip())
|
|
196
|
+
if not m:
|
|
197
|
+
return True, "unparseable constraint — skipped"
|
|
198
|
+
|
|
199
|
+
op, required_str = m.group(1), m.group(2)
|
|
200
|
+
inst_tuple = self._ver_tuple(installed)
|
|
201
|
+
req_tuple = self._ver_tuple(required_str)
|
|
202
|
+
|
|
203
|
+
ops = {
|
|
204
|
+
">=": inst_tuple >= req_tuple,
|
|
205
|
+
">": inst_tuple > req_tuple,
|
|
206
|
+
"<=": inst_tuple <= req_tuple,
|
|
207
|
+
"<": inst_tuple < req_tuple,
|
|
208
|
+
"==": inst_tuple == req_tuple,
|
|
209
|
+
}
|
|
210
|
+
satisfied = ops[op]
|
|
211
|
+
return satisfied, f"{installed} {op} {required_str}"
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _ver_tuple(ver: str) -> tuple:
|
|
215
|
+
parts = ver.split(".")
|
|
216
|
+
result = []
|
|
217
|
+
for p in parts[:3]:
|
|
218
|
+
try:
|
|
219
|
+
result.append(int(p))
|
|
220
|
+
except ValueError:
|
|
221
|
+
result.append(0)
|
|
222
|
+
while len(result) < 3:
|
|
223
|
+
result.append(0)
|
|
224
|
+
return tuple(result)
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _is_port_in_use(port: int) -> bool:
|
|
228
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
229
|
+
try:
|
|
230
|
+
s.bind(("127.0.0.1", port))
|
|
231
|
+
return False
|
|
232
|
+
except OSError:
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _runtime_install_hint(runtime: str) -> str:
|
|
237
|
+
hints = {
|
|
238
|
+
"java": (
|
|
239
|
+
"Install JDK and add JAVA_HOME to system env vars. "
|
|
240
|
+
"Download from: https://adoptium.net "
|
|
241
|
+
"Then: setx JAVA_HOME \"C:\\Program Files\\Eclipse Adoptium\\jdk-XX\""
|
|
242
|
+
),
|
|
243
|
+
"python": (
|
|
244
|
+
"Install Python from https://python.org/downloads "
|
|
245
|
+
"and ensure 'Add Python to PATH' is checked during setup."
|
|
246
|
+
),
|
|
247
|
+
"node": (
|
|
248
|
+
"Install Node.js from https://nodejs.org "
|
|
249
|
+
"and ensure the installer adds it to PATH."
|
|
250
|
+
),
|
|
251
|
+
"dotnet": (
|
|
252
|
+
"Install .NET SDK from https://dotnet.microsoft.com/download "
|
|
253
|
+
"and restart your terminal."
|
|
254
|
+
),
|
|
255
|
+
}
|
|
256
|
+
return hints.get(runtime, f"Install '{runtime}' and add it to PATH.")
|