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.
- {redeploy-0.2.18 → redeploy-0.2.20}/PKG-INFO +6 -6
- {redeploy-0.2.18 → redeploy-0.2.20}/README.md +5 -5
- {redeploy-0.2.18 → redeploy-0.2.20}/pyproject.toml +1 -1
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/__init__.py +1 -1
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/apply/executor.py +110 -33
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/audit.py +76 -23
- redeploy-0.2.20/redeploy/cli/__init__.py +66 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/cli.py +563 -440
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/discovery.py +92 -58
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/models.py +1 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/parse.py +43 -22
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/PKG-INFO +6 -6
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/SOURCES.txt +1 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/apply/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/apply/state.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/fleet.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/markpact/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/markpact/compiler.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/markpact/models.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/markpact/parser.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/observe.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/patterns.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/spec_loader.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/ssh.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/steps.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_audit.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_cli.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_markpact_compiler.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_markpact_parser.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_resume.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_spec_loader.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/verify.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy/version.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/requires.txt +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/setup.cfg +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/tests/test_iac.py +0 -0
- {redeploy-0.2.18 → redeploy-0.2.20}/tests/test_parse.py +0 -0
- {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.
|
|
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
|
-
    
|
|
31
|
+
  
|
|
32
32
|
|
|
33
|
-
- 🤖 **LLM usage:** $5.
|
|
34
|
-
- 👤 **Human dev:** ~$
|
|
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
|
-
    
|
|
41
41
|
|
|
42
42
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
43
43
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $5.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
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
|
-
    
|
|
17
17
|
|
|
18
18
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
19
19
|
|
|
@@ -7,7 +7,7 @@ Public API (stable from 0.2.0, semver guaranteed)::
|
|
|
7
7
|
|
|
8
8
|
Everything not listed in ``__all__`` is internal and may change without notice.
|
|
9
9
|
"""
|
|
10
|
-
__version__ = "0.2.
|
|
10
|
+
__version__ = "0.2.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
+
return self._probe_binary(exp)
|
|
504
504
|
elif cat == "directory":
|
|
505
|
-
|
|
505
|
+
return self._probe_directory(exp)
|
|
506
506
|
elif cat == "file":
|
|
507
|
-
|
|
507
|
+
return self._probe_file(exp)
|
|
508
508
|
elif cat == "local_file":
|
|
509
|
-
|
|
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
|
-
|
|
511
|
+
return self._probe_port_listening(exp)
|
|
518
512
|
elif cat == "container_image":
|
|
519
|
-
|
|
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
|
-
|
|
515
|
+
return self._probe_systemd_unit(exp, user=False)
|
|
529
516
|
elif cat == "systemd_user_unit":
|
|
530
|
-
|
|
517
|
+
return self._probe_systemd_unit(exp, user=True)
|
|
531
518
|
elif cat == "apt_package":
|
|
532
|
-
|
|
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=
|
|
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"]
|