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.
Files changed (151) hide show
  1. {redeploy-0.2.33 → redeploy-0.2.35}/PKG-INFO +6 -6
  2. {redeploy-0.2.33 → redeploy-0.2.35}/README.md +5 -5
  3. {redeploy-0.2.33 → redeploy-0.2.35}/pyproject.toml +1 -1
  4. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/handlers.py +4 -0
  6. redeploy-0.2.35/redeploy/blueprint/__init__.py +11 -0
  7. redeploy-0.2.35/redeploy/blueprint/extractor.py +328 -0
  8. redeploy-0.2.35/redeploy/blueprint/generators/docker_compose.py +190 -0
  9. redeploy-0.2.35/redeploy/blueprint/generators/migration.py +179 -0
  10. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/__init__.py +6 -0
  11. redeploy-0.2.35/redeploy/cli/commands/blueprint.py +298 -0
  12. redeploy-0.2.35/redeploy/cli/commands/device_map.py +376 -0
  13. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/models.py +230 -0
  14. redeploy-0.2.35/redeploy/tests/__init__.py +0 -0
  15. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/PKG-INFO +6 -6
  16. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/SOURCES.txt +7 -0
  17. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/__init__.py +0 -0
  18. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/exceptions.py +0 -0
  19. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/executor.py +0 -0
  20. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/progress.py +0 -0
  21. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/rollback.py +0 -0
  22. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/apply/state.py +0 -0
  23. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/audit.py +0 -0
  24. {redeploy-0.2.33/redeploy/tests → redeploy-0.2.35/redeploy/blueprint/generators}/__init__.py +0 -0
  25. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/__init__.py +0 -0
  26. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/audit.py +0 -0
  27. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/detect.py +0 -0
  28. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/devices.py +0 -0
  29. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/diagnose.py +0 -0
  30. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/diff.py +0 -0
  31. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/exec_.py +0 -0
  32. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/export.py +0 -0
  33. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/hardware.py +0 -0
  34. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/import_.py +0 -0
  35. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/init.py +0 -0
  36. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/inspect.py +0 -0
  37. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/patterns.py +0 -0
  38. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/plan_apply.py +0 -0
  39. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/plugin.py +0 -0
  40. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/probe.py +0 -0
  41. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/state.py +0 -0
  42. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/status.py +0 -0
  43. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/target.py +0 -0
  44. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/version.py +0 -0
  45. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/commands/workflow.py +0 -0
  46. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/core.py +0 -0
  47. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli/display.py +0 -0
  48. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/cli.py +0 -0
  49. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/data_sync.py +0 -0
  50. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/__init__.py +0 -0
  51. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/detector.py +0 -0
  52. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/hardware.py +0 -0
  53. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/probes.py +0 -0
  54. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/remote.py +0 -0
  55. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/templates.py +0 -0
  56. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/detect/workflow.py +0 -0
  57. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/discovery.py +0 -0
  58. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl/__init__.py +0 -0
  59. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl/loader.py +0 -0
  60. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl/parser.py +0 -0
  61. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/__init__.py +0 -0
  62. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/context.py +0 -0
  63. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/decorators.py +0 -0
  64. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/docker_steps.py +0 -0
  65. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/exceptions.py +0 -0
  66. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/runner.py +0 -0
  67. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/dsl_python/steps.py +0 -0
  68. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/fleet.py +0 -0
  69. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/__init__.py +0 -0
  70. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/base.py +0 -0
  71. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/docker_compose.py +0 -0
  72. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/parsers/__init__.py +0 -0
  73. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/parsers/compose.py +0 -0
  74. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/iac/registry.py +0 -0
  75. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/markpact/__init__.py +0 -0
  76. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/markpact/compiler.py +0 -0
  77. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/markpact/models.py +0 -0
  78. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/markpact/parser.py +0 -0
  79. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/observe.py +0 -0
  80. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/parse.py +0 -0
  81. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/patterns.py +0 -0
  82. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plan/__init__.py +0 -0
  83. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plan/planner.py +0 -0
  84. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/__init__.py +0 -0
  85. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/__init__.py +0 -0
  86. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/browser_reload.py +0 -0
  87. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
  88. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/notify.py +0 -0
  89. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/process_control.py +0 -0
  90. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  91. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/spec_loader.py +0 -0
  92. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/ssh.py +0 -0
  93. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/steps.py +0 -0
  94. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_audit.py +0 -0
  95. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_cli.py +0 -0
  96. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_data_sync.py +0 -0
  97. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_discovery.py +0 -0
  98. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_dsl.py +0 -0
  99. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_examples.py +0 -0
  100. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_executor.py +0 -0
  101. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_fleet.py +0 -0
  102. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_git_integration.py +0 -0
  103. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_iac.py +0 -0
  104. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_manifest.py +0 -0
  105. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_markpact_compiler.py +0 -0
  106. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_markpact_parser.py +0 -0
  107. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_models.py +0 -0
  108. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_observe.py +0 -0
  109. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_parse.py +0 -0
  110. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_patterns.py +0 -0
  111. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_planner_edge.py +0 -0
  112. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_plugins.py +0 -0
  113. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_probes.py +0 -0
  114. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_public_api.py +0 -0
  115. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_resume.py +0 -0
  116. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_spec_loader.py +0 -0
  117. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_ssh.py +0 -0
  118. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_templates.py +0 -0
  119. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_verify.py +0 -0
  120. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_version.py +0 -0
  121. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_version_changelog.py +0 -0
  122. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_version_cli.py +0 -0
  123. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_version_manifest.py +0 -0
  124. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_version_sources.py +0 -0
  125. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/tests/test_workflow.py +0 -0
  126. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/verify.py +0 -0
  127. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/__init__.py +0 -0
  128. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/bump.py +0 -0
  129. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/changelog.py +0 -0
  130. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/commits.py +0 -0
  131. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/diff.py +0 -0
  132. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/git_integration.py +0 -0
  133. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/git_transaction.py +0 -0
  134. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/manifest.py +0 -0
  135. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/__init__.py +0 -0
  136. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/base.py +0 -0
  137. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/json_.py +0 -0
  138. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/plain.py +0 -0
  139. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/regex.py +0 -0
  140. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/toml_.py +0 -0
  141. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/sources/yaml_.py +0 -0
  142. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version/transaction.py +0 -0
  143. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy/version.py +0 -0
  144. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/dependency_links.txt +0 -0
  145. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/entry_points.txt +0 -0
  146. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/requires.txt +0 -0
  147. {redeploy-0.2.33 → redeploy-0.2.35}/redeploy.egg-info/top_level.txt +0 -0
  148. {redeploy-0.2.33 → redeploy-0.2.35}/setup.cfg +0 -0
  149. {redeploy-0.2.33 → redeploy-0.2.35}/tests/test_iac.py +0 -0
  150. {redeploy-0.2.33 → redeploy-0.2.35}/tests/test_parse.py +0 -0
  151. {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.33
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.33-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
31
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-17.6h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
30
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.35-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
31
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
32
 
33
- - 🤖 **LLM usage:** $7.5000 (53 commits)
34
- - 👤 **Human dev:** ~$1758 (17.6h @ $100/h, 30min dedup)
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
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.33-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.35-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.33-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-17.6h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.35-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $7.5000 (53 commits)
10
- - 👤 **Human dev:** ~$1758 (17.6h @ $100/h, 30min dedup)
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
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.33-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.35-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
19
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.33"
7
+ version = "0.2.35"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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.33"
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