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 ADDED
@@ -0,0 +1,2 @@
1
+ """redeploy — Infrastructure migration toolkit: detect → plan → apply."""
2
+ __version__ = "0.1.1"
@@ -0,0 +1,4 @@
1
+ """apply — Execute a MigrationPlan step by step."""
2
+ from .executor import Executor
3
+
4
+ __all__ = ["Executor"]
@@ -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,4 @@
1
+ """detect — Probe infrastructure and produce InfraState."""
2
+ from .detector import Detector
3
+
4
+ __all__ = ["Detector"]
@@ -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}")