redeploy 0.2.19__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.19 → redeploy-0.2.20}/PKG-INFO +6 -6
- {redeploy-0.2.19 → redeploy-0.2.20}/README.md +5 -5
- {redeploy-0.2.19 → redeploy-0.2.20}/pyproject.toml +1 -1
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/__init__.py +1 -1
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/apply/executor.py +61 -33
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/audit.py +76 -23
- redeploy-0.2.20/redeploy/cli/__init__.py +66 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/cli.py +3 -32
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/discovery.py +92 -58
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/parse.py +43 -22
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/PKG-INFO +6 -6
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/SOURCES.txt +1 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/apply/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/apply/state.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/fleet.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/markpact/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/markpact/compiler.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/markpact/models.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/markpact/parser.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/models.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/observe.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/patterns.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/spec_loader.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/ssh.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/steps.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_audit.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_cli.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_markpact_compiler.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_markpact_parser.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_resume.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_spec_loader.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/verify.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/requires.txt +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/setup.cfg +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/tests/test_iac.py +0 -0
- {redeploy-0.2.19 → redeploy-0.2.20}/tests/test_parse.py +0 -0
- {redeploy-0.2.19 → 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.
|
|
@@ -470,27 +488,14 @@ class Executor:
|
|
|
470
488
|
while elapsed < timeout:
|
|
471
489
|
r = self.probe.run(cmd, timeout=20)
|
|
472
490
|
if r.ok and r.out.strip():
|
|
473
|
-
|
|
474
|
-
# Parse simple table: Name | Status
|
|
475
|
-
statuses = []
|
|
476
|
-
for line in lines:
|
|
477
|
-
if line.startswith("NAME") or not line.strip():
|
|
478
|
-
continue
|
|
479
|
-
parts = line.split(None, 1)
|
|
480
|
-
name = parts[0] if parts else "?"
|
|
481
|
-
status = parts[1].strip() if len(parts) > 1 else "?"
|
|
482
|
-
statuses.append((name, status))
|
|
483
|
-
|
|
491
|
+
statuses = self._parse_container_statuses(r.out)
|
|
484
492
|
status_str = ", ".join(f"{n}:{s}" for n, s in statuses)
|
|
493
|
+
|
|
485
494
|
if status_str != last_status:
|
|
486
495
|
logger.debug(f" [{elapsed}s] containers: {status_str}")
|
|
487
496
|
last_status = status_str
|
|
488
497
|
|
|
489
|
-
|
|
490
|
-
n for n, s in statuses
|
|
491
|
-
if not any(kw in s.lower() for kw in ("up", "running", "healthy"))
|
|
492
|
-
]
|
|
493
|
-
if statuses and not unhealthy:
|
|
498
|
+
if self._all_containers_healthy(statuses):
|
|
494
499
|
step.status = StepStatus.DONE
|
|
495
500
|
step.result = f"all containers healthy after {elapsed}s: {status_str}"
|
|
496
501
|
logger.debug(f" ✓ all containers healthy ({elapsed}s)")
|
|
@@ -506,6 +511,29 @@ class Executor:
|
|
|
506
511
|
step.result = f"timeout {timeout}s reached, last: {last_status or 'unknown'}"
|
|
507
512
|
logger.warning(f" docker_health_wait timed out after {timeout}s — proceeding to health check")
|
|
508
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
|
+
|
|
509
537
|
def _run_container_log_tail(self, step: MigrationStep) -> None:
|
|
510
538
|
"""Fetch and log the last N lines from each container after start."""
|
|
511
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"]
|
|
@@ -1419,41 +1419,12 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
|
|
|
1419
1419
|
spec = _load_spec_or_exit(console, resolved_spec)
|
|
1420
1420
|
|
|
1421
1421
|
# overlay manifest values — env-specific or global
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
console.print(f"[yellow]⚠ env '{env_name}' not in redeploy.yaml — known: "
|
|
1425
|
-
f"{', '.join(manifest.environments) or 'none'}[/yellow]")
|
|
1426
|
-
manifest.apply_to_spec(spec, env_name=env_name)
|
|
1427
|
-
env_label = f" [cyan][env: {env_name}][/cyan]" if env_name else ""
|
|
1428
|
-
console.print(f"[dim]manifest: {_find_manifest_path()}{env_label}[/dim]")
|
|
1429
|
-
elif not env_name:
|
|
1430
|
-
# Fallback: read DEPLOY_* from .env if no redeploy.yaml
|
|
1431
|
-
dotenv_manifest = ProjectManifest.from_dotenv(Path.cwd())
|
|
1432
|
-
if dotenv_manifest:
|
|
1433
|
-
dotenv_manifest.apply_to_spec(spec)
|
|
1434
|
-
console.print("[dim]manifest: .env (DEPLOY_* vars)[/dim]")
|
|
1435
|
-
|
|
1436
|
-
console.print(f"\n[bold]{spec.name}[/bold]"
|
|
1437
|
-
+ (f" [dim]{spec.description}[/dim]" if spec.description else ""))
|
|
1438
|
-
console.print(f" [dim]{spec.source.strategy.value}[/dim] → "
|
|
1439
|
-
f"[bold]{spec.target.strategy.value}[/bold]"
|
|
1440
|
-
f" ({spec.source.host})")
|
|
1422
|
+
_apply_manifest_to_spec(console, manifest, spec, env_name)
|
|
1423
|
+
_print_spec_summary(console, spec)
|
|
1441
1424
|
|
|
1442
1425
|
# ── optional live detect (overrides source in spec) ──────────────────────
|
|
1443
1426
|
if do_detect:
|
|
1444
|
-
|
|
1445
|
-
console.print(f"\n[bold]detect[/bold] (live probe of {spec.source.host})")
|
|
1446
|
-
d = Detector(
|
|
1447
|
-
host=spec.source.host,
|
|
1448
|
-
app=spec.source.app,
|
|
1449
|
-
domain=spec.source.domain,
|
|
1450
|
-
)
|
|
1451
|
-
state = d.run()
|
|
1452
|
-
console.print(f" detected: {state.detected_strategy.value} "
|
|
1453
|
-
f"version={state.current_version or '?'} "
|
|
1454
|
-
f"conflicts={len(state.conflicts)}")
|
|
1455
|
-
planner = Planner(state, spec.to_target_config())
|
|
1456
|
-
planner._spec = spec
|
|
1427
|
+
planner = _perform_live_detect(console, spec)
|
|
1457
1428
|
else:
|
|
1458
1429
|
planner = Planner.from_spec(spec)
|
|
1459
1430
|
|
|
@@ -527,7 +527,22 @@ def _detect_strategy_remote(
|
|
|
527
527
|
) -> dict:
|
|
528
528
|
"""Run a single SSH session to detect strategy, app, version, etc."""
|
|
529
529
|
key_opts = ["-i", key] if key else []
|
|
530
|
-
probe_cmd = (
|
|
530
|
+
probe_cmd = _build_probe_command()
|
|
531
|
+
cmd = _build_ssh_command(host, port, timeout, key_opts, probe_cmd)
|
|
532
|
+
|
|
533
|
+
out = _run_ssh_probe(cmd, timeout)
|
|
534
|
+
if out is None:
|
|
535
|
+
return {}
|
|
536
|
+
|
|
537
|
+
info, services = _parse_probe_output(out)
|
|
538
|
+
info["running_services"] = services
|
|
539
|
+
info["strategy"] = _infer_strategy(info, services)
|
|
540
|
+
|
|
541
|
+
return info
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _build_probe_command() -> str:
|
|
545
|
+
return (
|
|
531
546
|
"echo __arch__=$(uname -m); "
|
|
532
547
|
"echo __os__=$(. /etc/os-release 2>/dev/null && echo $PRETTY_NAME || uname -s); "
|
|
533
548
|
"echo __hostname__=$(hostname); "
|
|
@@ -538,20 +553,28 @@ def _detect_strategy_remote(
|
|
|
538
553
|
"systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | awk '{print $1}' | head -30; "
|
|
539
554
|
"echo __end_services__"
|
|
540
555
|
)
|
|
541
|
-
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _build_ssh_command(host: str, port: int, timeout: int, key_opts: list[str], probe_cmd: str) -> list[str]:
|
|
559
|
+
return ["ssh"] + [
|
|
542
560
|
"-o", f"ConnectTimeout={timeout}",
|
|
543
561
|
"-o", "StrictHostKeyChecking=no",
|
|
544
562
|
"-o", "BatchMode=yes",
|
|
545
563
|
"-p", str(port),
|
|
546
564
|
] + key_opts + [host, probe_cmd]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _run_ssh_probe(cmd: list[str], timeout: int) -> str | None:
|
|
547
568
|
try:
|
|
548
569
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
|
|
549
570
|
if r.returncode != 0:
|
|
550
|
-
return
|
|
551
|
-
|
|
571
|
+
return None
|
|
572
|
+
return r.stdout
|
|
552
573
|
except Exception:
|
|
553
|
-
return
|
|
574
|
+
return None
|
|
554
575
|
|
|
576
|
+
|
|
577
|
+
def _parse_probe_output(out: str) -> tuple[dict, list[str]]:
|
|
555
578
|
info: dict = {}
|
|
556
579
|
services: list[str] = []
|
|
557
580
|
in_services = False
|
|
@@ -582,19 +605,72 @@ def _detect_strategy_remote(
|
|
|
582
605
|
if line.endswith(".service"):
|
|
583
606
|
services.append(line)
|
|
584
607
|
|
|
585
|
-
info
|
|
608
|
+
return info, services
|
|
586
609
|
|
|
587
|
-
|
|
610
|
+
|
|
611
|
+
def _infer_strategy(info: dict, services: list[str]) -> str:
|
|
588
612
|
if info.get("docker_active") or info.get("has_docker"):
|
|
589
|
-
|
|
613
|
+
return "docker_full"
|
|
590
614
|
elif info.get("has_podman"):
|
|
591
|
-
|
|
615
|
+
return "podman_quadlet"
|
|
592
616
|
elif info.get("has_chromium") and any("kiosk" in s or "chromium" in s for s in services):
|
|
593
|
-
|
|
617
|
+
return "native_kiosk"
|
|
594
618
|
else:
|
|
595
|
-
|
|
619
|
+
return "systemd"
|
|
596
620
|
|
|
597
|
-
|
|
621
|
+
|
|
622
|
+
def _parse_probe_input(ip_or_host: str, users: Optional[list[str]]) -> tuple[str, list[str]]:
|
|
623
|
+
if "@" in ip_or_host:
|
|
624
|
+
forced_user, ip = ip_or_host.split("@", 1)
|
|
625
|
+
return ip, [forced_user] + (users or _DEFAULT_USERS)
|
|
626
|
+
else:
|
|
627
|
+
return ip_or_host, users or _DEFAULT_USERS
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _detect_app_from_services(services: list[str], app_hint: str) -> str:
|
|
631
|
+
if app_hint:
|
|
632
|
+
return app_hint
|
|
633
|
+
for svc in services:
|
|
634
|
+
for candidate in ("c2004", "app", "backend", "kiosk", "api"):
|
|
635
|
+
if candidate in svc.lower():
|
|
636
|
+
return candidate
|
|
637
|
+
return ""
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _update_existing_device(existing, result, ssh_user, ssh_key, ip, host_str, port, now) -> None:
|
|
641
|
+
existing.host = host_str
|
|
642
|
+
existing.ip = ip
|
|
643
|
+
existing.ssh_user = ssh_user
|
|
644
|
+
if ssh_key:
|
|
645
|
+
existing.ssh_key = ssh_key
|
|
646
|
+
existing.ssh_port = port
|
|
647
|
+
existing.strategy = result.strategy
|
|
648
|
+
existing.hostname = result.hostname or existing.hostname
|
|
649
|
+
existing.last_seen = now
|
|
650
|
+
existing.last_ssh_ok = now
|
|
651
|
+
if result.app and not existing.app:
|
|
652
|
+
existing.app = result.app
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _create_new_device(result, ssh_user, ssh_key, ip, host_str, port, now) -> "KnownDevice":
|
|
656
|
+
from .models import KnownDevice
|
|
657
|
+
|
|
658
|
+
return KnownDevice(
|
|
659
|
+
id=host_str,
|
|
660
|
+
host=host_str,
|
|
661
|
+
ip=ip,
|
|
662
|
+
mac="",
|
|
663
|
+
hostname=result.hostname,
|
|
664
|
+
ssh_user=ssh_user,
|
|
665
|
+
ssh_key=ssh_key if ssh_key else None,
|
|
666
|
+
ssh_port=port,
|
|
667
|
+
strategy=result.strategy,
|
|
668
|
+
app=result.app,
|
|
669
|
+
last_seen=now,
|
|
670
|
+
last_ssh_ok=now,
|
|
671
|
+
source="probe",
|
|
672
|
+
tags=["discovered"],
|
|
673
|
+
)
|
|
598
674
|
|
|
599
675
|
|
|
600
676
|
def auto_probe(
|
|
@@ -622,12 +698,7 @@ def auto_probe(
|
|
|
622
698
|
ProbeResult with all discovered fields.
|
|
623
699
|
"""
|
|
624
700
|
# Normalise input
|
|
625
|
-
|
|
626
|
-
forced_user, ip = ip_or_host.split("@", 1)
|
|
627
|
-
users = [forced_user] + (users or _DEFAULT_USERS)
|
|
628
|
-
else:
|
|
629
|
-
ip = ip_or_host
|
|
630
|
-
users = users or _DEFAULT_USERS
|
|
701
|
+
ip, users = _parse_probe_input(ip_or_host, users)
|
|
631
702
|
|
|
632
703
|
result = ProbeResult(ip=ip, ssh_port=port)
|
|
633
704
|
|
|
@@ -659,18 +730,7 @@ def auto_probe(
|
|
|
659
730
|
result.has_podman = bool(info.get("has_podman"))
|
|
660
731
|
result.has_chromium = bool(info.get("has_chromium"))
|
|
661
732
|
result.running_services = info.get("running_services", [])
|
|
662
|
-
|
|
663
|
-
# Try to detect app + version
|
|
664
|
-
app = app_hint
|
|
665
|
-
if not app:
|
|
666
|
-
for svc in result.running_services:
|
|
667
|
-
for candidate in ("c2004", "app", "backend", "kiosk", "api"):
|
|
668
|
-
if candidate in svc.lower():
|
|
669
|
-
app = candidate
|
|
670
|
-
break
|
|
671
|
-
if app:
|
|
672
|
-
break
|
|
673
|
-
result.app = app
|
|
733
|
+
result.app = _detect_app_from_services(result.running_services, app_hint)
|
|
674
734
|
|
|
675
735
|
# Save to registry
|
|
676
736
|
if save:
|
|
@@ -678,36 +738,10 @@ def auto_probe(
|
|
|
678
738
|
now = datetime.utcnow()
|
|
679
739
|
existing = reg.get(host_str) or reg.get(ip)
|
|
680
740
|
if existing:
|
|
681
|
-
existing
|
|
682
|
-
existing.ip = ip
|
|
683
|
-
existing.ssh_user = ssh_user
|
|
684
|
-
if ssh_key:
|
|
685
|
-
existing.ssh_key = ssh_key
|
|
686
|
-
existing.ssh_port = port
|
|
687
|
-
existing.strategy = result.strategy
|
|
688
|
-
existing.hostname = result.hostname or existing.hostname
|
|
689
|
-
existing.last_seen = now
|
|
690
|
-
existing.last_ssh_ok = now
|
|
691
|
-
if result.app and not existing.app:
|
|
692
|
-
existing.app = result.app
|
|
741
|
+
_update_existing_device(existing, result, ssh_user, ssh_key, ip, host_str, port, now)
|
|
693
742
|
reg.upsert(existing)
|
|
694
743
|
else:
|
|
695
|
-
reg.upsert(
|
|
696
|
-
id=host_str,
|
|
697
|
-
host=host_str,
|
|
698
|
-
ip=ip,
|
|
699
|
-
mac="",
|
|
700
|
-
hostname=result.hostname,
|
|
701
|
-
ssh_user=ssh_user,
|
|
702
|
-
ssh_key=ssh_key if ssh_key else None,
|
|
703
|
-
ssh_port=port,
|
|
704
|
-
strategy=result.strategy,
|
|
705
|
-
app=result.app,
|
|
706
|
-
last_seen=now,
|
|
707
|
-
last_ssh_ok=now,
|
|
708
|
-
source="probe",
|
|
709
|
-
tags=["discovered"],
|
|
710
|
-
))
|
|
744
|
+
reg.upsert(_create_new_device(result, ssh_user, ssh_key, ip, host_str, port, now))
|
|
711
745
|
reg.save()
|
|
712
746
|
logger.info(f"[probe {ip}] saved to registry as {host_str}")
|
|
713
747
|
|
|
@@ -98,28 +98,8 @@ def parse_diagnostics(output: str) -> dict:
|
|
|
98
98
|
section = _SECTION_HEADERS[line]
|
|
99
99
|
elif not line or line.startswith("__NO_"):
|
|
100
100
|
continue
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
elif section == "containers":
|
|
104
|
-
c = parse_container_line(line)
|
|
105
|
-
if c:
|
|
106
|
-
result["containers"].append(c)
|
|
107
|
-
elif section == "git":
|
|
108
|
-
for prefix in ("BRANCH:", "COMMIT:", "DIRTY:"):
|
|
109
|
-
if line.startswith(prefix):
|
|
110
|
-
result["git"][prefix[:-1].lower()] = line[len(prefix):]
|
|
111
|
-
break
|
|
112
|
-
elif section == "health":
|
|
113
|
-
try:
|
|
114
|
-
result["health_code"] = int(line)
|
|
115
|
-
except ValueError:
|
|
116
|
-
pass
|
|
117
|
-
elif section == "network":
|
|
118
|
-
if line.startswith("PORTS:"):
|
|
119
|
-
try:
|
|
120
|
-
result["listening_ports"] = int(line[6:])
|
|
121
|
-
except ValueError:
|
|
122
|
-
pass
|
|
101
|
+
else:
|
|
102
|
+
_parse_section_line(section, result, line)
|
|
123
103
|
return result
|
|
124
104
|
|
|
125
105
|
|
|
@@ -141,6 +121,47 @@ def _apply_system_line(sys: dict, line: str) -> None:
|
|
|
141
121
|
}
|
|
142
122
|
|
|
143
123
|
|
|
124
|
+
def _apply_git_line(git: dict, line: str) -> bool:
|
|
125
|
+
for prefix in ("BRANCH:", "COMMIT:", "DIRTY:"):
|
|
126
|
+
if line.startswith(prefix):
|
|
127
|
+
git[prefix[:-1].lower()] = line[len(prefix):]
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _apply_health_line(result: dict, line: str) -> bool:
|
|
133
|
+
try:
|
|
134
|
+
result["health_code"] = int(line)
|
|
135
|
+
return True
|
|
136
|
+
except ValueError:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _apply_network_line(result: dict, line: str) -> bool:
|
|
141
|
+
if line.startswith("PORTS:"):
|
|
142
|
+
try:
|
|
143
|
+
result["listening_ports"] = int(line[6:])
|
|
144
|
+
return True
|
|
145
|
+
except ValueError:
|
|
146
|
+
pass
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_section_line(section: str, result: dict, line: str) -> None:
|
|
151
|
+
if section == "system":
|
|
152
|
+
_apply_system_line(result["system"], line)
|
|
153
|
+
elif section == "containers":
|
|
154
|
+
c = parse_container_line(line)
|
|
155
|
+
if c:
|
|
156
|
+
result["containers"].append(c)
|
|
157
|
+
elif section == "git":
|
|
158
|
+
_apply_git_line(result["git"], line)
|
|
159
|
+
elif section == "health":
|
|
160
|
+
_apply_health_line(result, line)
|
|
161
|
+
elif section == "network":
|
|
162
|
+
_apply_network_line(result, line)
|
|
163
|
+
|
|
164
|
+
|
|
144
165
|
# ── health check ─────────────────────────────────────────────────────────────
|
|
145
166
|
|
|
146
167
|
_HEALTH_PREFIXES: dict[str, str] = {
|
|
@@ -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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|