dkview 3.0.0b5__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,150 @@
1
+ """`dkview clean`: remove old, unused image tags, keeping the newest few."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from typing import List, Sequence, Tuple
8
+
9
+ from ..ansi import BOLD, DIM, GREEN, RED, YELLOW, paint
10
+ from ..layout import render
11
+ from ..options import Options
12
+ from ..runner import capture
13
+ from ..table import Table
14
+ from ..transform import short_image
15
+ from ..units import ago, human_size
16
+ from .images import (IN_USE, NOT_IN_USE, ImageInfo, by_repository, global_options,
17
+ load_images)
18
+
19
+ DELETE = "delete"
20
+
21
+
22
+ @dataclass
23
+ class Decision:
24
+ image: ImageInfo
25
+ action: str # "delete", "keep: in use", "keep: newest"
26
+
27
+
28
+ def plan(images: List[ImageInfo], keep: int) -> List[Decision]:
29
+ """Decide per tag. Per repository the `keep` newest tags always stay,
30
+ images used by any container (running or stopped) always stay, and
31
+ the rest is deleted. Dangling images (<none>) are deleted when unused."""
32
+ decisions = []
33
+ for repo, items in by_repository(images).items():
34
+ for rank, image in enumerate(items):
35
+ if image.in_use:
36
+ action = "keep: in use"
37
+ elif image.dangling:
38
+ action = DELETE
39
+ elif rank < keep:
40
+ action = "keep: newest"
41
+ else:
42
+ action = DELETE
43
+ decisions.append(Decision(image, action))
44
+ return decisions
45
+
46
+
47
+ def freed_space(decisions: List[Decision]) -> float:
48
+ """Space released: an image ID only counts when every tag of it goes."""
49
+ sizes = {}
50
+ kept_ids = set()
51
+ for d in decisions:
52
+ if d.action == DELETE:
53
+ sizes[d.image.id] = d.image.size
54
+ else:
55
+ kept_ids.add(d.image.id)
56
+ return sum(size for image_id, size in sizes.items() if image_id not in kept_ids)
57
+
58
+
59
+ def _style(header: str, value: str):
60
+ if header == "ACTION":
61
+ if value == DELETE:
62
+ return RED
63
+ if value.endswith("in use"):
64
+ return GREEN
65
+ return DIM
66
+ return None
67
+
68
+
69
+ def plan_table(decisions: List[Decision], short: bool, show_kept: bool) -> Table:
70
+ table = Table(["REPOSITORY", "TAG", "IMAGE ID", "CREATED", "SIZE", "ACTION"])
71
+ touched = {d.image.repository for d in decisions if d.action == DELETE}
72
+ for d in decisions:
73
+ if d.image.repository not in touched:
74
+ continue
75
+ if d.action != DELETE and not show_kept:
76
+ continue
77
+ repo = d.image.repository
78
+ name = short_image(repo) if short else repo
79
+ mark = IN_USE if d.image.in_use else NOT_IN_USE
80
+ table.rows.append([f"{mark} {name}", d.image.tag, d.image.id,
81
+ ago(d.image.created), human_size(d.image.size), d.action])
82
+ return table
83
+
84
+
85
+ def confirm(question: str) -> bool:
86
+ if not sys.stdin.isatty():
87
+ sys.stderr.write("dkview: not asking without a terminal; use --yes to delete "
88
+ "or --dry-run to only look.\n")
89
+ return False
90
+ try:
91
+ answer = input(question)
92
+ except EOFError:
93
+ return False
94
+ return answer.strip().lower() in ("y", "yes")
95
+
96
+
97
+ def remove(argv: Sequence[str], targets: List[ImageInfo]) -> Tuple[int, List[str]]:
98
+ """Delete one reference at a time so one failure doesn't stop the rest."""
99
+ base = global_options(argv)
100
+ removed, errors = 0, []
101
+ for image in targets:
102
+ result = capture(base + ["rmi", image.reference])
103
+ if result.code == 0:
104
+ removed += 1
105
+ sys.stdout.write(paint(" ✓ ", GREEN) + f"removed {image.reference}\n")
106
+ else:
107
+ message = " ".join(result.stderr.split()) or f"exit code {result.code}"
108
+ errors.append(f"{image.reference}: {message}")
109
+ sys.stdout.write(paint(" ✗ ", RED) + f"{image.reference}: {message}\n")
110
+ sys.stdout.flush()
111
+ return removed, errors
112
+
113
+
114
+ def run(argv: Sequence[str], opts: Options) -> int:
115
+ images = load_images(argv)
116
+ if images is None:
117
+ return 1
118
+ if opts.grep:
119
+ images = [i for i in images if opts.grep.search(f"{i.repository}:{i.tag}")]
120
+
121
+ decisions = plan(images, opts.keep)
122
+ targets = [d.image for d in decisions if d.action == DELETE]
123
+ if not targets:
124
+ sys.stdout.write(paint("✓ Nothing to clean: ", GREEN)
125
+ + f"no unused tags beyond the newest {opts.keep} of each "
126
+ "repository, and no dangling images.\n")
127
+ return 0
128
+
129
+ table = plan_table(decisions, opts.short, show_kept=opts.full or opts.dry_run)
130
+ sys.stdout.write(render(table, opts.width, _style) + "\n")
131
+
132
+ freed = freed_space(decisions)
133
+ repos = len({t.repository for t in targets})
134
+ sys.stdout.write(
135
+ paint(f"{len(targets)} images to delete", BOLD, RED) + f" in {repos} repositories, "
136
+ + paint(f"up to {human_size(freed)} freed", BOLD)
137
+ + paint(f" (keeping the newest {opts.keep} per repository and every image "
138
+ "a container uses)", DIM) + "\n")
139
+
140
+ if opts.dry_run:
141
+ sys.stdout.write(paint("Dry run: nothing was deleted.", YELLOW) + "\n")
142
+ return 0
143
+ if not opts.yes and not confirm(f"Delete these {len(targets)} images? [y/N] "):
144
+ sys.stdout.write("Nothing was deleted.\n")
145
+ return 1
146
+
147
+ removed, errors = remove(argv, targets)
148
+ summary = f"Removed {removed} of {len(targets)} images."
149
+ sys.stdout.write("\n" + (paint(summary, GREEN) if not errors else paint(summary, YELLOW)) + "\n")
150
+ return 0 if not errors else 1
@@ -0,0 +1,168 @@
1
+ """`dkview dash`: containers, services, problems and disk use on one screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from ..ansi import BOLD, DIM, GREEN, RED, YELLOW, paint, terminal_width
11
+ from .. import engine
12
+ from ..formats import fields_template
13
+ from ..layout import render
14
+ from ..options import Options
15
+ from ..runner import capture
16
+ from ..styles import container_status, replicas, styler
17
+ from ..table import Table
18
+ from ..transform import compact_age, is_task_container, short_image, short_task_name
19
+
20
+
21
+ def _json_lines(argv: List[str]) -> Optional[List[Dict[str, Any]]]:
22
+ result = capture(argv)
23
+ if result.code != 0:
24
+ return None
25
+ items = []
26
+ for line in result.stdout.splitlines():
27
+ line = line.strip()
28
+ if line:
29
+ try:
30
+ items.append(json.loads(line))
31
+ except ValueError:
32
+ pass
33
+ return items
34
+
35
+
36
+ def collect() -> Dict[str, Any]:
37
+ """Ask docker for everything at once; `stats` alone takes ~2 seconds."""
38
+ program = engine.name()
39
+ commands = {
40
+ "info": [program, "info", "--format", "{{json .}}"],
41
+ "ps": [program, "ps", "-a", "--format",
42
+ fields_template(["ID", "Names", "Image", "State", "Status", "Ports"])],
43
+ "stats": [program, "stats", "--no-stream", "--format",
44
+ fields_template(["ID", "CPUPerc", "MemUsage"])],
45
+ "df": [program, "system", "df", "--format", "{{json .}}"],
46
+ }
47
+ if not engine.is_podman([program]): # podman has no swarm
48
+ commands["services"] = [program, "service", "ls", "--format", "{{json .}}"]
49
+ with ThreadPoolExecutor(len(commands)) as pool:
50
+ futures = {k: pool.submit(_json_lines, v) for k, v in commands.items()}
51
+ data = {k: f.result() for k, f in futures.items()}
52
+ if data["ps"] is None: # docker too old for .State in a template
53
+ data["ps"] = _json_lines([program, "ps", "-a", "--format", "{{json .}}"])
54
+ data.setdefault("services", None)
55
+ return data
56
+
57
+
58
+ def _header(info: Dict[str, Any]) -> str:
59
+ if "host" in info and "version" in info: # podman's layout
60
+ host = info.get("host") or {}
61
+ info = {"ServerVersion": (info.get("version") or {}).get("Version", "?"),
62
+ "Name": host.get("hostname", "?"), "NCPU": host.get("cpus", "?"),
63
+ "MemTotal": host.get("memTotal") or 0, "Engine": "Podman"}
64
+ swarm = (info.get("Swarm") or {}).get("LocalNodeState", "")
65
+ mem = info.get("MemTotal") or 0
66
+ parts = [
67
+ f"{info.get('Engine', 'Docker')} {info.get('ServerVersion', '?')} on {info.get('Name', '?')}",
68
+ f"{info.get('NCPU', '?')} CPUs",
69
+ f"{mem / 1024 ** 3:.1f}GiB RAM" if mem else "",
70
+ f"swarm {swarm}" if swarm and swarm != "inactive" else "",
71
+ ]
72
+ return paint(" · ".join(p for p in parts if p), BOLD)
73
+
74
+
75
+ def _counts(ps: List[Dict[str, Any]]) -> str:
76
+ running = sum(1 for c in ps if c.get("State") == "running")
77
+ unhealthy = sum(1 for c in ps if "unhealthy" in c.get("Status", ""))
78
+ failed = sum(1 for c in ps if c.get("State") == "exited"
79
+ and not c.get("Status", "").startswith("Exited (0)"))
80
+ stopped = sum(1 for c in ps if c.get("State") != "running") - failed
81
+ pieces = [paint(f"{running} running", GREEN)]
82
+ if unhealthy:
83
+ pieces.append(paint(f"{unhealthy} unhealthy", RED))
84
+ if failed:
85
+ pieces.append(paint(f"{failed} failed", RED))
86
+ if stopped:
87
+ pieces.append(paint(f"{stopped} stopped", DIM))
88
+ return "Containers: " + " · ".join(pieces)
89
+
90
+
91
+ def _problems(ps: List[Dict[str, Any]], services: List[Dict[str, Any]]) -> List[str]:
92
+ problems = []
93
+ for c in ps:
94
+ status = c.get("Status", "")
95
+ if container_status(status) == RED:
96
+ problems.append(f"container {c.get('Names')}: {compact_age(status)}")
97
+ elif c.get("State") == "restarting":
98
+ problems.append(f"container {c.get('Names')}: restarting")
99
+ for s in services:
100
+ if replicas(s.get("Replicas", "")) in (RED, YELLOW):
101
+ problems.append(f"service {s.get('Name')}: {s.get('Replicas')} replicas running")
102
+ return problems
103
+
104
+
105
+ def frame(opts: Options, width: Optional[int] = None) -> str:
106
+ width = width or opts.width or terminal_width()
107
+ data = collect()
108
+ if data["ps"] is None:
109
+ return paint(f"Cannot reach {engine.name()}. Is it running?", RED) + "\n"
110
+
111
+ info = (data["info"] or [{}])[0]
112
+ stats = {s.get("ID", "")[:12]: s for s in data["stats"] or []}
113
+ services = data["services"] or []
114
+ # Swarm keeps the last few dead tasks of every service. Their health is the
115
+ # service's REPLICAS, so they would only repeat it, many times over.
116
+ ps = [c for c in data["ps"] if c.get("State") == "running"
117
+ or not is_task_container(c.get("Names", ""))]
118
+ old_tasks = len(data["ps"]) - len(ps)
119
+
120
+ out = [_header(info), _counts(ps), ""]
121
+
122
+ problems = _problems(ps, services)
123
+ if problems:
124
+ out.append(paint(f"Needs attention ({len(problems)})", BOLD, RED))
125
+ out += [" " + paint("✗ ", RED) + p for p in problems]
126
+ else:
127
+ out.append(paint("✓ Everything is running and healthy", GREEN))
128
+ out.append("")
129
+
130
+ def order(c: Dict[str, Any]) -> tuple:
131
+ bad = container_status(c.get("Status", "")) == RED
132
+ return (not bad, c.get("State") != "running", c.get("Names", ""))
133
+
134
+ containers = Table(["NAME", "STATUS", "IMAGE", "CPU %", "MEM", "PORTS"])
135
+ for c in sorted(ps, key=order):
136
+ s = stats.get(c.get("ID", "")[:12], {})
137
+ mem = s.get("MemUsage", "").split(" / ")[0]
138
+ ports = re.sub(r"(0\.0\.0\.0|\[::\]|:::)", "", c.get("Ports", ""))
139
+ ports = ", ".join(dict.fromkeys(p.strip() for p in ports.split(",") if p.strip()))
140
+ image = c.get("Image", "")
141
+ containers.rows.append([
142
+ short_task_name(c.get("Names", "")), compact_age(c.get("Status", "")),
143
+ short_image(image) if opts.short else image,
144
+ s.get("CPUPerc", ""), mem, ports,
145
+ ])
146
+ out.append(paint("Containers", BOLD))
147
+ out.append(render(containers, width, styler))
148
+ if old_tasks:
149
+ out.append(paint(f"{old_tasks} stopped swarm task container{'s' if old_tasks != 1 else ''}"
150
+ " not shown; `dkview doctor` explains failed tasks", DIM))
151
+
152
+ if services:
153
+ table = Table(["NAME", "MODE", "REPLICAS", "IMAGE", "PORTS"])
154
+ for s in sorted(services, key=lambda s: s.get("Name", "")):
155
+ image = re.sub(r"@sha256:[0-9a-f]+", "", s.get("Image", ""))
156
+ table.rows.append([s.get("Name", ""), s.get("Mode", ""), s.get("Replicas", ""),
157
+ short_image(image) if opts.short else image, s.get("Ports", "")])
158
+ out += ["", paint("Services", BOLD), render(table, width, styler)]
159
+
160
+ if data["df"]:
161
+ table = Table(["TYPE", "TOTAL", "ACTIVE", "SIZE", "RECLAIMABLE"])
162
+ for d in data["df"]:
163
+ table.rows.append([d.get("Type", ""), str(d.get("TotalCount", d.get("Total", ""))),
164
+ str(d.get("Active", "")), d.get("Size", ""),
165
+ d.get("Reclaimable", "")])
166
+ out += ["", paint("Disk", BOLD), render(table, width, styler)]
167
+
168
+ return "\n".join(out) + "\n"
@@ -0,0 +1,259 @@
1
+ """`dkview doctor`: what is wrong with each swarm service, and why."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sys
8
+ from collections import OrderedDict
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from dataclasses import dataclass, field
11
+ from typing import Dict, List, Optional, Sequence
12
+
13
+ from .. import engine
14
+ from ..ansi import BOLD, DIM, GREEN, RED, YELLOW, paint, terminal_width
15
+ from ..layout import render
16
+ from ..options import Options
17
+ from ..runner import capture
18
+ from ..styles import styler
19
+ from ..table import Table
20
+ from ..transform import compact_age, short_image, sort_key
21
+ from .images import global_options
22
+
23
+ OK, WARN, BAD = "ok", "warn", "bad"
24
+ FAILED_STATES = ("failed", "rejected", "orphaned")
25
+
26
+
27
+ @dataclass
28
+ class Task:
29
+ slot: str
30
+ node: str
31
+ desired: str
32
+ state: str # "Failed"
33
+ when: str # "4s ago"
34
+ error: str
35
+
36
+ @classmethod
37
+ def from_json(cls, item: dict) -> "Task":
38
+ current = item.get("CurrentState", "")
39
+ state, _, when = current.partition(" ")
40
+ return cls(
41
+ slot=item.get("Name", ""),
42
+ node=item.get("Node", ""),
43
+ desired=item.get("DesiredState", ""),
44
+ state=state,
45
+ when=compact_age(when),
46
+ error=item.get("Error", "").strip().strip('"').strip(),
47
+ )
48
+
49
+ @property
50
+ def failed(self) -> bool:
51
+ return self.state.lower() in FAILED_STATES
52
+
53
+
54
+ @dataclass
55
+ class ServiceReport:
56
+ name: str
57
+ replicas: str
58
+ image: str
59
+ tasks: List[Task] = field(default_factory=list)
60
+ update_state: str = ""
61
+ update_message: str = ""
62
+
63
+ @property
64
+ def running(self) -> int:
65
+ m = re.match(r"(\d+)/(\d+)", self.replicas)
66
+ return int(m.group(1)) if m else 0
67
+
68
+ @property
69
+ def wanted(self) -> int:
70
+ m = re.match(r"(\d+)/(\d+)", self.replicas)
71
+ return int(m.group(2)) if m else 0
72
+
73
+ @property
74
+ def failures(self) -> List[Task]:
75
+ return [t for t in self.tasks if t.failed]
76
+
77
+ @property
78
+ def health(self) -> str:
79
+ if self.running < self.wanted:
80
+ return BAD
81
+ if self.failures or self.update_state in ("paused", "rollback_started",
82
+ "rollback_paused"):
83
+ return WARN
84
+ return OK
85
+
86
+
87
+ def _json_lines(text: str) -> List[dict]:
88
+ items = []
89
+ for line in text.splitlines():
90
+ try:
91
+ items.append(json.loads(line))
92
+ except ValueError:
93
+ pass
94
+ return items
95
+
96
+
97
+ def collect(argv: Sequence[str]) -> Optional[List[ServiceReport]]:
98
+ base = global_options(argv)
99
+ listing = capture(base + ["service", "ls", "--format", "{{json .}}"])
100
+ if listing.code != 0:
101
+ message = listing.stderr.strip()
102
+ if "not a swarm manager" in message or "swarm" in message.lower():
103
+ sys.stderr.write("dkview doctor: this node is not a swarm manager, "
104
+ "so there are no services to check.\n")
105
+ else:
106
+ sys.stderr.write(listing.stderr)
107
+ return None
108
+
109
+ services = [ServiceReport(s.get("Name", ""), s.get("Replicas", ""),
110
+ re.sub(r"@sha256:[0-9a-f]+", "", s.get("Image", "")))
111
+ for s in _json_lines(listing.stdout)]
112
+ if not services:
113
+ return []
114
+
115
+ def tasks_of(name: str) -> List[Task]:
116
+ result = capture(base + ["service", "ps", name, "--no-trunc", "--format", "{{json .}}"])
117
+ return [Task.from_json(t) for t in _json_lines(result.stdout)]
118
+
119
+ with ThreadPoolExecutor(min(8, len(services))) as pool:
120
+ for service, tasks in zip(services, pool.map(tasks_of, [s.name for s in services])):
121
+ service.tasks = tasks
122
+
123
+ inspect = capture(base + ["service", "inspect", "--format", "{{json .}}"]
124
+ + [s.name for s in services])
125
+ for service, data in zip(services, _json_lines(inspect.stdout)):
126
+ status = data.get("UpdateStatus") or {}
127
+ service.update_state = status.get("State", "") or ""
128
+ service.update_message = status.get("Message", "") or ""
129
+ return services
130
+
131
+
132
+ # ------------------------------------------------------------------ hints
133
+
134
+ _HINTS = [
135
+ (r"failed to resolve reference|no such image|manifest unknown|not found: manifest|"
136
+ r"pull access denied|unauthorized",
137
+ "The image can't be pulled. Check the image name and tag, and that the "
138
+ "node can log in to the registry (deploy with --with-registry-auth)."),
139
+ (r"non-zero exit \(137\)",
140
+ "The container was killed (exit 137): usually out of memory or a failed "
141
+ "health check. Check memory limits and `dkview errors {name}`."),
142
+ (r"non-zero exit \((\d+)\)",
143
+ "The program inside the container exited with an error. "
144
+ "See why with `dkview errors {name}`."),
145
+ (r"no suitable node",
146
+ "No node matches the service's placement constraints or resources. "
147
+ "Check constraints, labels and reserved CPU/memory."),
148
+ (r"port .* is already in use|address already in use",
149
+ "A published port is already taken on the node."),
150
+ (r"invalid mount|bind source path does not exist|no such file or directory",
151
+ "A volume or bind mount source doesn't exist on the node."),
152
+ (r"unhealthy container|health check",
153
+ "The container's health check keeps failing. "
154
+ "Check `dkview inspect` on a task container and the service logs."),
155
+ ]
156
+
157
+
158
+ def hint(error: str, service: str) -> str:
159
+ for pattern, text in _HINTS:
160
+ if re.search(pattern, error, re.IGNORECASE):
161
+ return text.format(name=service)
162
+ return ""
163
+
164
+
165
+ # -------------------------------------------------------------- rendering
166
+
167
+ def error_table(report: ServiceReport, every_task: bool) -> Table:
168
+ if every_task:
169
+ table = Table(["TASK", "NODE", "STATE", "WHEN", "ERROR"])
170
+ for t in report.failures:
171
+ table.rows.append([t.slot, t.node, t.state, t.when, t.error])
172
+ return table
173
+
174
+ groups: "OrderedDict[str, List[Task]]" = OrderedDict()
175
+ for t in report.failures:
176
+ groups.setdefault(t.error or "(no error message)", []).append(t)
177
+ table = Table(["TIMES", "STATE", "LAST", "NODES", "ERROR"])
178
+ for error, tasks in groups.items():
179
+ latest = min(tasks, key=lambda t: sort_key(t.when))
180
+ states = ", ".join(OrderedDict.fromkeys(t.state for t in tasks))
181
+ nodes = ", ".join(OrderedDict.fromkeys(t.node for t in tasks if t.node))
182
+ table.rows.append([f"{len(tasks)}×", states, latest.when, nodes, error])
183
+ return table
184
+
185
+
186
+ def _current(report: ServiceReport) -> str:
187
+ latest: Dict[str, Task] = OrderedDict()
188
+ for t in report.tasks: # docker lists the newest task of each slot first
189
+ latest.setdefault(t.slot, t)
190
+ states = [f"{t.state} {t.when}".strip() for t in latest.values()
191
+ if t.desired.lower() != "shutdown" or t.failed]
192
+ return "; ".join(OrderedDict.fromkeys(states))
193
+
194
+
195
+ def render_report(services: List[ServiceReport], opts: Options) -> str:
196
+ out: List[str] = []
197
+ counts = {h: sum(1 for s in services if s.health == h) for h in (OK, WARN, BAD)}
198
+ pieces = [paint(f"{counts[OK]} healthy", GREEN)]
199
+ if counts[WARN]:
200
+ pieces.append(paint(f"{counts[WARN]} restarting", YELLOW))
201
+ if counts[BAD]:
202
+ pieces.append(paint(f"{counts[BAD]} failing", RED))
203
+ out.append(paint("Swarm services: ", BOLD) + " · ".join(pieces))
204
+ out.append("")
205
+
206
+ order = {BAD: 0, WARN: 1, OK: 2}
207
+ problems = sorted((s for s in services if s.health != OK),
208
+ key=lambda s: (order[s.health], s.name))
209
+ for s in problems:
210
+ icon, color = ("✗", RED) if s.health == BAD else ("!", YELLOW)
211
+ image = short_image(s.image) if opts.short else s.image
212
+ out.append(paint(f"{icon} {s.name}", BOLD, color) + " "
213
+ + paint(f"{s.replicas} replicas", color) + paint(f" · {image}", DIM))
214
+ restarts = len(s.failures)
215
+ details = [f"{restarts} failed tasks in recent history" if restarts else ""]
216
+ current = _current(s)
217
+ if current:
218
+ details.append(f"now: {current}")
219
+ if s.update_state:
220
+ details.append(f"update {s.update_state}"
221
+ + (f": {s.update_message}" if s.update_message else ""))
222
+ out.append(" " + " · ".join(d for d in details if d))
223
+ if s.failures:
224
+ table = error_table(s, opts.full)
225
+ out += [" " + line for line in render(table, (opts.width or terminal_width()) - 2, styler).splitlines()]
226
+ seen = set()
227
+ for row in table.rows:
228
+ text = hint(row[-1], s.name)
229
+ if text and text not in seen:
230
+ seen.add(text)
231
+ out.append(" " + paint("→ ", YELLOW) + text)
232
+ out.append("")
233
+
234
+ healthy = [s for s in services if s.health == OK]
235
+ if healthy:
236
+ table = Table(["SERVICE", "REPLICAS", "IMAGE"])
237
+ for s in sorted(healthy, key=lambda s: s.name):
238
+ table.rows.append([s.name, s.replicas, short_image(s.image) if opts.short else s.image])
239
+ out.append(paint("✓ Healthy", BOLD, GREEN))
240
+ out.append(render(table, opts.width or terminal_width(), styler))
241
+ elif not problems:
242
+ out.append("No services.")
243
+ return "\n".join(out).rstrip("\n") + "\n"
244
+
245
+
246
+ def run(argv: Sequence[str], opts: Options) -> int:
247
+ if engine.is_podman(argv):
248
+ sys.stdout.write("Podman has no swarm services, so there is nothing for doctor "
249
+ "to check. `dkview errors` shows problems in container logs.\n")
250
+ return 0
251
+ services = collect(argv)
252
+ if services is None:
253
+ return 1
254
+ if opts.grep:
255
+ services = [s for s in services if opts.grep.search(s.name)]
256
+ sys.stdout.write(render_report(services, opts))
257
+ if opts.full is False and any(s.failures for s in services):
258
+ sys.stdout.write(paint("(identical errors are grouped; --full lists every task)\n", DIM))
259
+ return 1 if any(s.health == BAD for s in services) else 0