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.
- dkview/__init__.py +3 -0
- dkview/__main__.py +3 -0
- dkview/ansi.py +124 -0
- dkview/cli.py +261 -0
- dkview/docker.py +190 -0
- dkview/engine.py +34 -0
- dkview/features/__init__.py +0 -0
- dkview/features/clean.py +150 -0
- dkview/features/dashboard.py +168 -0
- dkview/features/doctor.py +259 -0
- dkview/features/errors.py +456 -0
- dkview/features/images.py +213 -0
- dkview/features/inspect.py +333 -0
- dkview/features/live.py +52 -0
- dkview/features/logs.py +115 -0
- dkview/features/shell.py +52 -0
- dkview/features/tables.py +102 -0
- dkview/formats.py +140 -0
- dkview/layout.py +184 -0
- dkview/options.py +28 -0
- dkview/runner.py +46 -0
- dkview/styles.py +91 -0
- dkview/table.py +108 -0
- dkview/transform.py +184 -0
- dkview/units.py +52 -0
- dkview-3.0.0b5.dist-info/METADATA +1191 -0
- dkview-3.0.0b5.dist-info/RECORD +31 -0
- dkview-3.0.0b5.dist-info/WHEEL +5 -0
- dkview-3.0.0b5.dist-info/entry_points.txt +2 -0
- dkview-3.0.0b5.dist-info/licenses/LICENSE +21 -0
- dkview-3.0.0b5.dist-info/top_level.txt +1 -0
dkview/features/clean.py
ADDED
|
@@ -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
|