redeploy 0.2.33__tar.gz → 0.2.35__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.
- {redeploy-0.2.33 → redeploy-0.2.35}/PKG-INFO +6 -6
- {redeploy-0.2.33 → redeploy-0.2.35}/README.md +5 -5
- {redeploy-0.2.33 → redeploy-0.2.35}/pyproject.toml +1 -1
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/__init__.py +1 -1
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/handlers.py +4 -0
- redeploy-0.2.35/redeploy/blueprint/__init__.py +11 -0
- redeploy-0.2.35/redeploy/blueprint/extractor.py +328 -0
- redeploy-0.2.35/redeploy/blueprint/generators/docker_compose.py +190 -0
- redeploy-0.2.35/redeploy/blueprint/generators/migration.py +179 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/__init__.py +6 -0
- redeploy-0.2.35/redeploy/cli/commands/blueprint.py +298 -0
- redeploy-0.2.35/redeploy/cli/commands/device_map.py +376 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/models.py +230 -0
- redeploy-0.2.35/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/PKG-INFO +6 -6
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/SOURCES.txt +7 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/exceptions.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/executor.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/progress.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/rollback.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/state.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/audit.py +0 -0
- {redeploy-0.2.33/redeploy/tests → redeploy-0.2.35/redeploy/blueprint/generators}/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/audit.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/detect.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/devices.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/diagnose.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/diff.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/exec_.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/export.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/hardware.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/import_.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/init.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/inspect.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/patterns.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/plan_apply.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/plugin.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/probe.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/state.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/status.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/target.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/version.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/workflow.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/core.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/display.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/hardware.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/discovery.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/fleet.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/markpact/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/markpact/compiler.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/markpact/models.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/markpact/parser.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/observe.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/parse.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/patterns.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/process_control.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/spec_loader.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/ssh.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/steps.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_audit.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_cli.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_markpact_compiler.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_markpact_parser.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_resume.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_spec_loader.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/verify.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/requires.txt +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/setup.cfg +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/tests/test_iac.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/tests/test_parse.py +0 -0
- {redeploy-0.2.33 → redeploy-0.2.35}/tests/test_redeploy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redeploy
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.35
|
|
4
4
|
Summary: Infrastructure migration toolkit: detect → plan → apply
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -27,17 +27,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
|
|
|
27
27
|
|
|
28
28
|
## AI Cost Tracking
|
|
29
29
|
|
|
30
|
-
     
|
|
31
|
+
  
|
|
32
32
|
|
|
33
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
34
|
-
- 👤 **Human dev:** ~$
|
|
33
|
+
- 🤖 **LLM usage:** $7.5000 (56 commits)
|
|
34
|
+
- 👤 **Human dev:** ~$1796 (18.0h @ $100/h, 30min dedup)
|
|
35
35
|
|
|
36
36
|
Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
40
|
-
    
|
|
41
41
|
|
|
42
42
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
43
43
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
     
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $7.5000 (56 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$1796 (18.0h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
12
|
Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
    
|
|
17
17
|
|
|
18
18
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
19
19
|
|
|
@@ -7,7 +7,7 @@ Public API (stable from 0.2.0, semver guaranteed)::
|
|
|
7
7
|
|
|
8
8
|
Everything not listed in ``__all__`` is internal and may change without notice.
|
|
9
9
|
"""
|
|
10
|
-
__version__ = "0.2.
|
|
10
|
+
__version__ = "0.2.35"
|
|
11
11
|
|
|
12
12
|
# ── Core models ───────────────────────────────────────────────────────────────
|
|
13
13
|
from .models import ( # noqa: F401
|
|
@@ -36,6 +36,10 @@ def run_scp(step: MigrationStep, probe: RemoteProbe, plan: MigrationPlan) -> Non
|
|
|
36
36
|
"""Copy file via SCP."""
|
|
37
37
|
if not step.src or not step.dst:
|
|
38
38
|
raise StepError(step, "scp requires src and dst")
|
|
39
|
+
if probe.is_local and Path(step.src).resolve() == Path(step.dst).resolve():
|
|
40
|
+
step.status = StepStatus.DONE
|
|
41
|
+
step.result = "skipped (same file)"
|
|
42
|
+
return
|
|
39
43
|
if probe.is_local:
|
|
40
44
|
cmd = ["cp", step.src, step.dst]
|
|
41
45
|
else:
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Blueprint subsystem — extract, generate and apply DeviceBlueprints.
|
|
2
|
+
|
|
3
|
+
Public API::
|
|
4
|
+
|
|
5
|
+
from redeploy.blueprint import extract_blueprint, generate_twin, generate_migration
|
|
6
|
+
"""
|
|
7
|
+
from .extractor import extract_blueprint
|
|
8
|
+
from .generators.docker_compose import generate_twin
|
|
9
|
+
from .generators.migration import generate_migration
|
|
10
|
+
|
|
11
|
+
__all__ = ["extract_blueprint", "generate_twin", "generate_migration"]
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Blueprint extractor — derive a DeviceBlueprint from multiple sources.
|
|
2
|
+
|
|
3
|
+
Sources (in order of priority):
|
|
4
|
+
1. Running InfraState (podman ps / docker inspect on live device)
|
|
5
|
+
2. DeviceMap hardware snapshot
|
|
6
|
+
3. docker-compose YAML files
|
|
7
|
+
4. markpact / migration YAML definitions
|
|
8
|
+
|
|
9
|
+
The extractor reconciles all available information into a single
|
|
10
|
+
DeviceBlueprint that is self-contained and portable.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from ..models import (
|
|
21
|
+
BlueprintSource,
|
|
22
|
+
DeviceBlueprint,
|
|
23
|
+
DeviceMap,
|
|
24
|
+
HardwareRequirements,
|
|
25
|
+
InfraState,
|
|
26
|
+
ServicePort,
|
|
27
|
+
ServiceSpec,
|
|
28
|
+
VolumeMount,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def extract_blueprint(
|
|
33
|
+
*,
|
|
34
|
+
name: str,
|
|
35
|
+
device_map: Optional[DeviceMap] = None,
|
|
36
|
+
infra: Optional[InfraState] = None,
|
|
37
|
+
compose_files: Optional[list[Path]] = None,
|
|
38
|
+
migration_file: Optional[Path] = None,
|
|
39
|
+
markpact_files: Optional[list[Path]] = None,
|
|
40
|
+
version: str = "0.0.0",
|
|
41
|
+
description: str = "",
|
|
42
|
+
tags: Optional[list[str]] = None,
|
|
43
|
+
detect_live: bool = False,
|
|
44
|
+
host: Optional[str] = None,
|
|
45
|
+
) -> DeviceBlueprint:
|
|
46
|
+
"""Build a DeviceBlueprint by reconciling all available sources.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
name:
|
|
51
|
+
Human-readable blueprint name, e.g. ``"c2004-kiosk"``.
|
|
52
|
+
device_map:
|
|
53
|
+
Previously saved DeviceMap (hardware + infra snapshot).
|
|
54
|
+
infra:
|
|
55
|
+
InfraState if already probed (skips live detection).
|
|
56
|
+
compose_files:
|
|
57
|
+
Local docker-compose YAML files to parse service specs from.
|
|
58
|
+
migration_file:
|
|
59
|
+
Local migration.yaml to extract app version and strategy from.
|
|
60
|
+
markpact_files:
|
|
61
|
+
Local markdown files with markpact fenced blocks.
|
|
62
|
+
detect_live:
|
|
63
|
+
If True and *host* is given, probe the host via SSH for a fresh
|
|
64
|
+
InfraState even if *device_map* already has one.
|
|
65
|
+
host:
|
|
66
|
+
SSH target used when ``detect_live=True``.
|
|
67
|
+
"""
|
|
68
|
+
if detect_live and host and not infra:
|
|
69
|
+
from ..detect import Detector
|
|
70
|
+
infra = infra or Detector(host).run()
|
|
71
|
+
|
|
72
|
+
# Prefer freshly probed infra, fall back to device_map.infra
|
|
73
|
+
_infra: Optional[InfraState] = infra or (device_map.infra if device_map else None)
|
|
74
|
+
_hw = device_map.hardware if device_map else None
|
|
75
|
+
|
|
76
|
+
services: list[ServiceSpec] = []
|
|
77
|
+
seen: set[str] = set()
|
|
78
|
+
|
|
79
|
+
# ── 1. Live InfraState (most accurate) ────────────────────────────────────
|
|
80
|
+
if _infra:
|
|
81
|
+
for svcs in _infra.services.values():
|
|
82
|
+
for svc in svcs:
|
|
83
|
+
if svc.name in seen:
|
|
84
|
+
continue
|
|
85
|
+
seen.add(svc.name)
|
|
86
|
+
services.append(ServiceSpec(
|
|
87
|
+
name=svc.name,
|
|
88
|
+
image=getattr(svc, "image", ""),
|
|
89
|
+
platform="", # unknown at this point
|
|
90
|
+
source_ref="infra:live",
|
|
91
|
+
))
|
|
92
|
+
|
|
93
|
+
# ── 2. docker-compose files ────────────────────────────────────────────────
|
|
94
|
+
for cf in (compose_files or []):
|
|
95
|
+
_merge_compose(cf, services, seen)
|
|
96
|
+
|
|
97
|
+
# ── 3. migration.yaml ─────────────────────────────────────────────────────
|
|
98
|
+
app_version = version
|
|
99
|
+
deploy_strategy = "podman_quadlet"
|
|
100
|
+
if migration_file and migration_file.exists():
|
|
101
|
+
_meta = _parse_migration_meta(migration_file)
|
|
102
|
+
app_version = _meta.get("version", version)
|
|
103
|
+
deploy_strategy = _meta.get("strategy", deploy_strategy)
|
|
104
|
+
|
|
105
|
+
# ── 4. Hardware requirements ───────────────────────────────────────────────
|
|
106
|
+
hw_req = _build_hw_requirements(_hw)
|
|
107
|
+
|
|
108
|
+
# ── 5. App URL ─────────────────────────────────────────────────────────────
|
|
109
|
+
app_url = None
|
|
110
|
+
if _infra and _infra.host:
|
|
111
|
+
h = _infra.host.split("@")[-1]
|
|
112
|
+
# Look for a port-80 / port-8100 style proxy
|
|
113
|
+
for port in (80, 8100, 8080, 443):
|
|
114
|
+
if port in (_infra.ports or {}):
|
|
115
|
+
app_url = f"http://{h}:{port}" if port not in (80, 443) else f"http://{h}"
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
source = BlueprintSource(
|
|
119
|
+
device_id=device_map.id if device_map else host,
|
|
120
|
+
device_name=device_map.name if device_map else "",
|
|
121
|
+
migration_file=str(migration_file) if migration_file else None,
|
|
122
|
+
markpact_files=[str(p) for p in (markpact_files or [])],
|
|
123
|
+
compose_files=[str(p) for p in (compose_files or [])],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return DeviceBlueprint(
|
|
127
|
+
name=name,
|
|
128
|
+
version=app_version,
|
|
129
|
+
description=description,
|
|
130
|
+
tags=tags or (device_map.tags if device_map else []),
|
|
131
|
+
services=services,
|
|
132
|
+
hardware=hw_req,
|
|
133
|
+
app_url=app_url,
|
|
134
|
+
deploy_strategy=deploy_strategy,
|
|
135
|
+
source=source,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def _merge_compose(path: Path, services: list[ServiceSpec], seen: set[str]) -> None:
|
|
142
|
+
"""Parse a docker-compose YAML and merge service specs."""
|
|
143
|
+
try:
|
|
144
|
+
raw = yaml.safe_load(path.read_text())
|
|
145
|
+
except Exception:
|
|
146
|
+
return
|
|
147
|
+
if not isinstance(raw, dict) or "services" not in raw:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
for svc_name, svc_def in raw["services"].items():
|
|
151
|
+
if not isinstance(svc_def, dict):
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# If already seen from InfraState, enrich rather than duplicate
|
|
155
|
+
existing = next((s for s in services if s.name == svc_name), None)
|
|
156
|
+
image = svc_def.get("image", "")
|
|
157
|
+
platform = svc_def.get("platform", "")
|
|
158
|
+
ports = _parse_compose_ports(svc_def.get("ports", []))
|
|
159
|
+
volumes = _parse_compose_volumes(svc_def.get("volumes", []))
|
|
160
|
+
env = _parse_compose_env(svc_def.get("environment", {}))
|
|
161
|
+
healthcheck = _parse_compose_healthcheck(svc_def.get("healthcheck", {}))
|
|
162
|
+
depends = svc_def.get("depends_on", [])
|
|
163
|
+
if isinstance(depends, dict):
|
|
164
|
+
depends = list(depends.keys())
|
|
165
|
+
command = svc_def.get("command", None)
|
|
166
|
+
if isinstance(command, list):
|
|
167
|
+
command = " ".join(str(c) for c in command)
|
|
168
|
+
restart = svc_def.get("restart", "unless-stopped")
|
|
169
|
+
privileged = bool(svc_def.get("privileged", False))
|
|
170
|
+
|
|
171
|
+
if existing:
|
|
172
|
+
if not existing.image and image:
|
|
173
|
+
existing.image = image
|
|
174
|
+
if not existing.platform and platform:
|
|
175
|
+
existing.platform = platform
|
|
176
|
+
if not existing.ports:
|
|
177
|
+
existing.ports = ports
|
|
178
|
+
if not existing.volumes:
|
|
179
|
+
existing.volumes = volumes
|
|
180
|
+
existing.env.update(env)
|
|
181
|
+
if not existing.healthcheck and healthcheck:
|
|
182
|
+
existing.healthcheck = healthcheck
|
|
183
|
+
if not existing.depends_on:
|
|
184
|
+
existing.depends_on = depends
|
|
185
|
+
existing.source_ref = str(path)
|
|
186
|
+
else:
|
|
187
|
+
seen.add(svc_name)
|
|
188
|
+
services.append(ServiceSpec(
|
|
189
|
+
name=svc_name,
|
|
190
|
+
image=image,
|
|
191
|
+
platform=platform,
|
|
192
|
+
ports=ports,
|
|
193
|
+
volumes=volumes,
|
|
194
|
+
env=env,
|
|
195
|
+
command=command,
|
|
196
|
+
healthcheck=healthcheck,
|
|
197
|
+
depends_on=depends,
|
|
198
|
+
restart=restart,
|
|
199
|
+
privileged=privileged,
|
|
200
|
+
source_ref=str(path),
|
|
201
|
+
))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_compose_ports(raw: list) -> list[ServicePort]:
|
|
205
|
+
result = []
|
|
206
|
+
for entry in raw:
|
|
207
|
+
if isinstance(entry, int):
|
|
208
|
+
result.append(ServicePort(host=entry, container=entry))
|
|
209
|
+
elif isinstance(entry, str):
|
|
210
|
+
# "8080:80/tcp"
|
|
211
|
+
m = re.match(r"(?:[\d.]+:)?(\d+):(\d+)(?:/(tcp|udp))?", entry)
|
|
212
|
+
if m:
|
|
213
|
+
result.append(ServicePort(
|
|
214
|
+
host=int(m.group(1)),
|
|
215
|
+
container=int(m.group(2)),
|
|
216
|
+
protocol=m.group(3) or "tcp",
|
|
217
|
+
))
|
|
218
|
+
elif isinstance(entry, dict):
|
|
219
|
+
result.append(ServicePort(
|
|
220
|
+
host=int(entry.get("published", entry.get("target", 0))),
|
|
221
|
+
container=int(entry.get("target", 0)),
|
|
222
|
+
protocol=entry.get("protocol", "tcp"),
|
|
223
|
+
))
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _parse_compose_volumes(raw: list | dict) -> list[VolumeMount]:
|
|
228
|
+
result = []
|
|
229
|
+
items = raw if isinstance(raw, list) else [f"{k}:{v}" for k, v in raw.items()]
|
|
230
|
+
for entry in items:
|
|
231
|
+
if isinstance(entry, str) and ":" in entry:
|
|
232
|
+
parts = entry.split(":")
|
|
233
|
+
result.append(VolumeMount(
|
|
234
|
+
host=parts[0],
|
|
235
|
+
container=parts[1],
|
|
236
|
+
read_only=len(parts) > 2 and "ro" in parts[2],
|
|
237
|
+
))
|
|
238
|
+
elif isinstance(entry, dict):
|
|
239
|
+
result.append(VolumeMount(
|
|
240
|
+
host=entry.get("source", ""),
|
|
241
|
+
container=entry.get("target", ""),
|
|
242
|
+
read_only=entry.get("read_only", False),
|
|
243
|
+
))
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parse_compose_env(raw: dict | list) -> dict[str, str]:
|
|
248
|
+
if isinstance(raw, dict):
|
|
249
|
+
return {str(k): str(v) for k, v in raw.items() if v is not None}
|
|
250
|
+
result = {}
|
|
251
|
+
for entry in raw:
|
|
252
|
+
if isinstance(entry, str) and "=" in entry:
|
|
253
|
+
k, _, v = entry.partition("=")
|
|
254
|
+
result[k.strip()] = v.strip()
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parse_compose_healthcheck(raw: dict) -> Optional[str]:
|
|
259
|
+
if not raw:
|
|
260
|
+
return None
|
|
261
|
+
test = raw.get("test", [])
|
|
262
|
+
if isinstance(test, list) and len(test) > 1:
|
|
263
|
+
return " ".join(str(t) for t in test[1:])
|
|
264
|
+
if isinstance(test, str):
|
|
265
|
+
return test
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _parse_migration_meta(path: Path) -> dict:
|
|
270
|
+
try:
|
|
271
|
+
raw = yaml.safe_load(path.read_text())
|
|
272
|
+
except Exception:
|
|
273
|
+
return {}
|
|
274
|
+
if not isinstance(raw, dict):
|
|
275
|
+
return {}
|
|
276
|
+
result = {}
|
|
277
|
+
if "version" in raw:
|
|
278
|
+
result["version"] = str(raw["version"])
|
|
279
|
+
if "strategy" in raw:
|
|
280
|
+
result["strategy"] = raw["strategy"]
|
|
281
|
+
# Walk steps looking for deploy action to infer strategy
|
|
282
|
+
for step in raw.get("steps", []):
|
|
283
|
+
action = step.get("action", "")
|
|
284
|
+
if "podman" in action or "quadlet" in action:
|
|
285
|
+
result["strategy"] = "podman_quadlet"
|
|
286
|
+
break
|
|
287
|
+
if "docker" in action:
|
|
288
|
+
result["strategy"] = "docker_compose"
|
|
289
|
+
break
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _build_hw_requirements(hw) -> HardwareRequirements:
|
|
294
|
+
if hw is None:
|
|
295
|
+
return HardwareRequirements()
|
|
296
|
+
|
|
297
|
+
display_type = None
|
|
298
|
+
display_res = None
|
|
299
|
+
features: list[str] = []
|
|
300
|
+
|
|
301
|
+
# DRM / DSI
|
|
302
|
+
for output in getattr(hw, "drm_outputs", []):
|
|
303
|
+
if "DSI" in (output.connector or ""):
|
|
304
|
+
display_type = "DSI"
|
|
305
|
+
if output.modes:
|
|
306
|
+
display_res = output.modes[0]
|
|
307
|
+
|
|
308
|
+
if getattr(hw, "backlights", []):
|
|
309
|
+
features.append("backlight")
|
|
310
|
+
if getattr(hw, "i2c_buses", []):
|
|
311
|
+
features.append("i2c")
|
|
312
|
+
|
|
313
|
+
# Infer arch from board name
|
|
314
|
+
board = getattr(hw, "board", "") or ""
|
|
315
|
+
arch = "linux/arm64" if any(x in board.lower() for x in ("rpi", "raspberry", "aarch64", "pi")) else ""
|
|
316
|
+
|
|
317
|
+
# Overlays → wayland / kiosk hints
|
|
318
|
+
for overlay in getattr(hw, "dsi_overlays", []):
|
|
319
|
+
if "dsi" in overlay.lower():
|
|
320
|
+
features.append("wayland")
|
|
321
|
+
|
|
322
|
+
return HardwareRequirements(
|
|
323
|
+
arch=arch,
|
|
324
|
+
display_type=display_type,
|
|
325
|
+
display_resolution=display_res,
|
|
326
|
+
i2c_required=bool(getattr(hw, "i2c_buses", [])),
|
|
327
|
+
features=features,
|
|
328
|
+
)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Generate a docker-compose.twin.yml for local digital-twin testing.
|
|
2
|
+
|
|
3
|
+
The twin runs the full stack on the developer's machine (typically amd64),
|
|
4
|
+
replacing platform-specific images where needed and mocking hardware
|
|
5
|
+
dependencies (display, I2C, GPIO) via environment variables and stub
|
|
6
|
+
containers.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from redeploy.blueprint.generators.docker_compose import generate_twin
|
|
11
|
+
compose_yaml = generate_twin(blueprint)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from ...models import DeviceBlueprint, ServiceSpec
|
|
21
|
+
|
|
22
|
+
# ── Platform remap table ───────────────────────────────────────────────────────
|
|
23
|
+
# If the original image has no known amd64 variant we annotate it.
|
|
24
|
+
_ARM_ONLY_PREFIXES = ("balena/", "resin/", "raspberrypi")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate_twin(
|
|
28
|
+
blueprint: DeviceBlueprint,
|
|
29
|
+
*,
|
|
30
|
+
local_platform: str = "linux/amd64",
|
|
31
|
+
port_offset: int = 0,
|
|
32
|
+
network_name: str = "twin",
|
|
33
|
+
compose_version: str = "3.8",
|
|
34
|
+
add_mock_display: bool = True,
|
|
35
|
+
env_overrides: dict[str, str] | None = None,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Render a docker-compose YAML string for a local digital-twin.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
blueprint:
|
|
42
|
+
Source DeviceBlueprint extracted from the real device.
|
|
43
|
+
local_platform:
|
|
44
|
+
Target platform for the twin (default ``linux/amd64``).
|
|
45
|
+
port_offset:
|
|
46
|
+
Add this offset to all host ports to avoid conflicts, e.g. ``10000``.
|
|
47
|
+
network_name:
|
|
48
|
+
Docker network name for the twin stack.
|
|
49
|
+
add_mock_display:
|
|
50
|
+
If the blueprint requires a DSI display, inject a headless Xvfb
|
|
51
|
+
stub so the UI container has a framebuffer to connect to.
|
|
52
|
+
env_overrides:
|
|
53
|
+
Extra env vars to inject into every service (e.g. ``TWIN=1``).
|
|
54
|
+
"""
|
|
55
|
+
services: dict[str, Any] = {}
|
|
56
|
+
|
|
57
|
+
for svc in blueprint.services:
|
|
58
|
+
svc_def = _build_service(
|
|
59
|
+
svc,
|
|
60
|
+
local_platform=local_platform,
|
|
61
|
+
port_offset=port_offset,
|
|
62
|
+
env_overrides=env_overrides or {},
|
|
63
|
+
twin_network=network_name,
|
|
64
|
+
)
|
|
65
|
+
services[svc.name] = svc_def
|
|
66
|
+
|
|
67
|
+
# Mock display stub
|
|
68
|
+
if add_mock_display and blueprint.hardware.display_type in ("DSI", "HDMI"):
|
|
69
|
+
res = blueprint.hardware.display_resolution or "1280x800"
|
|
70
|
+
w, _, h = res.partition("x")
|
|
71
|
+
services["_mock_display"] = {
|
|
72
|
+
"image": "jlesage/xvfb:latest",
|
|
73
|
+
"platform": local_platform,
|
|
74
|
+
"environment": {
|
|
75
|
+
"DISPLAY_WIDTH": w or "1280",
|
|
76
|
+
"DISPLAY_HEIGHT": h or "800",
|
|
77
|
+
"DISPLAY": ":99",
|
|
78
|
+
},
|
|
79
|
+
"networks": [network_name],
|
|
80
|
+
"profiles": ["mock"], # only started with --profile mock
|
|
81
|
+
"labels": {"twin.role": "mock-display"},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Compose document
|
|
85
|
+
doc: dict[str, Any] = {
|
|
86
|
+
"# Generated by redeploy blueprint twin": None,
|
|
87
|
+
"# Source blueprint": blueprint.name,
|
|
88
|
+
"# Original device": blueprint.source.device_id or "unknown",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
compose: dict[str, Any] = {
|
|
92
|
+
"version": compose_version,
|
|
93
|
+
"services": services,
|
|
94
|
+
"networks": {
|
|
95
|
+
network_name: {"driver": "bridge"},
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Render: comments first (not directly supported by yaml.dump — use a header)
|
|
100
|
+
header = (
|
|
101
|
+
f"# Generated by redeploy blueprint twin\n"
|
|
102
|
+
f"# Source blueprint : {blueprint.name} v{blueprint.version}\n"
|
|
103
|
+
f"# Original device : {blueprint.source.device_id or 'unknown'}\n"
|
|
104
|
+
f"# Original platform: {blueprint.hardware.arch or 'unknown'}\n"
|
|
105
|
+
f"# Twin platform : {local_platform}\n"
|
|
106
|
+
f"#\n"
|
|
107
|
+
f"# Start with: docker compose -f docker-compose.twin.yml up\n"
|
|
108
|
+
f"# With mocks: docker compose -f docker-compose.twin.yml --profile mock up\n\n"
|
|
109
|
+
)
|
|
110
|
+
body = yaml.dump(compose, allow_unicode=True, sort_keys=False, default_flow_style=False)
|
|
111
|
+
return header + body
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_service(
|
|
115
|
+
svc: ServiceSpec,
|
|
116
|
+
*,
|
|
117
|
+
local_platform: str,
|
|
118
|
+
port_offset: int,
|
|
119
|
+
env_overrides: dict[str, str],
|
|
120
|
+
twin_network: str,
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
d: dict[str, Any] = {}
|
|
123
|
+
|
|
124
|
+
# Image — keep original; add platform override
|
|
125
|
+
d["image"] = svc.image or "scratch"
|
|
126
|
+
if local_platform:
|
|
127
|
+
d["platform"] = local_platform
|
|
128
|
+
|
|
129
|
+
# Ports — apply offset if non-zero
|
|
130
|
+
if svc.ports:
|
|
131
|
+
port_list = []
|
|
132
|
+
for p in svc.ports:
|
|
133
|
+
host_port = p.host + port_offset
|
|
134
|
+
entry = f"{host_port}:{p.container}"
|
|
135
|
+
if p.protocol != "tcp":
|
|
136
|
+
entry += f"/{p.protocol}"
|
|
137
|
+
port_list.append(entry)
|
|
138
|
+
d["ports"] = port_list
|
|
139
|
+
|
|
140
|
+
# Volumes — swap absolute host paths to named volumes for portability
|
|
141
|
+
if svc.volumes:
|
|
142
|
+
vols = []
|
|
143
|
+
named: dict[str, Any] = {}
|
|
144
|
+
for v in svc.volumes:
|
|
145
|
+
if v.host.startswith("/"):
|
|
146
|
+
vol_name = re.sub(r"[^a-zA-Z0-9_]", "_", v.host.strip("/")) or "data"
|
|
147
|
+
vols.append(f"{vol_name}:{v.container}" + (":ro" if v.read_only else ""))
|
|
148
|
+
named[vol_name] = {"driver": "local"}
|
|
149
|
+
else:
|
|
150
|
+
vols.append(f"{v.host}:{v.container}" + (":ro" if v.read_only else ""))
|
|
151
|
+
d["volumes"] = vols
|
|
152
|
+
if named:
|
|
153
|
+
d["_named_volumes"] = named # caller merges into top-level volumes
|
|
154
|
+
|
|
155
|
+
# Environment — original + twin overrides
|
|
156
|
+
env = dict(svc.env)
|
|
157
|
+
env["TWIN"] = "1"
|
|
158
|
+
env["DIGITAL_TWIN_SOURCE"] = svc.source_ref or "blueprint"
|
|
159
|
+
env.update(env_overrides)
|
|
160
|
+
# Redact secrets for display (keep them for actual use)
|
|
161
|
+
if env:
|
|
162
|
+
d["environment"] = env
|
|
163
|
+
|
|
164
|
+
# Hardware stubs — replace real device paths with no-ops
|
|
165
|
+
if svc.privileged:
|
|
166
|
+
# drop privilege in twin — could break some things, user can override
|
|
167
|
+
d["# privileged"] = "true (removed in twin — re-add if needed)"
|
|
168
|
+
else:
|
|
169
|
+
d["privileged"] = False
|
|
170
|
+
|
|
171
|
+
if svc.command:
|
|
172
|
+
d["command"] = svc.command
|
|
173
|
+
|
|
174
|
+
if svc.restart:
|
|
175
|
+
d["restart"] = svc.restart
|
|
176
|
+
|
|
177
|
+
if svc.depends_on:
|
|
178
|
+
d["depends_on"] = svc.depends_on
|
|
179
|
+
|
|
180
|
+
d["networks"] = [twin_network]
|
|
181
|
+
|
|
182
|
+
if svc.healthcheck:
|
|
183
|
+
d["healthcheck"] = {
|
|
184
|
+
"test": ["CMD-SHELL", svc.healthcheck],
|
|
185
|
+
"interval": "10s",
|
|
186
|
+
"timeout": "5s",
|
|
187
|
+
"retries": 3,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return d
|