redeploy 0.1.1__py3-none-any.whl
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/__init__.py +2 -0
- redeploy/apply/__init__.py +4 -0
- redeploy/apply/executor.py +199 -0
- redeploy/cli.py +279 -0
- redeploy/detect/__init__.py +4 -0
- redeploy/detect/detector.py +96 -0
- redeploy/detect/probes.py +272 -0
- redeploy/detect/remote.py +53 -0
- redeploy/models.py +189 -0
- redeploy/plan/__init__.py +4 -0
- redeploy/plan/planner.py +284 -0
- redeploy/tests/__init__.py +0 -0
- redeploy/tests/test_models.py +119 -0
- redeploy-0.1.1.dist-info/METADATA +168 -0
- redeploy-0.1.1.dist-info/RECORD +18 -0
- redeploy-0.1.1.dist-info/WHEEL +5 -0
- redeploy-0.1.1.dist-info/entry_points.txt +2 -0
- redeploy-0.1.1.dist-info/top_level.txt +1 -0
redeploy/__init__.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Executor — runs MigrationPlan steps, handles rollback on failure."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import yaml
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
from ..detect.remote import RemoteProbe
|
|
14
|
+
from ..models import MigrationPlan, MigrationStep, StepAction, StepStatus
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StepError(Exception):
|
|
18
|
+
def __init__(self, step: MigrationStep, msg: str):
|
|
19
|
+
self.step = step
|
|
20
|
+
super().__init__(f"[{step.id}] {msg}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Executor:
|
|
24
|
+
"""Execute MigrationPlan steps on a remote host."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, plan: MigrationPlan, dry_run: bool = False):
|
|
27
|
+
self.plan = plan
|
|
28
|
+
self.dry_run = dry_run
|
|
29
|
+
self.probe = RemoteProbe(plan.host)
|
|
30
|
+
self._completed: list[MigrationStep] = []
|
|
31
|
+
|
|
32
|
+
def run(self) -> bool:
|
|
33
|
+
"""Execute all steps. Returns True if all passed."""
|
|
34
|
+
prefix = "[DRY RUN] " if self.dry_run else ""
|
|
35
|
+
logger.info(f"{prefix}Applying plan: {len(self.plan.steps)} steps "
|
|
36
|
+
f"({self.plan.from_strategy.value} → {self.plan.to_strategy.value})")
|
|
37
|
+
|
|
38
|
+
for step in self.plan.steps:
|
|
39
|
+
try:
|
|
40
|
+
self._execute_step(step)
|
|
41
|
+
self._completed.append(step)
|
|
42
|
+
except StepError as e:
|
|
43
|
+
logger.error(f"Step failed: {e}")
|
|
44
|
+
step.status = StepStatus.FAILED
|
|
45
|
+
step.error = str(e)
|
|
46
|
+
if not self.dry_run:
|
|
47
|
+
self._rollback()
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
logger.info(f"{'[DRY RUN] ' if self.dry_run else ''}All {len(self.plan.steps)} steps completed")
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
# ── step dispatcher ───────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def _execute_step(self, step: MigrationStep) -> None:
|
|
56
|
+
logger.info(f" {'[DRY]' if self.dry_run else '→'} [{step.id}] {step.description}")
|
|
57
|
+
step.status = StepStatus.RUNNING
|
|
58
|
+
|
|
59
|
+
if self.dry_run:
|
|
60
|
+
step.status = StepStatus.DONE
|
|
61
|
+
step.result = "dry-run"
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
dispatch = {
|
|
65
|
+
StepAction.SYSTEMCTL_STOP: self._run_ssh,
|
|
66
|
+
StepAction.SYSTEMCTL_DISABLE: self._run_ssh,
|
|
67
|
+
StepAction.SYSTEMCTL_START: self._run_ssh,
|
|
68
|
+
StepAction.KUBECTL_DELETE: self._run_ssh,
|
|
69
|
+
StepAction.DOCKER_COMPOSE_UP: self._run_ssh,
|
|
70
|
+
StepAction.DOCKER_COMPOSE_DOWN: self._run_ssh,
|
|
71
|
+
StepAction.DOCKER_BUILD: self._run_ssh,
|
|
72
|
+
StepAction.SSH_CMD: self._run_ssh,
|
|
73
|
+
StepAction.SCP: self._run_scp,
|
|
74
|
+
StepAction.RSYNC: self._run_rsync,
|
|
75
|
+
StepAction.HTTP_CHECK: self._run_http_check,
|
|
76
|
+
StepAction.VERSION_CHECK: self._run_version_check,
|
|
77
|
+
StepAction.WAIT: self._run_wait,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
handler = dispatch.get(step.action)
|
|
81
|
+
if not handler:
|
|
82
|
+
raise StepError(step, f"No handler for action {step.action}")
|
|
83
|
+
handler(step)
|
|
84
|
+
|
|
85
|
+
# ── handlers ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def _run_ssh(self, step: MigrationStep) -> None:
|
|
88
|
+
cmd = step.command
|
|
89
|
+
if not cmd:
|
|
90
|
+
raise StepError(step, "No command specified")
|
|
91
|
+
r = self.probe.run(cmd, timeout=300)
|
|
92
|
+
step.result = r.out[:500]
|
|
93
|
+
if not r.ok:
|
|
94
|
+
raise StepError(step, f"exit={r.returncode}: {r.stderr[:200]}")
|
|
95
|
+
step.status = StepStatus.DONE
|
|
96
|
+
|
|
97
|
+
def _run_scp(self, step: MigrationStep) -> None:
|
|
98
|
+
if not step.src or not step.dst:
|
|
99
|
+
raise StepError(step, "scp requires src and dst")
|
|
100
|
+
if self.probe.is_local:
|
|
101
|
+
cmd = ["cp", step.src, step.dst]
|
|
102
|
+
else:
|
|
103
|
+
cmd = ["scp", "-o", "StrictHostKeyChecking=no",
|
|
104
|
+
step.src, f"{self.plan.host}:{step.dst}"]
|
|
105
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
106
|
+
if result.returncode != 0:
|
|
107
|
+
raise StepError(step, f"scp failed: {result.stderr[:200]}")
|
|
108
|
+
step.status = StepStatus.DONE
|
|
109
|
+
step.result = "ok"
|
|
110
|
+
|
|
111
|
+
def _run_rsync(self, step: MigrationStep) -> None:
|
|
112
|
+
if not step.src or not step.dst:
|
|
113
|
+
raise StepError(step, "rsync requires src and dst")
|
|
114
|
+
if self.probe.is_local:
|
|
115
|
+
dst = step.dst
|
|
116
|
+
else:
|
|
117
|
+
dst = f"{self.plan.host}:{step.dst}"
|
|
118
|
+
cmd = ["rsync", "-az", "--delete", step.src, dst]
|
|
119
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
120
|
+
if result.returncode != 0:
|
|
121
|
+
raise StepError(step, f"rsync failed: {result.stderr[:200]}")
|
|
122
|
+
step.status = StepStatus.DONE
|
|
123
|
+
step.result = "ok"
|
|
124
|
+
|
|
125
|
+
def _run_http_check(self, step: MigrationStep, retries: int = 5, delay: int = 8) -> None:
|
|
126
|
+
if not step.url:
|
|
127
|
+
raise StepError(step, "http_check requires url")
|
|
128
|
+
last_err = ""
|
|
129
|
+
for attempt in range(retries):
|
|
130
|
+
try:
|
|
131
|
+
r = httpx.get(step.url, timeout=10, verify=False, follow_redirects=True)
|
|
132
|
+
body = r.text
|
|
133
|
+
if step.expect and step.expect not in body:
|
|
134
|
+
last_err = f"expected '{step.expect}' not found in response"
|
|
135
|
+
elif r.status_code >= 400:
|
|
136
|
+
last_err = f"HTTP {r.status_code}"
|
|
137
|
+
else:
|
|
138
|
+
step.status = StepStatus.DONE
|
|
139
|
+
step.result = body[:200]
|
|
140
|
+
return
|
|
141
|
+
except Exception as e:
|
|
142
|
+
last_err = str(e)
|
|
143
|
+
logger.debug(f" retry {attempt + 1}/{retries}: {last_err}")
|
|
144
|
+
time.sleep(delay)
|
|
145
|
+
raise StepError(step, f"HTTP check failed after {retries} retries: {last_err}")
|
|
146
|
+
|
|
147
|
+
def _run_version_check(self, step: MigrationStep) -> None:
|
|
148
|
+
if not step.url or not step.expect:
|
|
149
|
+
raise StepError(step, "version_check requires url and expect")
|
|
150
|
+
try:
|
|
151
|
+
r = httpx.get(step.url, timeout=10, verify=False, follow_redirects=True)
|
|
152
|
+
body = r.text
|
|
153
|
+
if step.expect not in body:
|
|
154
|
+
raise StepError(step, f"version '{step.expect}' not found in response: {body[:100]}")
|
|
155
|
+
step.status = StepStatus.DONE
|
|
156
|
+
step.result = f"version {step.expect} confirmed"
|
|
157
|
+
except StepError:
|
|
158
|
+
raise
|
|
159
|
+
except Exception as e:
|
|
160
|
+
raise StepError(step, str(e))
|
|
161
|
+
|
|
162
|
+
def _run_wait(self, step: MigrationStep) -> None:
|
|
163
|
+
if step.seconds > 0:
|
|
164
|
+
logger.debug(f" waiting {step.seconds}s...")
|
|
165
|
+
time.sleep(step.seconds)
|
|
166
|
+
step.status = StepStatus.DONE
|
|
167
|
+
step.result = f"waited {step.seconds}s"
|
|
168
|
+
|
|
169
|
+
# ── rollback ──────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def _rollback(self) -> None:
|
|
172
|
+
logger.warning("Rolling back completed steps...")
|
|
173
|
+
for step in reversed(self._completed):
|
|
174
|
+
if step.rollback_command:
|
|
175
|
+
logger.info(f" ↩ rollback [{step.id}]: {step.rollback_command}")
|
|
176
|
+
r = self.probe.run(step.rollback_command, timeout=120)
|
|
177
|
+
if not r.ok:
|
|
178
|
+
logger.warning(f" rollback failed: {r.stderr[:100]}")
|
|
179
|
+
|
|
180
|
+
# ── summary ───────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def summary(self) -> str:
|
|
183
|
+
total = len(self.plan.steps)
|
|
184
|
+
done = sum(1 for s in self.plan.steps if s.status == StepStatus.DONE)
|
|
185
|
+
failed = sum(1 for s in self.plan.steps if s.status == StepStatus.FAILED)
|
|
186
|
+
icon = "✅" if failed == 0 else "❌"
|
|
187
|
+
return f"{icon} {done}/{total} steps completed" + (f", {failed} failed" if failed else "")
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def from_file(plan_path: Path) -> "Executor":
|
|
191
|
+
with plan_path.open() as f:
|
|
192
|
+
raw = yaml.safe_load(f)
|
|
193
|
+
plan = MigrationPlan(**raw)
|
|
194
|
+
return Executor(plan)
|
|
195
|
+
|
|
196
|
+
def save_results(self, output: Path) -> None:
|
|
197
|
+
data = self.plan.model_dump(mode="json")
|
|
198
|
+
output.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
|
|
199
|
+
logger.info(f"Results saved to {output}")
|
redeploy/cli.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""redeploy CLI — detect | plan | apply | migrate."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import yaml
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .models import DeployStrategy, TargetConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _setup_logging(verbose: bool) -> None:
|
|
16
|
+
logger.remove()
|
|
17
|
+
level = "DEBUG" if verbose else "INFO"
|
|
18
|
+
logger.add(sys.stderr, level=level,
|
|
19
|
+
format="<green>{time:HH:mm:ss}</green> | <level>{level:<7}</level> | {message}")
|
|
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
|
+
# ── detect ────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
@cli.command()
|
|
36
|
+
@click.option("--host", required=True, help="SSH host (user@ip) or 'local'")
|
|
37
|
+
@click.option("--app", default="c2004", show_default=True, help="Application name")
|
|
38
|
+
@click.option("--domain", default=None, help="Public domain for HTTP health checks")
|
|
39
|
+
@click.option("-o", "--output", default="infra.yaml", show_default=True,
|
|
40
|
+
type=click.Path(), help="Output file for InfraState")
|
|
41
|
+
@click.pass_context
|
|
42
|
+
def detect(ctx, host, app, domain, output):
|
|
43
|
+
"""Probe infrastructure and produce infra.yaml."""
|
|
44
|
+
from rich.console import Console
|
|
45
|
+
from rich.table import Table
|
|
46
|
+
from .detect import Detector
|
|
47
|
+
|
|
48
|
+
console = Console()
|
|
49
|
+
out_path = Path(output)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
d = Detector(host=host, app=app, domain=domain)
|
|
53
|
+
state = d.run()
|
|
54
|
+
d.save(state, out_path)
|
|
55
|
+
except ConnectionError as e:
|
|
56
|
+
console.print(f"[red]✗ {e}[/red]")
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
# Print summary
|
|
60
|
+
console.print(f"\n[bold]Infrastructure: {host}[/bold]")
|
|
61
|
+
|
|
62
|
+
t = Table(show_header=False, box=None, padding=(0, 2))
|
|
63
|
+
t.add_column("key", style="dim")
|
|
64
|
+
t.add_column("value")
|
|
65
|
+
t.add_row("App", state.app)
|
|
66
|
+
t.add_row("Strategy (detected)", state.detected_strategy.value)
|
|
67
|
+
t.add_row("Version", state.current_version or "unknown")
|
|
68
|
+
t.add_row("Docker", state.runtime.docker or "—")
|
|
69
|
+
t.add_row("k3s", state.runtime.k3s or "—")
|
|
70
|
+
t.add_row("Podman", state.runtime.podman or "—")
|
|
71
|
+
t.add_row("Open ports", ", ".join(str(p) for p in sorted(state.ports.keys())))
|
|
72
|
+
console.print(t)
|
|
73
|
+
|
|
74
|
+
# Docker services
|
|
75
|
+
if state.services.get("docker"):
|
|
76
|
+
console.print("\n[bold]Docker containers:[/bold]")
|
|
77
|
+
for s in state.services["docker"]:
|
|
78
|
+
icon = "✅" if s.status == "healthy" else "⚪"
|
|
79
|
+
console.print(f" {icon} {s.name} ({s.status})")
|
|
80
|
+
|
|
81
|
+
# k3s pods
|
|
82
|
+
if state.services.get("k3s"):
|
|
83
|
+
console.print(f"\n[bold]k3s pods ({len(state.services['k3s'])}):[/bold]")
|
|
84
|
+
for s in state.services["k3s"]:
|
|
85
|
+
icon = "✅" if s.status == "running" else "⚪"
|
|
86
|
+
console.print(f" {icon} {s.namespace}/{s.name} ({s.status})")
|
|
87
|
+
|
|
88
|
+
# Conflicts
|
|
89
|
+
if state.conflicts:
|
|
90
|
+
console.print(f"\n[bold yellow]Conflicts ({len(state.conflicts)}):[/bold yellow]")
|
|
91
|
+
for c in state.conflicts:
|
|
92
|
+
color = {"critical": "red", "high": "yellow", "medium": "blue", "low": "dim"}[c.severity.value]
|
|
93
|
+
console.print(f" [{color}][{c.severity.upper()}][/{color}] {c.type}: {c.description}")
|
|
94
|
+
if c.fix_hint:
|
|
95
|
+
console.print(f" [dim]hint: {c.fix_hint}[/dim]")
|
|
96
|
+
else:
|
|
97
|
+
console.print("\n[green]No conflicts detected.[/green]")
|
|
98
|
+
|
|
99
|
+
console.print(f"\n[dim]Saved to {out_path}[/dim]")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── plan ──────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
@cli.command()
|
|
105
|
+
@click.option("--infra", default="infra.yaml", show_default=True,
|
|
106
|
+
type=click.Path(exists=True), help="InfraState file (from detect)")
|
|
107
|
+
@click.option("--target", default=None, type=click.Path(),
|
|
108
|
+
help="Target config YAML (desired state)")
|
|
109
|
+
@click.option("--strategy", default=None,
|
|
110
|
+
type=click.Choice([s.value for s in DeployStrategy]),
|
|
111
|
+
help="Override target strategy")
|
|
112
|
+
@click.option("--domain", default=None, help="Public domain for verify step")
|
|
113
|
+
@click.option("--version", "target_version", default=None, help="Target version to verify")
|
|
114
|
+
@click.option("--compose", multiple=True, help="Compose file(s) for docker_full strategy")
|
|
115
|
+
@click.option("--env-file", default=None, help="Env file path")
|
|
116
|
+
@click.option("-o", "--output", default="migration-plan.yaml", show_default=True,
|
|
117
|
+
type=click.Path(), help="Output migration plan file")
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def plan(ctx, infra, target, strategy, domain, target_version, compose, env_file, output):
|
|
120
|
+
"""Generate migration-plan.yaml from infra.yaml + target config."""
|
|
121
|
+
from rich.console import Console
|
|
122
|
+
from rich.table import Table
|
|
123
|
+
from .plan import Planner
|
|
124
|
+
|
|
125
|
+
console = Console()
|
|
126
|
+
out_path = Path(output)
|
|
127
|
+
infra_path = Path(infra)
|
|
128
|
+
target_path = Path(target) if target else None
|
|
129
|
+
|
|
130
|
+
planner = Planner.from_files(infra_path, target_path)
|
|
131
|
+
|
|
132
|
+
# CLI overrides
|
|
133
|
+
if strategy:
|
|
134
|
+
planner.target.strategy = DeployStrategy(strategy)
|
|
135
|
+
if domain:
|
|
136
|
+
planner.target.domain = domain
|
|
137
|
+
if target_version:
|
|
138
|
+
planner.target.verify_version = target_version
|
|
139
|
+
if compose:
|
|
140
|
+
planner.target.compose_files = list(compose)
|
|
141
|
+
if env_file:
|
|
142
|
+
planner.target.env_file = env_file
|
|
143
|
+
|
|
144
|
+
migration = planner.run()
|
|
145
|
+
planner.save(migration, out_path)
|
|
146
|
+
|
|
147
|
+
console.print(f"\n[bold]Migration plan: {migration.from_strategy.value} → {migration.to_strategy.value}[/bold]")
|
|
148
|
+
console.print(f" Risk: {migration.risk.value}")
|
|
149
|
+
console.print(f" Estimated downtime: {migration.estimated_downtime}")
|
|
150
|
+
console.print(f" Steps: {len(migration.steps)}")
|
|
151
|
+
|
|
152
|
+
if migration.steps:
|
|
153
|
+
console.print("\n[bold]Steps:[/bold]")
|
|
154
|
+
t = Table(show_header=True, box=None, padding=(0, 2))
|
|
155
|
+
t.add_column("#", style="dim", width=3)
|
|
156
|
+
t.add_column("ID")
|
|
157
|
+
t.add_column("Action", style="cyan")
|
|
158
|
+
t.add_column("Description")
|
|
159
|
+
t.add_column("Risk", style="dim")
|
|
160
|
+
for i, step in enumerate(migration.steps, 1):
|
|
161
|
+
t.add_row(str(i), step.id, step.action.value, step.description, step.risk.value)
|
|
162
|
+
console.print(t)
|
|
163
|
+
|
|
164
|
+
if migration.notes:
|
|
165
|
+
console.print("\n[bold yellow]Notes:[/bold yellow]")
|
|
166
|
+
for note in migration.notes:
|
|
167
|
+
console.print(f" • {note}")
|
|
168
|
+
|
|
169
|
+
console.print(f"\n[dim]Saved to {out_path}[/dim]")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── apply ─────────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
@cli.command()
|
|
175
|
+
@click.option("--plan", "plan_file", default="migration-plan.yaml", show_default=True,
|
|
176
|
+
type=click.Path(exists=True), help="Migration plan file")
|
|
177
|
+
@click.option("--dry-run", is_flag=True, help="Show steps without executing")
|
|
178
|
+
@click.option("--step", default=None, help="Run only a specific step by ID")
|
|
179
|
+
@click.option("-o", "--output", default=None, type=click.Path(),
|
|
180
|
+
help="Save results to file after apply")
|
|
181
|
+
@click.pass_context
|
|
182
|
+
def apply(ctx, plan_file, dry_run, step, output):
|
|
183
|
+
"""Execute a migration plan."""
|
|
184
|
+
from rich.console import Console
|
|
185
|
+
from .apply import Executor
|
|
186
|
+
|
|
187
|
+
console = Console()
|
|
188
|
+
executor = Executor.from_file(Path(plan_file))
|
|
189
|
+
|
|
190
|
+
if step:
|
|
191
|
+
# Filter to single step
|
|
192
|
+
matched = [s for s in executor.plan.steps if s.id == step]
|
|
193
|
+
if not matched:
|
|
194
|
+
console.print(f"[red]Step '{step}' not found in plan[/red]")
|
|
195
|
+
ids = ", ".join(s.id for s in executor.plan.steps)
|
|
196
|
+
console.print(f"Available: {ids}")
|
|
197
|
+
sys.exit(1)
|
|
198
|
+
executor.plan.steps = matched
|
|
199
|
+
|
|
200
|
+
executor.dry_run = dry_run
|
|
201
|
+
|
|
202
|
+
prefix = "[DRY RUN] " if dry_run else ""
|
|
203
|
+
console.print(f"\n{prefix}[bold]Applying: {executor.plan.from_strategy.value}"
|
|
204
|
+
f" → {executor.plan.to_strategy.value}[/bold] "
|
|
205
|
+
f"({len(executor.plan.steps)} steps)")
|
|
206
|
+
|
|
207
|
+
ok = executor.run()
|
|
208
|
+
console.print(f"\n{executor.summary()}")
|
|
209
|
+
|
|
210
|
+
if output:
|
|
211
|
+
executor.save_results(Path(output))
|
|
212
|
+
|
|
213
|
+
if not ok:
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── migrate (detect + plan + apply) ──────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
@cli.command()
|
|
220
|
+
@click.option("--host", required=True, help="SSH host (user@ip) or 'local'")
|
|
221
|
+
@click.option("--app", default="c2004", show_default=True)
|
|
222
|
+
@click.option("--domain", default=None)
|
|
223
|
+
@click.option("--target", default=None, type=click.Path(), help="Target config YAML")
|
|
224
|
+
@click.option("--strategy", default="docker_full", show_default=True,
|
|
225
|
+
type=click.Choice([s.value for s in DeployStrategy]))
|
|
226
|
+
@click.option("--version", "target_version", default=None)
|
|
227
|
+
@click.option("--compose", multiple=True)
|
|
228
|
+
@click.option("--env-file", default=None)
|
|
229
|
+
@click.option("--dry-run", is_flag=True)
|
|
230
|
+
@click.option("--infra-out", default="infra.yaml", show_default=True, type=click.Path())
|
|
231
|
+
@click.option("--plan-out", default="migration-plan.yaml", show_default=True, type=click.Path())
|
|
232
|
+
@click.pass_context
|
|
233
|
+
def migrate(ctx, host, app, domain, target, strategy, target_version,
|
|
234
|
+
compose, env_file, dry_run, infra_out, plan_out):
|
|
235
|
+
"""Full pipeline: detect → plan → apply."""
|
|
236
|
+
from rich.console import Console
|
|
237
|
+
from .detect import Detector
|
|
238
|
+
from .plan import Planner
|
|
239
|
+
from .apply import Executor
|
|
240
|
+
from .models import TargetConfig
|
|
241
|
+
|
|
242
|
+
console = Console()
|
|
243
|
+
|
|
244
|
+
# 1. detect
|
|
245
|
+
console.print(f"\n[bold]Step 1/3 — detect[/bold]")
|
|
246
|
+
d = Detector(host=host, app=app, domain=domain)
|
|
247
|
+
state = d.run()
|
|
248
|
+
d.save(state, Path(infra_out))
|
|
249
|
+
console.print(f" Strategy: {state.detected_strategy.value} "
|
|
250
|
+
f" Version: {state.current_version or '?'} "
|
|
251
|
+
f" Conflicts: {len(state.conflicts)}")
|
|
252
|
+
|
|
253
|
+
# 2. plan
|
|
254
|
+
console.print(f"\n[bold]Step 2/3 — plan[/bold]")
|
|
255
|
+
target_path = Path(target) if target else None
|
|
256
|
+
planner = Planner.from_files(Path(infra_out), target_path)
|
|
257
|
+
planner.target.strategy = DeployStrategy(strategy)
|
|
258
|
+
if domain:
|
|
259
|
+
planner.target.domain = domain
|
|
260
|
+
if target_version:
|
|
261
|
+
planner.target.verify_version = target_version
|
|
262
|
+
if compose:
|
|
263
|
+
planner.target.compose_files = list(compose)
|
|
264
|
+
if env_file:
|
|
265
|
+
planner.target.env_file = env_file
|
|
266
|
+
|
|
267
|
+
migration = planner.run()
|
|
268
|
+
planner.save(migration, Path(plan_out))
|
|
269
|
+
console.print(f" Steps: {len(migration.steps)} Risk: {migration.risk.value} "
|
|
270
|
+
f"Downtime: {migration.estimated_downtime}")
|
|
271
|
+
|
|
272
|
+
# 3. apply
|
|
273
|
+
console.print(f"\n[bold]Step 3/3 — apply{' (dry-run)' if dry_run else ''}[/bold]")
|
|
274
|
+
executor = Executor(migration, dry_run=dry_run)
|
|
275
|
+
ok = executor.run()
|
|
276
|
+
console.print(f"\n{executor.summary()}")
|
|
277
|
+
|
|
278
|
+
if not ok:
|
|
279
|
+
sys.exit(1)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Detector — orchestrates all probes and produces InfraState."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from ..models import InfraState
|
|
11
|
+
from .probes import (
|
|
12
|
+
detect_conflicts, detect_strategy, probe_docker_services,
|
|
13
|
+
probe_health, probe_iptables_dnat, probe_k3s_services,
|
|
14
|
+
probe_ports, probe_runtime, probe_systemd_services,
|
|
15
|
+
)
|
|
16
|
+
from .remote import RemoteProbe
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Detector:
|
|
20
|
+
"""Probe infrastructure and produce InfraState."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, host: str, app: str = "c2004", domain: Optional[str] = None):
|
|
23
|
+
self.host = host
|
|
24
|
+
self.app = app
|
|
25
|
+
self.domain = domain
|
|
26
|
+
self.probe = RemoteProbe(host)
|
|
27
|
+
|
|
28
|
+
def run(self) -> InfraState:
|
|
29
|
+
logger.info(f"Detecting infrastructure on {self.host} (app={self.app})")
|
|
30
|
+
|
|
31
|
+
if not self.probe.is_reachable():
|
|
32
|
+
raise ConnectionError(f"Host {self.host} is not reachable via SSH")
|
|
33
|
+
|
|
34
|
+
logger.debug("Probing runtime...")
|
|
35
|
+
runtime = probe_runtime(self.probe)
|
|
36
|
+
logger.debug(f" docker={runtime.docker}, k3s={runtime.k3s}, podman={runtime.podman}")
|
|
37
|
+
|
|
38
|
+
logger.debug("Probing ports...")
|
|
39
|
+
ports = probe_ports(self.probe)
|
|
40
|
+
logger.debug(f" listening ports: {sorted(ports.keys())}")
|
|
41
|
+
|
|
42
|
+
logger.debug("Probing iptables DNAT...")
|
|
43
|
+
dnat = probe_iptables_dnat(self.probe, [80, 443, 8000, 8080, 8443])
|
|
44
|
+
|
|
45
|
+
logger.debug("Probing Docker services...")
|
|
46
|
+
docker_svcs = probe_docker_services(self.probe) if runtime.docker else []
|
|
47
|
+
|
|
48
|
+
logger.debug("Probing k3s services...")
|
|
49
|
+
k3s_svcs = probe_k3s_services(self.probe, runtime.k3s_namespaces) if runtime.k3s else []
|
|
50
|
+
|
|
51
|
+
logger.debug("Probing systemd services...")
|
|
52
|
+
systemd_svcs = probe_systemd_services(self.probe, self.app)
|
|
53
|
+
|
|
54
|
+
logger.debug("Probing HTTP health...")
|
|
55
|
+
health = probe_health(self.host, self.app, self.domain)
|
|
56
|
+
|
|
57
|
+
logger.debug("Detecting conflicts...")
|
|
58
|
+
conflicts = detect_conflicts(ports, dnat, runtime, docker_svcs, k3s_svcs)
|
|
59
|
+
|
|
60
|
+
strategy = detect_strategy(runtime, docker_svcs, k3s_svcs, systemd_svcs)
|
|
61
|
+
logger.info(f"Detected strategy: {strategy.value}")
|
|
62
|
+
|
|
63
|
+
if conflicts:
|
|
64
|
+
logger.warning(f"Found {len(conflicts)} conflict(s):")
|
|
65
|
+
for c in conflicts:
|
|
66
|
+
logger.warning(f" [{c.severity.upper()}] {c.type}: {c.description}")
|
|
67
|
+
|
|
68
|
+
current_version = None
|
|
69
|
+
for h in health:
|
|
70
|
+
if h.version:
|
|
71
|
+
current_version = h.version
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
state = InfraState(
|
|
75
|
+
host=self.host,
|
|
76
|
+
app=self.app,
|
|
77
|
+
runtime=runtime,
|
|
78
|
+
ports=ports,
|
|
79
|
+
services={
|
|
80
|
+
"docker": docker_svcs,
|
|
81
|
+
"k3s": k3s_svcs,
|
|
82
|
+
"systemd": systemd_svcs,
|
|
83
|
+
"podman": [],
|
|
84
|
+
},
|
|
85
|
+
health=health,
|
|
86
|
+
conflicts=conflicts,
|
|
87
|
+
detected_strategy=strategy,
|
|
88
|
+
current_version=current_version,
|
|
89
|
+
raw={"dnat": [{"port": p, "target": t} for p, t in dnat]},
|
|
90
|
+
)
|
|
91
|
+
return state
|
|
92
|
+
|
|
93
|
+
def save(self, state: InfraState, output: Path) -> None:
|
|
94
|
+
data = state.model_dump(mode="json")
|
|
95
|
+
output.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
|
|
96
|
+
logger.info(f"InfraState saved to {output}")
|