local-dev-composer 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.
- local_dev_composer-0.1.0/PKG-INFO +88 -0
- local_dev_composer-0.1.0/README.md +59 -0
- local_dev_composer-0.1.0/pyproject.toml +60 -0
- local_dev_composer-0.1.0/setup.cfg +4 -0
- local_dev_composer-0.1.0/src/ldc/__init__.py +3 -0
- local_dev_composer-0.1.0/src/ldc/__main__.py +5 -0
- local_dev_composer-0.1.0/src/ldc/adapters/__init__.py +0 -0
- local_dev_composer-0.1.0/src/ldc/adapters/config/__init__.py +0 -0
- local_dev_composer-0.1.0/src/ldc/adapters/config/yaml_reader.py +172 -0
- local_dev_composer-0.1.0/src/ldc/adapters/git/__init__.py +0 -0
- local_dev_composer-0.1.0/src/ldc/adapters/git/subprocess_client.py +64 -0
- local_dev_composer-0.1.0/src/ldc/adapters/health/__init__.py +0 -0
- local_dev_composer-0.1.0/src/ldc/adapters/health/command_checker.py +41 -0
- local_dev_composer-0.1.0/src/ldc/adapters/health/composite_checker.py +39 -0
- local_dev_composer-0.1.0/src/ldc/adapters/health/http_checker.py +31 -0
- local_dev_composer-0.1.0/src/ldc/adapters/health/process_checker.py +28 -0
- local_dev_composer-0.1.0/src/ldc/adapters/health/tcp_checker.py +33 -0
- local_dev_composer-0.1.0/src/ldc/adapters/prerequisites/__init__.py +0 -0
- local_dev_composer-0.1.0/src/ldc/adapters/prerequisites/system_checker.py +256 -0
- local_dev_composer-0.1.0/src/ldc/adapters/process/__init__.py +0 -0
- local_dev_composer-0.1.0/src/ldc/adapters/process/windows_runner.py +143 -0
- local_dev_composer-0.1.0/src/ldc/adapters/reporting/__init__.py +0 -0
- local_dev_composer-0.1.0/src/ldc/adapters/reporting/rich_reporter.py +211 -0
- local_dev_composer-0.1.0/src/ldc/adapters/state/__init__.py +0 -0
- local_dev_composer-0.1.0/src/ldc/adapters/state/json_store.py +64 -0
- local_dev_composer-0.1.0/src/ldc/application/__init__.py +1 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/__init__.py +0 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/bootstrap.py +91 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/check.py +65 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/clone.py +77 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/doctor.py +115 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/down.py +61 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/env.py +94 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/install.py +61 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/logs.py +74 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/rebuild.py +86 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/restart.py +65 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/status.py +36 -0
- local_dev_composer-0.1.0/src/ldc/application/commands/up.py +229 -0
- local_dev_composer-0.1.0/src/ldc/application/container.py +83 -0
- local_dev_composer-0.1.0/src/ldc/application/env_resolver.py +61 -0
- local_dev_composer-0.1.0/src/ldc/application/installer_service.py +41 -0
- local_dev_composer-0.1.0/src/ldc/cli.py +242 -0
- local_dev_composer-0.1.0/src/ldc/domain/__init__.py +1 -0
- local_dev_composer-0.1.0/src/ldc/domain/exceptions.py +29 -0
- local_dev_composer-0.1.0/src/ldc/domain/graph.py +149 -0
- local_dev_composer-0.1.0/src/ldc/domain/models.py +172 -0
- local_dev_composer-0.1.0/src/ldc/ports/__init__.py +1 -0
- local_dev_composer-0.1.0/src/ldc/ports/config_reader.py +11 -0
- local_dev_composer-0.1.0/src/ldc/ports/git_client.py +22 -0
- local_dev_composer-0.1.0/src/ldc/ports/health_checker.py +28 -0
- local_dev_composer-0.1.0/src/ldc/ports/installer.py +24 -0
- local_dev_composer-0.1.0/src/ldc/ports/prerequisite_checker.py +23 -0
- local_dev_composer-0.1.0/src/ldc/ports/process_runner.py +39 -0
- local_dev_composer-0.1.0/src/ldc/ports/reporter.py +43 -0
- local_dev_composer-0.1.0/src/ldc/ports/state_store.py +20 -0
- local_dev_composer-0.1.0/src/local_dev_composer.egg-info/PKG-INFO +88 -0
- local_dev_composer-0.1.0/src/local_dev_composer.egg-info/SOURCES.txt +60 -0
- local_dev_composer-0.1.0/src/local_dev_composer.egg-info/dependency_links.txt +1 -0
- local_dev_composer-0.1.0/src/local_dev_composer.egg-info/entry_points.txt +2 -0
- local_dev_composer-0.1.0/src/local_dev_composer.egg-info/requires.txt +7 -0
- local_dev_composer-0.1.0/src/local_dev_composer.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: local-dev-composer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Orchestrate local microservice environments on Windows without Docker or WSL
|
|
5
|
+
Author: Sergii Bugera
|
|
6
|
+
Project-URL: Homepage, https://github.com/sbugera/local-dev-composer
|
|
7
|
+
Project-URL: Repository, https://github.com/sbugera/local-dev-composer
|
|
8
|
+
Project-URL: Issues, https://github.com/sbugera/local-dev-composer/issues
|
|
9
|
+
Keywords: microservices,local-development,developer-tools,process-manager,windows,devtools
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
+
Classifier: Topic :: System :: Systems Administration
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Requires-Dist: pyyaml>=6.0
|
|
25
|
+
Requires-Dist: psutil>=5.9.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# local-dev-composer (ldc)
|
|
31
|
+
|
|
32
|
+
Orchestrate local microservice environments on **Windows 11** without Docker, Podman, or WSL.
|
|
33
|
+
|
|
34
|
+
- Start a full service graph with one command
|
|
35
|
+
- Per-service environment isolation (same variable, different values per service)
|
|
36
|
+
- Dependency ordering — services start leaf-first, stop dependents-first
|
|
37
|
+
- Prerequisite checks with actionable fix hints (Java version, PATH commands, env vars, ports, folders)
|
|
38
|
+
- Live Rich terminal dashboard
|
|
39
|
+
- Groups — declare the minimum set of services per development scenario
|
|
40
|
+
- Survives restarts — reconciles live PIDs from `.ldc/state.json`
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/sbugera/local-dev-composer.git
|
|
46
|
+
cd local-dev-composer
|
|
47
|
+
pip install -e .
|
|
48
|
+
|
|
49
|
+
cp composer.example.yml composer.yml
|
|
50
|
+
# edit composer.yml for your project
|
|
51
|
+
|
|
52
|
+
ldc doctor # check everything, get a numbered fix list
|
|
53
|
+
ldc clone # clone all repos
|
|
54
|
+
ldc check --fix # verify and fix prerequisites
|
|
55
|
+
ldc install # run install commands
|
|
56
|
+
ldc up --group gateway-dev # start minimum services for your task
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Daily use
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
ldc up --group gateway-dev # start what you need
|
|
63
|
+
ldc status # service table with PIDs and health
|
|
64
|
+
ldc logs gateway -f # follow a service log
|
|
65
|
+
ldc down # stop everything
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Documentation
|
|
69
|
+
|
|
70
|
+
| topic | description |
|
|
71
|
+
|-------|-------------|
|
|
72
|
+
| [Installation](docs/installation.md) | Setup, direct-script mode |
|
|
73
|
+
| [Configuration](docs/configuration.md) | Full `composer.yml` schema reference |
|
|
74
|
+
| [Commands](docs/commands.md) | All CLI commands with options |
|
|
75
|
+
| [Groups](docs/groups.md) | Named service sets, smart selection |
|
|
76
|
+
| [Environment](docs/environment.md) | Per-service env isolation, `.env` files |
|
|
77
|
+
| [Prerequisites](docs/prerequisites.md) | Runtime/command/folder/port checks |
|
|
78
|
+
| [Health Checks](docs/health-checks.md) | HTTP, TCP, command, process types |
|
|
79
|
+
| [State & Logs](docs/state-and-logs.md) | `.ldc/state.json`, log files, clearing state |
|
|
80
|
+
| [Architecture](docs/architecture.md) | Hexagonal design, layers, extending ldc |
|
|
81
|
+
| [Testing](docs/testing.md) | Running tests, writing new ones |
|
|
82
|
+
|
|
83
|
+
## Requirements
|
|
84
|
+
|
|
85
|
+
- Python 3.9+
|
|
86
|
+
- Git for Windows
|
|
87
|
+
- Windows 11
|
|
88
|
+
- No Docker, no WSL
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# local-dev-composer (ldc)
|
|
2
|
+
|
|
3
|
+
Orchestrate local microservice environments on **Windows 11** without Docker, Podman, or WSL.
|
|
4
|
+
|
|
5
|
+
- Start a full service graph with one command
|
|
6
|
+
- Per-service environment isolation (same variable, different values per service)
|
|
7
|
+
- Dependency ordering — services start leaf-first, stop dependents-first
|
|
8
|
+
- Prerequisite checks with actionable fix hints (Java version, PATH commands, env vars, ports, folders)
|
|
9
|
+
- Live Rich terminal dashboard
|
|
10
|
+
- Groups — declare the minimum set of services per development scenario
|
|
11
|
+
- Survives restarts — reconciles live PIDs from `.ldc/state.json`
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/sbugera/local-dev-composer.git
|
|
17
|
+
cd local-dev-composer
|
|
18
|
+
pip install -e .
|
|
19
|
+
|
|
20
|
+
cp composer.example.yml composer.yml
|
|
21
|
+
# edit composer.yml for your project
|
|
22
|
+
|
|
23
|
+
ldc doctor # check everything, get a numbered fix list
|
|
24
|
+
ldc clone # clone all repos
|
|
25
|
+
ldc check --fix # verify and fix prerequisites
|
|
26
|
+
ldc install # run install commands
|
|
27
|
+
ldc up --group gateway-dev # start minimum services for your task
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Daily use
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
ldc up --group gateway-dev # start what you need
|
|
34
|
+
ldc status # service table with PIDs and health
|
|
35
|
+
ldc logs gateway -f # follow a service log
|
|
36
|
+
ldc down # stop everything
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Documentation
|
|
40
|
+
|
|
41
|
+
| topic | description |
|
|
42
|
+
|-------|-------------|
|
|
43
|
+
| [Installation](docs/installation.md) | Setup, direct-script mode |
|
|
44
|
+
| [Configuration](docs/configuration.md) | Full `composer.yml` schema reference |
|
|
45
|
+
| [Commands](docs/commands.md) | All CLI commands with options |
|
|
46
|
+
| [Groups](docs/groups.md) | Named service sets, smart selection |
|
|
47
|
+
| [Environment](docs/environment.md) | Per-service env isolation, `.env` files |
|
|
48
|
+
| [Prerequisites](docs/prerequisites.md) | Runtime/command/folder/port checks |
|
|
49
|
+
| [Health Checks](docs/health-checks.md) | HTTP, TCP, command, process types |
|
|
50
|
+
| [State & Logs](docs/state-and-logs.md) | `.ldc/state.json`, log files, clearing state |
|
|
51
|
+
| [Architecture](docs/architecture.md) | Hexagonal design, layers, extending ldc |
|
|
52
|
+
| [Testing](docs/testing.md) | Running tests, writing new ones |
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
- Python 3.9+
|
|
57
|
+
- Git for Windows
|
|
58
|
+
- Windows 11
|
|
59
|
+
- No Docker, no WSL
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "local-dev-composer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Orchestrate local microservice environments on Windows without Docker or WSL"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "Sergii Bugera"},
|
|
13
|
+
]
|
|
14
|
+
keywords = [
|
|
15
|
+
"microservices", "local-development", "developer-tools",
|
|
16
|
+
"process-manager", "windows", "devtools",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: Microsoft :: Windows",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Topic :: Software Development :: Build Tools",
|
|
29
|
+
"Topic :: System :: Systems Administration",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"rich>=13.0.0",
|
|
33
|
+
"pyyaml>=6.0",
|
|
34
|
+
"psutil>=5.9.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/sbugera/local-dev-composer"
|
|
39
|
+
Repository = "https://github.com/sbugera/local-dev-composer"
|
|
40
|
+
Issues = "https://github.com/sbugera/local-dev-composer/issues"
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=7.0",
|
|
45
|
+
"pytest-cov>=4.0",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.scripts]
|
|
49
|
+
ldc = "ldc.cli:main"
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.packages.find]
|
|
52
|
+
where = ["src"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
pythonpath = ["src"]
|
|
57
|
+
addopts = "--import-mode=importlib"
|
|
58
|
+
|
|
59
|
+
[tool.coverage.run]
|
|
60
|
+
source = ["src/ldc"]
|
|
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
|