redeploy 0.2.18__tar.gz → 0.2.20__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 (114) hide show
  1. {redeploy-0.2.18 → redeploy-0.2.20}/PKG-INFO +6 -6
  2. {redeploy-0.2.18 → redeploy-0.2.20}/README.md +5 -5
  3. {redeploy-0.2.18 → redeploy-0.2.20}/pyproject.toml +1 -1
  4. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/apply/executor.py +110 -33
  6. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/audit.py +76 -23
  7. redeploy-0.2.20/redeploy/cli/__init__.py +66 -0
  8. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/cli.py +563 -440
  9. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/discovery.py +92 -58
  10. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/models.py +1 -0
  11. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/parse.py +43 -22
  12. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/PKG-INFO +6 -6
  13. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/SOURCES.txt +1 -0
  14. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/apply/__init__.py +0 -0
  15. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/apply/state.py +0 -0
  16. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/data_sync.py +0 -0
  17. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/__init__.py +0 -0
  18. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/detector.py +0 -0
  19. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/probes.py +0 -0
  20. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/remote.py +0 -0
  21. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/templates.py +0 -0
  22. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/workflow.py +0 -0
  23. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl/__init__.py +0 -0
  24. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl/loader.py +0 -0
  25. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl/parser.py +0 -0
  26. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/__init__.py +0 -0
  27. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/context.py +0 -0
  28. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/decorators.py +0 -0
  29. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/docker_steps.py +0 -0
  30. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/exceptions.py +0 -0
  31. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/runner.py +0 -0
  32. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/steps.py +0 -0
  33. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/fleet.py +0 -0
  34. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/__init__.py +0 -0
  35. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/base.py +0 -0
  36. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/docker_compose.py +0 -0
  37. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/parsers/__init__.py +0 -0
  38. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/parsers/compose.py +0 -0
  39. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/registry.py +0 -0
  40. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/markpact/__init__.py +0 -0
  41. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/markpact/compiler.py +0 -0
  42. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/markpact/models.py +0 -0
  43. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/markpact/parser.py +0 -0
  44. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/observe.py +0 -0
  45. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/patterns.py +0 -0
  46. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plan/__init__.py +0 -0
  47. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plan/planner.py +0 -0
  48. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plugins/__init__.py +0 -0
  49. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plugins/builtin/__init__.py +0 -0
  50. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plugins/builtin/browser_reload.py +0 -0
  51. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plugins/builtin/notify.py +0 -0
  52. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  53. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/spec_loader.py +0 -0
  54. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/ssh.py +0 -0
  55. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/steps.py +0 -0
  56. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/__init__.py +0 -0
  57. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_audit.py +0 -0
  58. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_cli.py +0 -0
  59. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_data_sync.py +0 -0
  60. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_discovery.py +0 -0
  61. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_dsl.py +0 -0
  62. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_examples.py +0 -0
  63. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_executor.py +0 -0
  64. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_fleet.py +0 -0
  65. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_git_integration.py +0 -0
  66. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_iac.py +0 -0
  67. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_manifest.py +0 -0
  68. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_markpact_compiler.py +0 -0
  69. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_markpact_parser.py +0 -0
  70. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_models.py +0 -0
  71. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_observe.py +0 -0
  72. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_parse.py +0 -0
  73. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_patterns.py +0 -0
  74. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_planner_edge.py +0 -0
  75. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_plugins.py +0 -0
  76. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_probes.py +0 -0
  77. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_public_api.py +0 -0
  78. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_resume.py +0 -0
  79. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_spec_loader.py +0 -0
  80. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_ssh.py +0 -0
  81. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_templates.py +0 -0
  82. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_verify.py +0 -0
  83. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_version.py +0 -0
  84. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_version_changelog.py +0 -0
  85. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_version_cli.py +0 -0
  86. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_version_manifest.py +0 -0
  87. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_version_sources.py +0 -0
  88. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_workflow.py +0 -0
  89. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/verify.py +0 -0
  90. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/__init__.py +0 -0
  91. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/bump.py +0 -0
  92. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/changelog.py +0 -0
  93. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/commits.py +0 -0
  94. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/diff.py +0 -0
  95. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/git_integration.py +0 -0
  96. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/git_transaction.py +0 -0
  97. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/manifest.py +0 -0
  98. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/__init__.py +0 -0
  99. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/base.py +0 -0
  100. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/json_.py +0 -0
  101. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/plain.py +0 -0
  102. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/regex.py +0 -0
  103. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/toml_.py +0 -0
  104. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/yaml_.py +0 -0
  105. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/transaction.py +0 -0
  106. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version.py +0 -0
  107. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/dependency_links.txt +0 -0
  108. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/entry_points.txt +0 -0
  109. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/requires.txt +0 -0
  110. {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/top_level.txt +0 -0
  111. {redeploy-0.2.18 → redeploy-0.2.20}/setup.cfg +0 -0
  112. {redeploy-0.2.18 → redeploy-0.2.20}/tests/test_iac.py +0 -0
  113. {redeploy-0.2.18 → redeploy-0.2.20}/tests/test_parse.py +0 -0
  114. {redeploy-0.2.18 → redeploy-0.2.20}/tests/test_redeploy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.18
3
+ Version: 0.2.20
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.18-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-$5.55-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-12.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.20-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-$5.85-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-13.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
32
 
33
- - 🤖 **LLM usage:** $5.5500 (37 commits)
34
- - 👤 **Human dev:** ~$1255 (12.6h @ $100/h, 30min dedup)
33
+ - 🤖 **LLM usage:** $5.8500 (39 commits)
34
+ - 👤 **Human dev:** ~$1337 (13.4h @ $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.18-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.20-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.18-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-$5.55-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-12.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.20-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-$5.85-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-13.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $5.5500 (37 commits)
10
- - 👤 **Human dev:** ~$1255 (12.6h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $5.8500 (39 commits)
10
+ - 👤 **Human dev:** ~$1337 (13.4h @ $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.18-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.20-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.18"
7
+ version = "0.2.20"
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.18"
10
+ __version__ = "0.2.20"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -194,14 +194,21 @@ class Executor:
194
194
  logger.info(f"resume: skipping {len(skip_ids)} already-completed step(s): "
195
195
  f"{', '.join(sorted(skip_ids))}")
196
196
 
197
+ ok = self._execute_steps_loop(skip_ids)
198
+ elapsed = time.monotonic() - self._t0
199
+
200
+ self._handle_completion(ok, elapsed)
201
+ self._write_audit(ok=ok, elapsed_s=elapsed)
202
+ return ok
203
+
204
+ def _execute_steps_loop(self, skip_ids: set[str]) -> bool:
205
+ """Execute steps, handling skips and errors. Returns True if all passed."""
197
206
  ok = True
198
207
  for i, step in enumerate(self.plan.steps, 1):
199
208
  if step.id in skip_ids:
200
- step.status = StepStatus.SKIPPED
201
- step.result = "resumed: previously completed"
202
- if self._emitter:
203
- self._emitter.step_done(i, step)
209
+ self._skip_step(i, step)
204
210
  continue
211
+
205
212
  try:
206
213
  if self._emitter:
207
214
  self._emitter.step_start(i, step)
@@ -212,20 +219,33 @@ class Executor:
212
219
  if self._emitter:
213
220
  self._emitter.step_done(i, step)
214
221
  except StepError as e:
215
- logger.error(f"Step failed: {e}")
216
- step.status = StepStatus.FAILED
217
- step.error = str(e)
218
- if self._state is not None:
219
- self._state.mark_failed(step.id, str(e))
220
- if self._emitter:
221
- self._emitter.step_fail(i, step, str(e))
222
- self._emitter.failed(len(self._completed), len(self.plan.steps), str(e))
223
- if not self.dry_run:
224
- self._rollback()
222
+ self._handle_step_failure(i, step, e)
225
223
  ok = False
226
224
  break
225
+ return ok
227
226
 
228
- elapsed = time.monotonic() - self._t0
227
+ def _skip_step(self, i: int, step: MigrationStep) -> None:
228
+ """Handle a skipped step due to resume."""
229
+ step.status = StepStatus.SKIPPED
230
+ step.result = "resumed: previously completed"
231
+ if self._emitter:
232
+ self._emitter.step_done(i, step)
233
+
234
+ def _handle_step_failure(self, i: int, step: MigrationStep, error: StepError) -> None:
235
+ """Handle a step failure."""
236
+ logger.error(f"Step failed: {error}")
237
+ step.status = StepStatus.FAILED
238
+ step.error = str(error)
239
+ if self._state is not None:
240
+ self._state.mark_failed(step.id, str(error))
241
+ if self._emitter:
242
+ self._emitter.step_fail(i, step, str(error))
243
+ self._emitter.failed(len(self._completed), len(self.plan.steps), str(error))
244
+ if not self.dry_run:
245
+ self._rollback()
246
+
247
+ def _handle_completion(self, ok: bool, elapsed: float) -> None:
248
+ """Handle plan completion or failure."""
229
249
  if ok:
230
250
  logger.info(f"{'[DRY RUN] ' if self.dry_run else ''}All {len(self.plan.steps)} steps completed")
231
251
  if self._emitter:
@@ -237,8 +257,6 @@ class Executor:
237
257
  if self._state is not None and self._state_path is not None:
238
258
  logger.info(f"resume: checkpoint saved → {self._state_path} "
239
259
  f"({self._state.completed_count}/{self._state.total_steps} done)")
240
- self._write_audit(ok=ok, elapsed_s=elapsed)
241
- return ok
242
260
 
243
261
  def _compute_skip_set(self) -> set[str]:
244
262
  """Determine which step ids should be skipped this run.
@@ -296,6 +314,7 @@ class Executor:
296
314
  StepAction.DOCKER_BUILD: self._run_docker_build,
297
315
  StepAction.DOCKER_HEALTH_WAIT: self._run_docker_health_wait,
298
316
  StepAction.CONTAINER_LOG_TAIL: self._run_container_log_tail,
317
+ StepAction.PODMAN_BUILD: self._run_podman_build,
299
318
  StepAction.SSH_CMD: self._run_ssh,
300
319
  StepAction.SCP: self._run_scp,
301
320
  StepAction.RSYNC: self._run_rsync,
@@ -403,6 +422,54 @@ class Executor:
403
422
  logger.debug(f" build completed in {elapsed + poll_interval}s")
404
423
  step.status = StepStatus.DONE
405
424
 
425
+ def _run_podman_build(self, step: MigrationStep) -> None:
426
+ """Run podman build on remote with periodic progress polling."""
427
+ cmd = step.command
428
+ if not cmd:
429
+ raise StepError(step, "No command specified")
430
+
431
+ timeout = step.timeout or 1800 # 30 min default for ARM64 builds
432
+ poll_interval = 15 # seconds between progress snapshots
433
+ done_event = threading.Event()
434
+ result_holder: list = []
435
+
436
+ def _ssh_build() -> None:
437
+ r = self.probe.run(cmd, timeout=timeout)
438
+ result_holder.append(r)
439
+ done_event.set()
440
+
441
+ thread = threading.Thread(target=_ssh_build, daemon=True)
442
+ thread.start()
443
+
444
+ elapsed = 0
445
+ while not done_event.wait(timeout=poll_interval):
446
+ elapsed += poll_interval
447
+ snap = self.probe.run(
448
+ "podman system df 2>/dev/null | grep -E 'Image|Cache' | "
449
+ "awk '{print $1, $3, $4}'",
450
+ timeout=10,
451
+ )
452
+ if snap.ok and snap.out.strip():
453
+ lines = " | ".join(snap.out.strip().splitlines())
454
+ msg = f"[{elapsed}s] podman cache: {lines}"
455
+ logger.debug(f" {msg}")
456
+ if self._emitter:
457
+ self._emitter.progress(step.id, msg)
458
+ else:
459
+ msg = f"[{elapsed}s] podman build in progress..."
460
+ logger.debug(f" {msg}")
461
+ if self._emitter:
462
+ self._emitter.progress(step.id, msg)
463
+
464
+ if not result_holder:
465
+ raise StepError(step, "Podman build thread did not return a result")
466
+ r = result_holder[0]
467
+ step.result = r.out[:500]
468
+ if not r.ok:
469
+ raise StepError(step, f"exit={r.exit_code}: {r.stderr[:300]}")
470
+ logger.debug(f" podman build completed in {elapsed + poll_interval}s")
471
+ step.status = StepStatus.DONE
472
+
406
473
  def _run_docker_health_wait(self, step: MigrationStep) -> None:
407
474
  """Wait until all containers reach 'healthy' or 'running' status.
408
475
 
@@ -421,27 +488,14 @@ class Executor:
421
488
  while elapsed < timeout:
422
489
  r = self.probe.run(cmd, timeout=20)
423
490
  if r.ok and r.out.strip():
424
- lines = r.out.strip().splitlines()
425
- # Parse simple table: Name | Status
426
- statuses = []
427
- for line in lines:
428
- if line.startswith("NAME") or not line.strip():
429
- continue
430
- parts = line.split(None, 1)
431
- name = parts[0] if parts else "?"
432
- status = parts[1].strip() if len(parts) > 1 else "?"
433
- statuses.append((name, status))
434
-
491
+ statuses = self._parse_container_statuses(r.out)
435
492
  status_str = ", ".join(f"{n}:{s}" for n, s in statuses)
493
+
436
494
  if status_str != last_status:
437
495
  logger.debug(f" [{elapsed}s] containers: {status_str}")
438
496
  last_status = status_str
439
497
 
440
- unhealthy = [
441
- n for n, s in statuses
442
- if not any(kw in s.lower() for kw in ("up", "running", "healthy"))
443
- ]
444
- if statuses and not unhealthy:
498
+ if self._all_containers_healthy(statuses):
445
499
  step.status = StepStatus.DONE
446
500
  step.result = f"all containers healthy after {elapsed}s: {status_str}"
447
501
  logger.debug(f" ✓ all containers healthy ({elapsed}s)")
@@ -457,6 +511,29 @@ class Executor:
457
511
  step.result = f"timeout {timeout}s reached, last: {last_status or 'unknown'}"
458
512
  logger.warning(f" docker_health_wait timed out after {timeout}s — proceeding to health check")
459
513
 
514
+ def _parse_container_statuses(self, output: str) -> list[tuple[str, str]]:
515
+ """Parse docker compose ps output into (name, status) tuples."""
516
+ lines = output.strip().splitlines()
517
+ statuses = []
518
+ for line in lines:
519
+ if line.startswith("NAME") or not line.strip():
520
+ continue
521
+ parts = line.split(None, 1)
522
+ name = parts[0] if parts else "?"
523
+ status = parts[1].strip() if len(parts) > 1 else "?"
524
+ statuses.append((name, status))
525
+ return statuses
526
+
527
+ def _all_containers_healthy(self, statuses: list[tuple[str, str]]) -> bool:
528
+ """Check if all containers are in a healthy/running state."""
529
+ if not statuses:
530
+ return False
531
+ unhealthy = [
532
+ n for n, s in statuses
533
+ if not any(kw in s.lower() for kw in ("up", "running", "healthy"))
534
+ ]
535
+ return not unhealthy
536
+
460
537
  def _run_container_log_tail(self, step: MigrationStep) -> None:
461
538
  """Fetch and log the last N lines from each container after start."""
462
539
  cmd = step.command
@@ -500,36 +500,23 @@ class Auditor:
500
500
  def _probe_one(self, exp: _Expect) -> AuditCheck:
501
501
  cat = exp.category
502
502
  if cat == "binary":
503
- ok, detail = self.probe.has_binary(exp.name)
503
+ return self._probe_binary(exp)
504
504
  elif cat == "directory":
505
- ok, detail = self.probe.has_path(exp.name, kind="dir")
505
+ return self._probe_directory(exp)
506
506
  elif cat == "file":
507
- ok, detail = self.probe.has_path(exp.name, kind="file")
507
+ return self._probe_file(exp)
508
508
  elif cat == "local_file":
509
- local_ok = Path(exp.name).expanduser().exists()
510
- return AuditCheck(
511
- category=cat, name=exp.name,
512
- status="pass" if local_ok else "fail",
513
- detail="present" if local_ok else "missing on controller",
514
- fix_hint=exp.fix_hint, source_step=exp.source_step,
515
- )
509
+ return self._probe_local_file(exp)
516
510
  elif cat == "port_listening":
517
- ok, detail = self.probe.port_listening(int(exp.name))
511
+ return self._probe_port_listening(exp)
518
512
  elif cat == "container_image":
519
- ok, detail = self.probe.has_image(exp.name)
520
- # built-by-spec images: missing = "warn" not "fail"
521
- return AuditCheck(
522
- category=cat, name=exp.name,
523
- status="pass" if ok else "warn",
524
- detail=detail or ("present" if ok else "will be built by step"),
525
- fix_hint=exp.fix_hint, source_step=exp.source_step,
526
- )
513
+ return self._probe_container_image(exp)
527
514
  elif cat == "systemd_unit":
528
- ok, detail = self.probe.has_systemd_unit(exp.name, user=False)
515
+ return self._probe_systemd_unit(exp, user=False)
529
516
  elif cat == "systemd_user_unit":
530
- ok, detail = self.probe.has_systemd_unit(exp.name, user=True)
517
+ return self._probe_systemd_unit(exp, user=True)
531
518
  elif cat == "apt_package":
532
- ok, detail = self.probe.apt_package(exp.name)
519
+ return self._probe_apt_package(exp)
533
520
  else:
534
521
  return AuditCheck(
535
522
  category=cat, name=exp.name,
@@ -537,8 +524,74 @@ class Auditor:
537
524
  source_step=exp.source_step,
538
525
  )
539
526
 
527
+ def _probe_binary(self, exp: _Expect) -> AuditCheck:
528
+ ok, detail = self.probe.has_binary(exp.name)
529
+ return AuditCheck(
530
+ category=exp.category, name=exp.name,
531
+ status="pass" if ok else "fail",
532
+ detail=detail or ("present" if ok else "missing"),
533
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
534
+ )
535
+
536
+ def _probe_directory(self, exp: _Expect) -> AuditCheck:
537
+ ok, detail = self.probe.has_path(exp.name, kind="dir")
538
+ return AuditCheck(
539
+ category=exp.category, name=exp.name,
540
+ status="pass" if ok else "fail",
541
+ detail=detail or ("present" if ok else "missing"),
542
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
543
+ )
544
+
545
+ def _probe_file(self, exp: _Expect) -> AuditCheck:
546
+ ok, detail = self.probe.has_path(exp.name, kind="file")
547
+ return AuditCheck(
548
+ category=exp.category, name=exp.name,
549
+ status="pass" if ok else "fail",
550
+ detail=detail or ("present" if ok else "missing"),
551
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
552
+ )
553
+
554
+ def _probe_local_file(self, exp: _Expect) -> AuditCheck:
555
+ local_ok = Path(exp.name).expanduser().exists()
556
+ return AuditCheck(
557
+ category=exp.category, name=exp.name,
558
+ status="pass" if local_ok else "fail",
559
+ detail="present" if local_ok else "missing on controller",
560
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
561
+ )
562
+
563
+ def _probe_port_listening(self, exp: _Expect) -> AuditCheck:
564
+ ok, detail = self.probe.port_listening(int(exp.name))
565
+ return AuditCheck(
566
+ category=exp.category, name=exp.name,
567
+ status="pass" if ok else "fail",
568
+ detail=detail or ("present" if ok else "missing"),
569
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
570
+ )
571
+
572
+ def _probe_container_image(self, exp: _Expect) -> AuditCheck:
573
+ ok, detail = self.probe.has_image(exp.name)
574
+ # built-by-spec images: missing = "warn" not "fail"
575
+ return AuditCheck(
576
+ category=exp.category, name=exp.name,
577
+ status="pass" if ok else "warn",
578
+ detail=detail or ("present" if ok else "will be built by step"),
579
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
580
+ )
581
+
582
+ def _probe_systemd_unit(self, exp: _Expect, user: bool) -> AuditCheck:
583
+ ok, detail = self.probe.has_systemd_unit(exp.name, user=user)
584
+ return AuditCheck(
585
+ category=exp.category, name=exp.name,
586
+ status="pass" if ok else "fail",
587
+ detail=detail or ("present" if ok else "missing"),
588
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
589
+ )
590
+
591
+ def _probe_apt_package(self, exp: _Expect) -> AuditCheck:
592
+ ok, detail = self.probe.apt_package(exp.name)
540
593
  return AuditCheck(
541
- category=cat, name=exp.name,
594
+ category=exp.category, name=exp.name,
542
595
  status="pass" if ok else "fail",
543
596
  detail=detail or ("present" if ok else "missing"),
544
597
  fix_hint=exp.fix_hint, source_step=exp.source_step,
@@ -0,0 +1,66 @@
1
+ """redeploy CLI — detect | plan | apply | migrate."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ import click
7
+ from loguru import logger
8
+
9
+ from .. import __version__
10
+
11
+
12
+ def _setup_logging(verbose: bool) -> None:
13
+ logger.remove()
14
+ level = "DEBUG" if verbose else "INFO"
15
+ logger.add(
16
+ sys.stderr,
17
+ level=level,
18
+ format="<green>{time:HH:mm:ss}</green> | <level>{level:<7}</level> | {message}",
19
+ )
20
+
21
+
22
+ @click.group()
23
+ @click.version_option(__version__)
24
+ @click.option("-v", "--verbose", is_flag=True)
25
+ @click.pass_context
26
+ def cli(ctx, verbose):
27
+ """redeploy — Infrastructure migration toolkit: detect → plan → apply"""
28
+ _setup_logging(verbose)
29
+ ctx.ensure_object(dict)
30
+ ctx.obj["verbose"] = verbose
31
+
32
+
33
+ # Import and register all commands
34
+ from .commands import detect, diagnose, inspect, workflow, export, plugin
35
+ from .commands import exec_, plan_apply, state, init, status, devices, target, probe
36
+ from .commands import import_, diff, audit, patterns, version as version_cmd
37
+
38
+ # Register commands
39
+ cli.add_command(detect.detect)
40
+ cli.add_command(diagnose.diagnose)
41
+ cli.add_command(inspect.inspect)
42
+ cli.add_command(workflow.workflow_cmd)
43
+ cli.add_command(export.export_cmd)
44
+ cli.add_command(plugin.plugin_cmd)
45
+ cli.add_command(exec_.exec_cmd)
46
+ cli.add_command(exec_.exec_multi_cmd)
47
+ cli.add_command(plan_apply.plan)
48
+ cli.add_command(plan_apply.apply)
49
+ cli.add_command(plan_apply.migrate)
50
+ cli.add_command(plan_apply.run)
51
+ cli.add_command(state.state_cmd)
52
+ cli.add_command(init.init)
53
+ cli.add_command(status.status)
54
+ cli.add_command(devices.devices)
55
+ cli.add_command(devices.scan)
56
+ cli.add_command(devices.device_add)
57
+ cli.add_command(devices.device_rm)
58
+ cli.add_command(target.target)
59
+ cli.add_command(probe.probe)
60
+ cli.add_command(import_.import_cmd)
61
+ cli.add_command(diff.diff)
62
+ cli.add_command(audit.audit)
63
+ cli.add_command(patterns.patterns)
64
+ cli.add_command(version_cmd.version_cmd)
65
+
66
+ __all__ = ["cli"]