redeploy 0.2.26__tar.gz → 0.2.28__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 (142) hide show
  1. {redeploy-0.2.26 → redeploy-0.2.28}/PKG-INFO +6 -6
  2. {redeploy-0.2.26 → redeploy-0.2.28}/README.md +5 -5
  3. {redeploy-0.2.26 → redeploy-0.2.28}/pyproject.toml +1 -1
  4. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/__init__.py +1 -1
  5. redeploy-0.2.28/redeploy/apply/__init__.py +49 -0
  6. redeploy-0.2.28/redeploy/apply/exceptions.py +12 -0
  7. redeploy-0.2.28/redeploy/apply/executor.py +261 -0
  8. redeploy-0.2.28/redeploy/apply/handlers.py +422 -0
  9. redeploy-0.2.28/redeploy/apply/progress.py +107 -0
  10. redeploy-0.2.28/redeploy/apply/rollback.py +44 -0
  11. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/PKG-INFO +6 -6
  12. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/SOURCES.txt +4 -0
  13. redeploy-0.2.26/redeploy/apply/__init__.py +0 -16
  14. redeploy-0.2.26/redeploy/apply/executor.py +0 -739
  15. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/apply/state.py +0 -0
  16. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/audit.py +0 -0
  17. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/__init__.py +0 -0
  18. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/__init__.py +0 -0
  19. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/audit.py +0 -0
  20. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/detect.py +0 -0
  21. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/devices.py +0 -0
  22. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/diagnose.py +0 -0
  23. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/diff.py +0 -0
  24. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/exec_.py +0 -0
  25. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/export.py +0 -0
  26. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/import_.py +0 -0
  27. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/init.py +0 -0
  28. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/inspect.py +0 -0
  29. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/patterns.py +0 -0
  30. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/plan_apply.py +0 -0
  31. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/plugin.py +0 -0
  32. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/probe.py +0 -0
  33. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/state.py +0 -0
  34. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/status.py +0 -0
  35. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/target.py +0 -0
  36. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/version.py +0 -0
  37. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/workflow.py +0 -0
  38. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/core.py +0 -0
  39. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/display.py +0 -0
  40. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli.py +0 -0
  41. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/data_sync.py +0 -0
  42. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/__init__.py +0 -0
  43. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/detector.py +0 -0
  44. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/probes.py +0 -0
  45. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/remote.py +0 -0
  46. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/templates.py +0 -0
  47. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/workflow.py +0 -0
  48. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/discovery.py +0 -0
  49. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl/__init__.py +0 -0
  50. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl/loader.py +0 -0
  51. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl/parser.py +0 -0
  52. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/__init__.py +0 -0
  53. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/context.py +0 -0
  54. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/decorators.py +0 -0
  55. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/docker_steps.py +0 -0
  56. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/exceptions.py +0 -0
  57. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/runner.py +0 -0
  58. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/steps.py +0 -0
  59. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/fleet.py +0 -0
  60. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/__init__.py +0 -0
  61. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/base.py +0 -0
  62. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/docker_compose.py +0 -0
  63. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/parsers/__init__.py +0 -0
  64. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/parsers/compose.py +0 -0
  65. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/registry.py +0 -0
  66. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/markpact/__init__.py +0 -0
  67. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/markpact/compiler.py +0 -0
  68. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/markpact/models.py +0 -0
  69. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/markpact/parser.py +0 -0
  70. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/models.py +0 -0
  71. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/observe.py +0 -0
  72. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/parse.py +0 -0
  73. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/patterns.py +0 -0
  74. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plan/__init__.py +0 -0
  75. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plan/planner.py +0 -0
  76. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plugins/__init__.py +0 -0
  77. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plugins/builtin/__init__.py +0 -0
  78. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plugins/builtin/browser_reload.py +0 -0
  79. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plugins/builtin/notify.py +0 -0
  80. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  81. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/spec_loader.py +0 -0
  82. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/ssh.py +0 -0
  83. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/steps.py +0 -0
  84. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/__init__.py +0 -0
  85. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_audit.py +0 -0
  86. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_cli.py +0 -0
  87. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_data_sync.py +0 -0
  88. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_discovery.py +0 -0
  89. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_dsl.py +0 -0
  90. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_examples.py +0 -0
  91. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_executor.py +0 -0
  92. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_fleet.py +0 -0
  93. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_git_integration.py +0 -0
  94. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_iac.py +0 -0
  95. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_manifest.py +0 -0
  96. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_markpact_compiler.py +0 -0
  97. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_markpact_parser.py +0 -0
  98. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_models.py +0 -0
  99. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_observe.py +0 -0
  100. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_parse.py +0 -0
  101. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_patterns.py +0 -0
  102. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_planner_edge.py +0 -0
  103. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_plugins.py +0 -0
  104. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_probes.py +0 -0
  105. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_public_api.py +0 -0
  106. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_resume.py +0 -0
  107. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_spec_loader.py +0 -0
  108. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_ssh.py +0 -0
  109. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_templates.py +0 -0
  110. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_verify.py +0 -0
  111. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_version.py +0 -0
  112. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_version_changelog.py +0 -0
  113. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_version_cli.py +0 -0
  114. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_version_manifest.py +0 -0
  115. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_version_sources.py +0 -0
  116. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_workflow.py +0 -0
  117. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/verify.py +0 -0
  118. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/__init__.py +0 -0
  119. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/bump.py +0 -0
  120. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/changelog.py +0 -0
  121. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/commits.py +0 -0
  122. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/diff.py +0 -0
  123. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/git_integration.py +0 -0
  124. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/git_transaction.py +0 -0
  125. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/manifest.py +0 -0
  126. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/__init__.py +0 -0
  127. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/base.py +0 -0
  128. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/json_.py +0 -0
  129. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/plain.py +0 -0
  130. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/regex.py +0 -0
  131. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/toml_.py +0 -0
  132. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/yaml_.py +0 -0
  133. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/transaction.py +0 -0
  134. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version.py +0 -0
  135. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/dependency_links.txt +0 -0
  136. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/entry_points.txt +0 -0
  137. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/requires.txt +0 -0
  138. {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/top_level.txt +0 -0
  139. {redeploy-0.2.26 → redeploy-0.2.28}/setup.cfg +0 -0
  140. {redeploy-0.2.26 → redeploy-0.2.28}/tests/test_iac.py +0 -0
  141. {redeploy-0.2.26 → redeploy-0.2.28}/tests/test_parse.py +0 -0
  142. {redeploy-0.2.26 → redeploy-0.2.28}/tests/test_redeploy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.26
3
+ Version: 0.2.28
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.26-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-$6.90-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-14.3h-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.28-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.20-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-14.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
32
 
33
- - 🤖 **LLM usage:** $6.9000 (46 commits)
34
- - 👤 **Human dev:** ~$1432 (14.3h @ $100/h, 30min dedup)
33
+ - 🤖 **LLM usage:** $7.2000 (48 commits)
34
+ - 👤 **Human dev:** ~$1450 (14.5h @ $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.26-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.28-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.26-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-$6.90-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-14.3h-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.28-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.20-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-14.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $6.9000 (46 commits)
10
- - 👤 **Human dev:** ~$1432 (14.3h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.2000 (48 commits)
10
+ - 👤 **Human dev:** ~$1450 (14.5h @ $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.26-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.28-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.26"
7
+ version = "0.2.28"
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.26"
10
+ __version__ = "0.2.28"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -0,0 +1,49 @@
1
+ """apply — Execute a MigrationPlan step by step."""
2
+ from .exceptions import StepError
3
+ from .executor import Executor
4
+ from .handlers import (
5
+ run_container_log_tail,
6
+ run_docker_build,
7
+ run_docker_health_wait,
8
+ run_http_check,
9
+ run_inline_script,
10
+ run_plugin,
11
+ run_podman_build,
12
+ run_rsync,
13
+ run_scp,
14
+ run_ssh,
15
+ run_version_check,
16
+ run_wait,
17
+ )
18
+ from .progress import ProgressEmitter
19
+ from .rollback import rollback_steps
20
+ from .state import (
21
+ DEFAULT_STATE_DIR,
22
+ ResumeState,
23
+ default_state_path,
24
+ state_key,
25
+ )
26
+
27
+ __all__ = [
28
+ "Executor",
29
+ "ProgressEmitter",
30
+ "ResumeState",
31
+ "StepError",
32
+ "rollback_steps",
33
+ "default_state_path",
34
+ "state_key",
35
+ "DEFAULT_STATE_DIR",
36
+ # Handlers
37
+ "run_container_log_tail",
38
+ "run_docker_build",
39
+ "run_docker_health_wait",
40
+ "run_http_check",
41
+ "run_inline_script",
42
+ "run_plugin",
43
+ "run_podman_build",
44
+ "run_rsync",
45
+ "run_scp",
46
+ "run_ssh",
47
+ "run_version_check",
48
+ "run_wait",
49
+ ]
@@ -0,0 +1,12 @@
1
+ """Exceptions for the apply package."""
2
+ from __future__ import annotations
3
+
4
+ from ..models import MigrationStep
5
+
6
+
7
+ class StepError(Exception):
8
+ """Exception raised when a migration step fails."""
9
+
10
+ def __init__(self, step: MigrationStep, msg: str):
11
+ self.step = step
12
+ super().__init__(f"[{step.id}] {msg}")
@@ -0,0 +1,261 @@
1
+ """Executor — runs MigrationPlan steps, handles rollback on failure."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from pathlib import Path
6
+ from typing import IO, Optional
7
+
8
+ import yaml
9
+ from loguru import logger
10
+
11
+ from ..detect.remote import RemoteProbe
12
+ from ..models import MigrationPlan, MigrationStep, StepAction, StepStatus
13
+ from .exceptions import StepError
14
+ from .handlers import (
15
+ run_ssh, run_scp, run_rsync, run_docker_build, run_podman_build,
16
+ run_docker_health_wait, run_container_log_tail, run_http_check,
17
+ run_version_check, run_plugin, run_wait, run_inline_script,
18
+ )
19
+ from .progress import ProgressEmitter
20
+ from .rollback import rollback_steps
21
+ from .state import ResumeState, default_state_path
22
+
23
+
24
+ class Executor:
25
+ """Execute MigrationPlan steps on a remote host."""
26
+
27
+ def __init__(self, plan: MigrationPlan, dry_run: bool = False,
28
+ ssh_key: Optional[str] = None,
29
+ progress_yaml: bool = False,
30
+ progress_stream: IO[str] = None,
31
+ audit_log: bool = True,
32
+ audit_path: Optional["Path"] = None,
33
+ resume: bool = False,
34
+ from_step: Optional[str] = None,
35
+ state_path: Optional["Path"] = None,
36
+ spec_path: Optional[str] = None):
37
+ self.plan = plan
38
+ self.dry_run = dry_run
39
+ self.probe = RemoteProbe(plan.host)
40
+ if ssh_key:
41
+ self.probe.key = ssh_key
42
+ self._completed: list[MigrationStep] = []
43
+ self._emitter: Optional[ProgressEmitter] = (
44
+ ProgressEmitter(progress_stream) if progress_yaml else None
45
+ )
46
+ self._audit_log = audit_log
47
+ self._audit_path = audit_path
48
+ self._t0: float = 0.0
49
+
50
+ # ── spec path for command_ref resolution ─────────────────────────────
51
+ if spec_path and not plan.spec_path:
52
+ plan.spec_path = spec_path
53
+
54
+ # ── resume / checkpoint ──────────────────────────────────────────────
55
+ self._resume = resume
56
+ self._from_step = from_step
57
+ spec_id = spec_path or plan.spec_path or plan.target_file or plan.infra_file or plan.app
58
+ if state_path is None and not dry_run:
59
+ state_path = default_state_path(spec_id, plan.host)
60
+ self._state_path: Optional[Path] = (
61
+ Path(state_path) if state_path is not None else None
62
+ )
63
+ self._state: Optional[ResumeState] = None
64
+ if self._state_path is not None and not dry_run:
65
+ self._state = ResumeState.load_or_new(
66
+ self._state_path,
67
+ spec_path=str(spec_id),
68
+ host=plan.host,
69
+ total_steps=len(plan.steps),
70
+ )
71
+ # Keep total_steps in sync if the spec changed between runs.
72
+ self._state.total_steps = len(plan.steps)
73
+
74
+ @property
75
+ def completed_steps(self) -> list[MigrationStep]:
76
+ return list(self._completed)
77
+
78
+ @property
79
+ def state(self) -> Optional[ResumeState]:
80
+ """Current ResumeState (None when dry_run or disabled)."""
81
+ return self._state
82
+
83
+ @property
84
+ def state_path(self) -> Optional[Path]:
85
+ return self._state_path
86
+
87
+ def run(self) -> bool:
88
+ """Execute all steps. Returns True if all passed."""
89
+ self._t0 = time.monotonic()
90
+ prefix = "[DRY RUN] " if self.dry_run else ""
91
+ logger.info(f"{prefix}Applying plan: {len(self.plan.steps)} steps "
92
+ f"({self.plan.from_strategy.value} → {self.plan.to_strategy.value})")
93
+ if self._emitter:
94
+ self._emitter.start(self.plan)
95
+
96
+ skip_ids = self._compute_skip_set()
97
+ if skip_ids:
98
+ logger.info(f"resume: skipping {len(skip_ids)} already-completed step(s): "
99
+ f"{', '.join(sorted(skip_ids))}")
100
+
101
+ ok = self._execute_steps_loop(skip_ids)
102
+ elapsed = time.monotonic() - self._t0
103
+
104
+ self._handle_completion(ok, elapsed)
105
+ self._write_audit(ok=ok, elapsed_s=elapsed)
106
+ return ok
107
+
108
+ def _execute_steps_loop(self, skip_ids: set[str]) -> bool:
109
+ """Execute steps, handling skips and errors. Returns True if all passed."""
110
+ ok = True
111
+ for i, step in enumerate(self.plan.steps, 1):
112
+ if step.id in skip_ids:
113
+ self._skip_step(i, step)
114
+ continue
115
+
116
+ try:
117
+ if self._emitter:
118
+ self._emitter.step_start(i, step)
119
+ self._execute_step(step)
120
+ self._completed.append(step)
121
+ if self._state is not None:
122
+ self._state.mark_done(step.id)
123
+ if self._emitter:
124
+ self._emitter.step_done(i, step)
125
+ except StepError as e:
126
+ self._handle_step_failure(i, step, e)
127
+ ok = False
128
+ break
129
+ return ok
130
+
131
+ def _skip_step(self, i: int, step: MigrationStep) -> None:
132
+ """Handle a skipped step due to resume."""
133
+ step.status = StepStatus.SKIPPED
134
+ step.result = "resumed: previously completed"
135
+ if self._emitter:
136
+ self._emitter.step_done(i, step)
137
+
138
+ def _handle_step_failure(self, i: int, step: MigrationStep, error: StepError) -> None:
139
+ """Handle a step failure."""
140
+ logger.error(f"Step failed: {error}")
141
+ step.status = StepStatus.FAILED
142
+ step.error = str(error)
143
+ if self._state is not None:
144
+ self._state.mark_failed(step.id, str(error))
145
+ if self._emitter:
146
+ self._emitter.step_fail(i, step, str(error))
147
+ self._emitter.failed(len(self._completed), len(self.plan.steps), str(error))
148
+ if not self.dry_run:
149
+ self._rollback()
150
+
151
+ def _handle_completion(self, ok: bool, elapsed: float) -> None:
152
+ """Handle plan completion or failure."""
153
+ if ok:
154
+ logger.info(f"{'[DRY RUN] ' if self.dry_run else ''}All {len(self.plan.steps)} steps completed")
155
+ if self._emitter:
156
+ self._emitter.done(len(self.plan.steps))
157
+ # Plan finished cleanly — drop the checkpoint.
158
+ if self._state is not None:
159
+ self._state.remove()
160
+ else:
161
+ if self._state is not None and self._state_path is not None:
162
+ logger.info(f"resume: checkpoint saved → {self._state_path} "
163
+ f"({self._state.completed_count}/{self._state.total_steps} done)")
164
+
165
+ def _compute_skip_set(self) -> set[str]:
166
+ """Determine which step ids should be skipped this run.
167
+
168
+ Sources (in priority order):
169
+ 1. ``--from-step <id>``: skip every step BEFORE that id.
170
+ 2. ``--resume`` + persisted state: skip every previously completed id.
171
+ """
172
+ skip: set[str] = set()
173
+ ids = [s.id for s in self.plan.steps]
174
+
175
+ if self._from_step:
176
+ if self._from_step not in ids:
177
+ logger.warning(
178
+ f"--from-step '{self._from_step}' not found in plan; running full plan"
179
+ )
180
+ else:
181
+ idx = ids.index(self._from_step)
182
+ skip.update(ids[:idx])
183
+
184
+ if self._resume and self._state is not None:
185
+ skip.update(self._state.completed_step_ids)
186
+
187
+ return skip
188
+
189
+ def _write_audit(self, *, ok: bool, elapsed_s: float) -> None:
190
+ if not self._audit_log:
191
+ return
192
+ try:
193
+ from ..observe import DeployAuditLog
194
+ log = DeployAuditLog(path=self._audit_path)
195
+ log.record(self.plan, self._completed, ok=ok,
196
+ elapsed_s=elapsed_s, dry_run=self.dry_run)
197
+ except Exception as exc: # never crash the executor
198
+ logger.debug(f"audit_log write failed (non-fatal): {exc}")
199
+
200
+ # ── step dispatcher ───────────────────────────────────────────────────────
201
+
202
+ def _execute_step(self, step: MigrationStep) -> None:
203
+ logger.info(f" {'[DRY]' if self.dry_run else '→'} [{step.id}] {step.description}")
204
+ step.status = StepStatus.RUNNING
205
+
206
+ if self.dry_run:
207
+ step.status = StepStatus.DONE
208
+ step.result = "dry-run"
209
+ return
210
+
211
+ dispatch = {
212
+ StepAction.SYSTEMCTL_STOP: lambda s: run_ssh(s, self.probe),
213
+ StepAction.SYSTEMCTL_DISABLE: lambda s: run_ssh(s, self.probe),
214
+ StepAction.SYSTEMCTL_START: lambda s: run_ssh(s, self.probe),
215
+ StepAction.KUBECTL_DELETE: lambda s: run_ssh(s, self.probe),
216
+ StepAction.DOCKER_COMPOSE_UP: lambda s: run_ssh(s, self.probe),
217
+ StepAction.DOCKER_COMPOSE_DOWN: lambda s: run_ssh(s, self.probe),
218
+ StepAction.DOCKER_BUILD: lambda s: run_docker_build(s, self.probe, self._emitter),
219
+ StepAction.DOCKER_HEALTH_WAIT: lambda s: run_docker_health_wait(s, self.probe),
220
+ StepAction.CONTAINER_LOG_TAIL: lambda s: run_container_log_tail(s, self.probe),
221
+ StepAction.PODMAN_BUILD: lambda s: run_podman_build(s, self.probe, self._emitter),
222
+ StepAction.SSH_CMD: lambda s: run_ssh(s, self.probe),
223
+ StepAction.SCP: lambda s: run_scp(s, self.probe, self.plan),
224
+ StepAction.RSYNC: lambda s: run_rsync(s, self.probe, self.plan),
225
+ StepAction.HTTP_CHECK: lambda s: run_http_check(s, self.probe),
226
+ StepAction.VERSION_CHECK: lambda s: run_version_check(s, self.probe),
227
+ StepAction.WAIT: lambda s: run_wait(s),
228
+ StepAction.PLUGIN: lambda s: run_plugin(s, self.probe, self.plan, self._emitter, self.dry_run),
229
+ StepAction.INLINE_SCRIPT: lambda s: run_inline_script(s, self.probe, self.plan),
230
+ }
231
+
232
+ handler = dispatch.get(step.action)
233
+ if not handler:
234
+ raise StepError(step, f"No handler for action {step.action}")
235
+ handler(step)
236
+
237
+ # ── rollback ──────────────────────────────────────────────────────────────
238
+
239
+ def _rollback(self) -> None:
240
+ rollback_steps(self._completed, self.probe, self._state)
241
+
242
+ # ── summary ───────────────────────────────────────────────────────────────
243
+
244
+ def summary(self) -> str:
245
+ total = len(self.plan.steps)
246
+ done = sum(1 for s in self.plan.steps if s.status == StepStatus.DONE)
247
+ failed = sum(1 for s in self.plan.steps if s.status == StepStatus.FAILED)
248
+ icon = "✅" if failed == 0 else "❌"
249
+ return f"{icon} {done}/{total} steps completed" + (f", {failed} failed" if failed else "")
250
+
251
+ @staticmethod
252
+ def from_file(plan_path: Path) -> "Executor":
253
+ with plan_path.open() as f:
254
+ raw = yaml.safe_load(f)
255
+ plan = MigrationPlan(**raw)
256
+ return Executor(plan)
257
+
258
+ def save_results(self, output: Path) -> None:
259
+ data = self.plan.model_dump(mode="json")
260
+ output.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
261
+ logger.info(f"Results saved to {output}")