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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Individual probes: docker, k3s, systemd, ports, health, version."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ..models import (
|
|
9
|
+
AppHealthInfo, ConflictInfo, ConflictSeverity, DeployStrategy,
|
|
10
|
+
PortInfo, RuntimeInfo, ServiceInfo,
|
|
11
|
+
)
|
|
12
|
+
from .remote import RemoteProbe
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def probe_runtime(p: RemoteProbe) -> RuntimeInfo:
|
|
16
|
+
"""Detect installed runtimes: docker, k3s, podman, systemd."""
|
|
17
|
+
def ver(cmd: str) -> Optional[str]:
|
|
18
|
+
r = p.run(cmd)
|
|
19
|
+
return r.out.splitlines()[0] if r.ok and r.out else None
|
|
20
|
+
|
|
21
|
+
docker = ver("docker --version 2>/dev/null")
|
|
22
|
+
dc = ver("docker compose version 2>/dev/null | head -1")
|
|
23
|
+
k3s = ver("k3s --version 2>/dev/null | head -1")
|
|
24
|
+
podman = ver("podman --version 2>/dev/null")
|
|
25
|
+
systemd = ver("systemctl --version 2>/dev/null | head -1")
|
|
26
|
+
|
|
27
|
+
k3s_ns: list[str] = []
|
|
28
|
+
if k3s:
|
|
29
|
+
r = p.run("k3s kubectl get ns -o name 2>/dev/null")
|
|
30
|
+
if r.ok:
|
|
31
|
+
k3s_ns = [line.replace("namespace/", "").strip() for line in r.out.splitlines() if line.strip()]
|
|
32
|
+
|
|
33
|
+
os_info = ver("cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'")
|
|
34
|
+
arch = ver("uname -m")
|
|
35
|
+
|
|
36
|
+
return RuntimeInfo(
|
|
37
|
+
docker=docker, docker_compose=dc, k3s=k3s, k3s_namespaces=k3s_ns,
|
|
38
|
+
podman=podman, systemd=systemd, os=os_info, arch=arch,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def probe_ports(p: RemoteProbe) -> dict[int, PortInfo]:
|
|
43
|
+
"""Detect listening ports and which process owns them."""
|
|
44
|
+
ports: dict[int, PortInfo] = {}
|
|
45
|
+
r = p.run("ss -tlnp 2>/dev/null | grep LISTEN")
|
|
46
|
+
if not r.ok:
|
|
47
|
+
return ports
|
|
48
|
+
|
|
49
|
+
for line in r.out.splitlines():
|
|
50
|
+
# extract port from Local Address:Port column
|
|
51
|
+
m = re.search(r':(\d+)\s', line)
|
|
52
|
+
if not m:
|
|
53
|
+
continue
|
|
54
|
+
port = int(m.group(1))
|
|
55
|
+
# extract process name
|
|
56
|
+
proc_m = re.search(r'users:\(\("([^"]+)"', line)
|
|
57
|
+
proc = proc_m.group(1) if proc_m else "unknown"
|
|
58
|
+
pid_m = re.search(r'pid=(\d+)', line)
|
|
59
|
+
pid = int(pid_m.group(1)) if pid_m else None
|
|
60
|
+
ports[port] = PortInfo(port=port, process=proc, pid=pid)
|
|
61
|
+
|
|
62
|
+
return ports
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def probe_iptables_dnat(p: RemoteProbe, ports: list[int]) -> list[tuple[int, str]]:
|
|
66
|
+
"""Find iptables DNAT rules stealing specific ports (returns [(port, target_ip)])."""
|
|
67
|
+
results = []
|
|
68
|
+
r = p.run("iptables -t nat -L -n 2>/dev/null | grep DNAT")
|
|
69
|
+
if not r.ok:
|
|
70
|
+
return results
|
|
71
|
+
for port in ports:
|
|
72
|
+
count = 0
|
|
73
|
+
for line in r.out.splitlines():
|
|
74
|
+
if f"dpt:{port}" in line or f":{port} " in line:
|
|
75
|
+
m = re.search(r'to:([0-9.]+:\d+)', line)
|
|
76
|
+
target = m.group(1) if m else "unknown"
|
|
77
|
+
results.append((port, target))
|
|
78
|
+
count += 1
|
|
79
|
+
# multiple DNAT rules on same port = conflict
|
|
80
|
+
return results
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def probe_docker_services(p: RemoteProbe) -> list[ServiceInfo]:
|
|
84
|
+
"""List running Docker containers."""
|
|
85
|
+
services = []
|
|
86
|
+
r = p.run(
|
|
87
|
+
"docker ps --format '{{.Names}}|{{.Image}}|{{.Status}}' 2>/dev/null"
|
|
88
|
+
)
|
|
89
|
+
if not r.ok:
|
|
90
|
+
return services
|
|
91
|
+
|
|
92
|
+
for line in r.out.splitlines():
|
|
93
|
+
parts = line.split("|")
|
|
94
|
+
if len(parts) < 2:
|
|
95
|
+
continue
|
|
96
|
+
name, image = parts[0].strip(), parts[1].strip()
|
|
97
|
+
status = parts[2].strip() if len(parts) > 2 else "unknown"
|
|
98
|
+
healthy = "healthy" in status.lower()
|
|
99
|
+
|
|
100
|
+
# get port mappings
|
|
101
|
+
rp = p.run(f"docker inspect {name} --format '{{{{json .NetworkSettings.Ports}}}}' 2>/dev/null")
|
|
102
|
+
ports: list[int] = []
|
|
103
|
+
if rp.ok and rp.out:
|
|
104
|
+
try:
|
|
105
|
+
pmap = json.loads(rp.out)
|
|
106
|
+
for k in pmap:
|
|
107
|
+
m = re.match(r'(\d+)/', k)
|
|
108
|
+
if m:
|
|
109
|
+
ports.append(int(m.group(1)))
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
services.append(ServiceInfo(
|
|
114
|
+
name=name, image=image,
|
|
115
|
+
status="healthy" if healthy else status,
|
|
116
|
+
ports=ports,
|
|
117
|
+
))
|
|
118
|
+
return services
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def probe_k3s_services(p: RemoteProbe, namespaces: list[str]) -> list[ServiceInfo]:
|
|
122
|
+
"""List running k3s pods."""
|
|
123
|
+
services = []
|
|
124
|
+
for ns in namespaces:
|
|
125
|
+
if ns in ("kube-system", "kube-public", "kube-node-lease", "cert-manager"):
|
|
126
|
+
continue
|
|
127
|
+
r = p.run(
|
|
128
|
+
f"k3s kubectl get pods -n {ns} --no-headers 2>/dev/null"
|
|
129
|
+
" | awk '{print $1\"|\"$2\"|\"$3}'"
|
|
130
|
+
)
|
|
131
|
+
if not r.ok:
|
|
132
|
+
continue
|
|
133
|
+
for line in r.out.splitlines():
|
|
134
|
+
parts = line.split("|")
|
|
135
|
+
if len(parts) < 3:
|
|
136
|
+
continue
|
|
137
|
+
name, ready, phase = parts[0].strip(), parts[1].strip(), parts[2].strip()
|
|
138
|
+
services.append(ServiceInfo(
|
|
139
|
+
name=name, namespace=ns,
|
|
140
|
+
status=phase.lower(),
|
|
141
|
+
))
|
|
142
|
+
return services
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def probe_systemd_services(p: RemoteProbe, app: str) -> list[ServiceInfo]:
|
|
146
|
+
"""List app-related systemd units."""
|
|
147
|
+
services = []
|
|
148
|
+
r = p.run(
|
|
149
|
+
f"systemctl list-units --type=service --state=active --no-pager 2>/dev/null"
|
|
150
|
+
f" | grep -i {app} | awk '{{print $1\"|\"$3\"|\"$4}}'"
|
|
151
|
+
)
|
|
152
|
+
if not r.ok:
|
|
153
|
+
return services
|
|
154
|
+
for line in r.out.splitlines():
|
|
155
|
+
parts = line.split("|")
|
|
156
|
+
if not parts[0].strip():
|
|
157
|
+
continue
|
|
158
|
+
services.append(ServiceInfo(
|
|
159
|
+
name=parts[0].strip(),
|
|
160
|
+
unit=parts[0].strip(),
|
|
161
|
+
status=parts[1].strip() if len(parts) > 1 else "unknown",
|
|
162
|
+
))
|
|
163
|
+
return services
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def probe_health(host: str, app: str, domain: Optional[str] = None) -> list[AppHealthInfo]:
|
|
167
|
+
"""HTTP health checks against known endpoints."""
|
|
168
|
+
import httpx
|
|
169
|
+
|
|
170
|
+
results = []
|
|
171
|
+
base = f"https://{domain}" if domain else None
|
|
172
|
+
endpoints = []
|
|
173
|
+
|
|
174
|
+
if base:
|
|
175
|
+
endpoints += [
|
|
176
|
+
(f"{base}/api/v1/health", "backend"),
|
|
177
|
+
(f"{base}/firmware/health", "firmware"),
|
|
178
|
+
(f"{base}/", "frontend"),
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
for url, name in endpoints:
|
|
182
|
+
try:
|
|
183
|
+
r = httpx.get(url, timeout=5, verify=False, follow_redirects=True)
|
|
184
|
+
version = None
|
|
185
|
+
if r.headers.get("content-type", "").startswith("application/json"):
|
|
186
|
+
try:
|
|
187
|
+
body = r.json()
|
|
188
|
+
version = body.get("version")
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
results.append(AppHealthInfo(
|
|
192
|
+
url=url, status_code=r.status_code,
|
|
193
|
+
version=version,
|
|
194
|
+
healthy=(r.status_code == 200),
|
|
195
|
+
))
|
|
196
|
+
except Exception as e:
|
|
197
|
+
results.append(AppHealthInfo(url=url, healthy=False))
|
|
198
|
+
|
|
199
|
+
return results
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def detect_conflicts(
|
|
203
|
+
ports: dict[int, PortInfo],
|
|
204
|
+
iptables_dnat: list[tuple[int, str]],
|
|
205
|
+
runtime: RuntimeInfo,
|
|
206
|
+
docker_services: list[ServiceInfo],
|
|
207
|
+
k3s_services: list[ServiceInfo],
|
|
208
|
+
) -> list[ConflictInfo]:
|
|
209
|
+
"""Identify conflicts: port stealing, duplicate services, etc."""
|
|
210
|
+
conflicts = []
|
|
211
|
+
|
|
212
|
+
# Multiple DNAT rules on same port
|
|
213
|
+
seen_ports: dict[int, int] = {}
|
|
214
|
+
for port, target in iptables_dnat:
|
|
215
|
+
seen_ports[port] = seen_ports.get(port, 0) + 1
|
|
216
|
+
for port, count in seen_ports.items():
|
|
217
|
+
if count > 1:
|
|
218
|
+
conflicts.append(ConflictInfo(
|
|
219
|
+
type="port_steal",
|
|
220
|
+
description=f"Port {port} has {count} iptables DNAT rules — k3s may intercept before docker-proxy",
|
|
221
|
+
severity=ConflictSeverity.HIGH,
|
|
222
|
+
affected=[str(port)],
|
|
223
|
+
fix_hint=f"Stop k3s: `systemctl stop k3s && systemctl disable k3s`",
|
|
224
|
+
))
|
|
225
|
+
|
|
226
|
+
# k3s running alongside Docker on same app
|
|
227
|
+
if runtime.k3s and docker_services:
|
|
228
|
+
docker_names = {s.name for s in docker_services}
|
|
229
|
+
k3s_names = {s.name for s in k3s_services}
|
|
230
|
+
overlap_hint = docker_names & {s.replace("-", "") for s in k3s_names}
|
|
231
|
+
if k3s_services:
|
|
232
|
+
conflicts.append(ConflictInfo(
|
|
233
|
+
type="dual_runtime",
|
|
234
|
+
description=f"k3s ({runtime.k3s}) and Docker both running — may serve same app from different versions",
|
|
235
|
+
severity=ConflictSeverity.HIGH,
|
|
236
|
+
affected=list(k3s_names),
|
|
237
|
+
fix_hint="Use `redeploy plan` to generate a migration plan",
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
# Port 80/443 not owned by expected process
|
|
241
|
+
for port in (80, 443):
|
|
242
|
+
if port in ports:
|
|
243
|
+
proc = ports[port].process
|
|
244
|
+
if proc not in ("docker-proxy", "nginx", "traefik"):
|
|
245
|
+
conflicts.append(ConflictInfo(
|
|
246
|
+
type="unexpected_port_owner",
|
|
247
|
+
description=f"Port {port} owned by '{proc}' (expected docker-proxy/nginx/traefik)",
|
|
248
|
+
severity=ConflictSeverity.MEDIUM,
|
|
249
|
+
affected=[str(port)],
|
|
250
|
+
))
|
|
251
|
+
|
|
252
|
+
return conflicts
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def detect_strategy(
|
|
256
|
+
runtime: RuntimeInfo,
|
|
257
|
+
docker_services: list[ServiceInfo],
|
|
258
|
+
k3s_services: list[ServiceInfo],
|
|
259
|
+
systemd_services: list[ServiceInfo],
|
|
260
|
+
) -> DeployStrategy:
|
|
261
|
+
"""Infer the current deployment strategy from detected services."""
|
|
262
|
+
if k3s_services and not docker_services:
|
|
263
|
+
return DeployStrategy.K3S
|
|
264
|
+
if docker_services and not k3s_services:
|
|
265
|
+
return DeployStrategy.DOCKER_FULL
|
|
266
|
+
if docker_services and k3s_services:
|
|
267
|
+
return DeployStrategy.K3S # k3s is winning (has DNAT priority)
|
|
268
|
+
if systemd_services and runtime.podman:
|
|
269
|
+
return DeployStrategy.PODMAN_QUADLET
|
|
270
|
+
if systemd_services:
|
|
271
|
+
return DeployStrategy.SYSTEMD
|
|
272
|
+
return DeployStrategy.UNKNOWN
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""SSH remote executor for detect probes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class RunResult:
|
|
10
|
+
stdout: str
|
|
11
|
+
stderr: str
|
|
12
|
+
returncode: int
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def ok(self) -> bool:
|
|
16
|
+
return self.returncode == 0
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def out(self) -> str:
|
|
20
|
+
return self.stdout.strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RemoteProbe:
|
|
24
|
+
"""Execute shell commands over SSH (or locally)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, host: str):
|
|
27
|
+
self.host = host
|
|
28
|
+
self.is_local = (host == "local" or host == "localhost")
|
|
29
|
+
|
|
30
|
+
def run(self, cmd: str, timeout: int = 30) -> RunResult:
|
|
31
|
+
if self.is_local:
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
cmd, shell=True, capture_output=True, text=True, timeout=timeout
|
|
34
|
+
)
|
|
35
|
+
else:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["ssh", "-o", "StrictHostKeyChecking=no",
|
|
38
|
+
"-o", "ConnectTimeout=10",
|
|
39
|
+
"-o", "BatchMode=yes",
|
|
40
|
+
self.host, cmd],
|
|
41
|
+
capture_output=True, text=True, timeout=timeout,
|
|
42
|
+
)
|
|
43
|
+
return RunResult(
|
|
44
|
+
stdout=result.stdout,
|
|
45
|
+
stderr=result.stderr,
|
|
46
|
+
returncode=result.returncode,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def is_reachable(self) -> bool:
|
|
50
|
+
if self.is_local:
|
|
51
|
+
return True
|
|
52
|
+
r = self.run("echo ok", timeout=10)
|
|
53
|
+
return r.ok and "ok" in r.out
|
redeploy/models.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Shared data models for redeploy: InfraState, MigrationPlan, Target."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Enums ─────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
class ConflictSeverity(str, Enum):
|
|
14
|
+
LOW = "low"
|
|
15
|
+
MEDIUM = "medium"
|
|
16
|
+
HIGH = "high"
|
|
17
|
+
CRITICAL = "critical"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StepAction(str, Enum):
|
|
21
|
+
SYSTEMCTL_STOP = "systemctl_stop"
|
|
22
|
+
SYSTEMCTL_DISABLE = "systemctl_disable"
|
|
23
|
+
SYSTEMCTL_START = "systemctl_start"
|
|
24
|
+
KUBECTL_DELETE = "kubectl_delete"
|
|
25
|
+
DOCKER_COMPOSE_UP = "docker_compose_up"
|
|
26
|
+
DOCKER_COMPOSE_DOWN = "docker_compose_down"
|
|
27
|
+
DOCKER_BUILD = "docker_build"
|
|
28
|
+
RSYNC = "rsync"
|
|
29
|
+
SCP = "scp"
|
|
30
|
+
SSH_CMD = "ssh_cmd"
|
|
31
|
+
HTTP_CHECK = "http_check"
|
|
32
|
+
VERSION_CHECK = "version_check"
|
|
33
|
+
WAIT = "wait"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StepStatus(str, Enum):
|
|
37
|
+
PENDING = "pending"
|
|
38
|
+
RUNNING = "running"
|
|
39
|
+
DONE = "done"
|
|
40
|
+
FAILED = "failed"
|
|
41
|
+
SKIPPED = "skipped"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DeployStrategy(str, Enum):
|
|
45
|
+
DOCKER_FULL = "docker_full"
|
|
46
|
+
NATIVE_KIOSK = "native_kiosk"
|
|
47
|
+
DOCKER_KIOSK = "docker_kiosk"
|
|
48
|
+
PODMAN_QUADLET = "podman_quadlet"
|
|
49
|
+
K3S = "k3s"
|
|
50
|
+
SYSTEMD = "systemd"
|
|
51
|
+
UNKNOWN = "unknown"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── InfraState (output of detect) ─────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
class ServiceInfo(BaseModel):
|
|
57
|
+
name: str
|
|
58
|
+
image: Optional[str] = None
|
|
59
|
+
status: str = "unknown"
|
|
60
|
+
version: Optional[str] = None
|
|
61
|
+
ports: list[int] = Field(default_factory=list)
|
|
62
|
+
labels: dict[str, str] = Field(default_factory=dict)
|
|
63
|
+
namespace: Optional[str] = None # k3s/k8s namespace
|
|
64
|
+
unit: Optional[str] = None # systemd unit name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PortInfo(BaseModel):
|
|
68
|
+
port: int
|
|
69
|
+
process: str
|
|
70
|
+
pid: Optional[int] = None
|
|
71
|
+
via: Optional[str] = None # "traefik", "nginx", "ingress-nginx"
|
|
72
|
+
protocol: str = "tcp"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ConflictInfo(BaseModel):
|
|
76
|
+
type: str # "port_steal", "version_mismatch", "duplicate_service"
|
|
77
|
+
description: str
|
|
78
|
+
severity: ConflictSeverity = ConflictSeverity.MEDIUM
|
|
79
|
+
affected: list[str] = Field(default_factory=list)
|
|
80
|
+
fix_hint: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RuntimeInfo(BaseModel):
|
|
84
|
+
docker: Optional[str] = None
|
|
85
|
+
docker_compose: Optional[str] = None
|
|
86
|
+
k3s: Optional[str] = None
|
|
87
|
+
k3s_namespaces: list[str] = Field(default_factory=list)
|
|
88
|
+
podman: Optional[str] = None
|
|
89
|
+
systemd: Optional[str] = None
|
|
90
|
+
os: Optional[str] = None
|
|
91
|
+
arch: Optional[str] = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AppHealthInfo(BaseModel):
|
|
95
|
+
url: str
|
|
96
|
+
status_code: Optional[int] = None
|
|
97
|
+
version: Optional[str] = None
|
|
98
|
+
healthy: bool = False
|
|
99
|
+
response_ms: Optional[float] = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class InfraState(BaseModel):
|
|
103
|
+
"""Full detected state of infrastructure — output of `detect`."""
|
|
104
|
+
host: str # "root@1.2.3.4" or "local"
|
|
105
|
+
app: str = "unknown"
|
|
106
|
+
scanned_at: datetime = Field(default_factory=datetime.utcnow)
|
|
107
|
+
|
|
108
|
+
runtime: RuntimeInfo = Field(default_factory=RuntimeInfo)
|
|
109
|
+
ports: dict[int, PortInfo] = Field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
services: dict[str, list[ServiceInfo]] = Field(
|
|
112
|
+
default_factory=lambda: {"docker": [], "k3s": [], "systemd": [], "podman": []}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
health: list[AppHealthInfo] = Field(default_factory=list)
|
|
116
|
+
conflicts: list[ConflictInfo] = Field(default_factory=list)
|
|
117
|
+
raw: dict[str, Any] = Field(default_factory=dict) # raw probe output
|
|
118
|
+
|
|
119
|
+
detected_strategy: DeployStrategy = DeployStrategy.UNKNOWN
|
|
120
|
+
current_version: Optional[str] = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── Target (input to plan) ─────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
class TargetConfig(BaseModel):
|
|
126
|
+
"""Desired infrastructure state — input to `plan`."""
|
|
127
|
+
strategy: DeployStrategy = DeployStrategy.DOCKER_FULL
|
|
128
|
+
app: str = "c2004"
|
|
129
|
+
version: Optional[str] = None
|
|
130
|
+
|
|
131
|
+
compose_files: list[str] = Field(default_factory=list)
|
|
132
|
+
env_file: Optional[str] = None
|
|
133
|
+
remote_dir: str = "~/c2004"
|
|
134
|
+
domain: Optional[str] = None
|
|
135
|
+
|
|
136
|
+
stop_services: list[str] = Field(default_factory=list) # systemd units to stop
|
|
137
|
+
disable_services: list[str] = Field(default_factory=list)
|
|
138
|
+
delete_k3s_namespaces: list[str] = Field(default_factory=list)
|
|
139
|
+
|
|
140
|
+
verify_url: Optional[str] = None
|
|
141
|
+
verify_version: Optional[str] = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── MigrationStep ─────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
class MigrationStep(BaseModel):
|
|
147
|
+
id: str
|
|
148
|
+
action: StepAction
|
|
149
|
+
description: str
|
|
150
|
+
status: StepStatus = StepStatus.PENDING
|
|
151
|
+
|
|
152
|
+
# action-specific params
|
|
153
|
+
service: Optional[str] = None # systemctl service name
|
|
154
|
+
command: Optional[str] = None # raw SSH command
|
|
155
|
+
compose: Optional[str] = None # compose file path
|
|
156
|
+
flags: list[str] = Field(default_factory=list)
|
|
157
|
+
url: Optional[str] = None # HTTP check URL
|
|
158
|
+
expect: Optional[str] = None # expected string in response
|
|
159
|
+
src: Optional[str] = None # rsync/scp source
|
|
160
|
+
dst: Optional[str] = None # rsync/scp destination
|
|
161
|
+
seconds: int = 0 # wait duration
|
|
162
|
+
namespace: Optional[str] = None # k3s namespace
|
|
163
|
+
|
|
164
|
+
reason: Optional[str] = None
|
|
165
|
+
risk: ConflictSeverity = ConflictSeverity.LOW
|
|
166
|
+
rollback_command: Optional[str] = None
|
|
167
|
+
|
|
168
|
+
result: Optional[str] = None # filled after apply
|
|
169
|
+
error: Optional[str] = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── MigrationPlan (output of plan, input to apply) ───────────────────────────
|
|
173
|
+
|
|
174
|
+
class MigrationPlan(BaseModel):
|
|
175
|
+
"""Full migration plan — output of `plan`, input to `apply`."""
|
|
176
|
+
infra_file: str = "infra.yaml"
|
|
177
|
+
target_file: Optional[str] = None
|
|
178
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
179
|
+
|
|
180
|
+
host: str
|
|
181
|
+
app: str
|
|
182
|
+
from_strategy: DeployStrategy
|
|
183
|
+
to_strategy: DeployStrategy
|
|
184
|
+
|
|
185
|
+
risk: ConflictSeverity = ConflictSeverity.LOW
|
|
186
|
+
estimated_downtime: str = "unknown"
|
|
187
|
+
steps: list[MigrationStep] = Field(default_factory=list)
|
|
188
|
+
|
|
189
|
+
notes: list[str] = Field(default_factory=list)
|