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.
@@ -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)
@@ -0,0 +1,4 @@
1
+ """plan — Generate MigrationPlan from InfraState + TargetConfig."""
2
+ from .planner import Planner
3
+
4
+ __all__ = ["Planner"]