docker-orb 1.0.0__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.
docker_orb/inject.py ADDED
@@ -0,0 +1,160 @@
1
+ """
2
+ docker-orb-inject — test utility for injecting log messages into running
3
+ Compose containers.
4
+
5
+ Writes directly to the container's PID 1 stdout fd via `docker exec` so the
6
+ message appears in the live log stream exactly like a real log line.
7
+
8
+ Usage:
9
+ docker-orb-inject # interactive: pick container, loop for messages
10
+ docker-orb-inject -n myapp -d api -m "ERROR: test error"
11
+ docker-orb-inject -n myapp -c myapp-api-1 -m "WARN: test"
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import subprocess
16
+ import sys
17
+
18
+ import click
19
+
20
+
21
+ def _list_containers(project: str) -> list[tuple[str, str]]:
22
+ """Return list of (container_name, service_name) tuples for a project."""
23
+ result = subprocess.run(
24
+ ["docker", "compose", "-p", project, "ps", "--all",
25
+ "--format", "{{.Name}}\t{{.Service}}"],
26
+ capture_output=True, text=True,
27
+ )
28
+ containers = []
29
+ for line in result.stdout.strip().splitlines():
30
+ parts = line.split("\t")
31
+ if len(parts) >= 2:
32
+ containers.append((parts[0], parts[1]))
33
+ return containers
34
+
35
+
36
+ def _resolve_container(project: str, service: str | None, container: str | None) -> str | None:
37
+ """Resolve a specific container name from either a service name or direct container name."""
38
+ if container:
39
+ return container
40
+
41
+ containers = _list_containers(project)
42
+ if not containers:
43
+ return None
44
+
45
+ if service:
46
+ matches = [c for c, s in containers if s == service]
47
+ if not matches:
48
+ click.echo(f"No containers found for service '{service}'.", err=True)
49
+ return None
50
+ # Pick the first one (could add round-robin later)
51
+ return matches[0]
52
+
53
+ return None
54
+
55
+
56
+ def _inject(container_name: str, message: str, prefix: str) -> bool:
57
+ """Write a message to the container's stdout. Returns True on success."""
58
+ full_message = f"{prefix}{message}" if prefix else message
59
+ # Write to /proc/1/fd/1 — the main process's stdout fd — so it appears in `docker logs`
60
+ result = subprocess.run(
61
+ ["docker", "exec", container_name,
62
+ "sh", "-c", f"echo '{full_message}' > /proc/1/fd/1"],
63
+ capture_output=True, text=True,
64
+ )
65
+ if result.returncode != 0:
66
+ click.echo(f"Inject failed: {result.stderr.strip()}", err=True)
67
+ return False
68
+ return True
69
+
70
+
71
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
72
+ @click.option("-n", "--project", default=None,
73
+ help="Docker Compose project name (default: current directory's compose project).")
74
+ @click.option("-d", "--service", default=None, metavar="NAME",
75
+ help="Service name — a container from this service will be chosen.")
76
+ @click.option("-c", "--container", default=None, metavar="NAME",
77
+ help="Exact container name to inject into.")
78
+ @click.option("-m", "--message", "messages", multiple=True, metavar="MSG",
79
+ help="Message(s) to inject. Omit for interactive mode.")
80
+ @click.option("--prefix", default="[TEST] ", show_default=True,
81
+ help="Prefix prepended to every injected message.")
82
+ @click.option("--no-prefix", is_flag=True,
83
+ help="Suppress the prefix entirely.")
84
+ def main(
85
+ project: str | None,
86
+ service: str | None,
87
+ container: str | None,
88
+ messages: tuple[str, ...],
89
+ prefix: str,
90
+ no_prefix: bool,
91
+ ) -> None:
92
+ """
93
+ Inject log messages into a running container for testing docker-orb
94
+ filters/monitors.
95
+
96
+ Messages are written to the container's stdout so they appear in the
97
+ live log stream.
98
+
99
+ \b
100
+ Examples:
101
+ docker-orb-inject # fully interactive
102
+ docker-orb-inject -d api -m "ERROR: test"
103
+ docker-orb-inject -d worker -m "job failed" -m "OOM killed"
104
+ docker-orb-inject -c myapp-api-1 --no-prefix -m "raw message"
105
+ """
106
+ from . import compose as k
107
+
108
+ if no_prefix:
109
+ prefix = ""
110
+
111
+ # Resolve project
112
+ if project is None:
113
+ project = k.get_current_project()
114
+
115
+ # If neither service nor container given, show a picker
116
+ if not service and not container:
117
+ containers = _list_containers(project)
118
+ if not containers:
119
+ click.echo(f"No containers found for project '{project}'.", err=True)
120
+ sys.exit(1)
121
+
122
+ click.echo(f"\nContainers in project '{project}':\n")
123
+ for i, (cname, sname) in enumerate(containers):
124
+ click.echo(f" [{i}] {cname} ({sname})")
125
+ click.echo()
126
+
127
+ choice = click.prompt("Select container number", type=int)
128
+ if choice < 0 or choice >= len(containers):
129
+ click.echo("Invalid choice.", err=True)
130
+ sys.exit(1)
131
+ container = containers[choice][0]
132
+
133
+ # Resolve to a specific container name
134
+ target_container = _resolve_container(project, service, container)
135
+ if not target_container:
136
+ click.echo("Could not resolve a target container.", err=True)
137
+ sys.exit(1)
138
+
139
+ click.echo(f"Injecting into container: {target_container} (project: {project})")
140
+ click.echo(f"Prefix: '{prefix}'\n")
141
+
142
+ # One-shot mode
143
+ if messages:
144
+ for msg in messages:
145
+ ok = _inject(target_container, msg, prefix)
146
+ if ok:
147
+ click.echo(f" ✓ {prefix}{msg}")
148
+ return
149
+
150
+ # Interactive loop
151
+ click.echo("Interactive mode — type a message and press Enter. Ctrl+C to quit.\n")
152
+ while True:
153
+ try:
154
+ msg = click.prompt("Message")
155
+ ok = _inject(target_container, msg, prefix)
156
+ if ok:
157
+ click.echo(f" ✓ Injected")
158
+ except (click.Abort, KeyboardInterrupt):
159
+ click.echo("\nDone.")
160
+ break
docker_orb/jsonlog.py ADDED
@@ -0,0 +1,125 @@
1
+ """
2
+ Detection and readable-formatting for structured JSON log lines.
3
+
4
+ Most k8s services that emit structured logs use JSON — nearly unreadable as
5
+ a raw line. parse_json_log_line() detects a JSON object line and extracts
6
+ common level/message/timestamp fields (checking a few conventional key
7
+ names, since loggers disagree on naming), leaving everything else as
8
+ trailing key=value context. The caller decides whether to actually display
9
+ the formatted form or the raw line — detection always runs regardless, so
10
+ the "press Enter for full JSON" detail view works even in raw mode.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+
18
+ # Checked in order; first key present wins. Covers the common structured
19
+ # logging libraries (zap, logrus, structlog, bunyan, stdlib json logging, ...).
20
+ _LEVEL_KEYS = ("level", "lvl", "severity", "loglevel", "log.level")
21
+ _MESSAGE_KEYS = ("message", "msg", "text")
22
+ _TIME_KEYS = ("timestamp", "time", "ts", "@timestamp", "asctime")
23
+
24
+ # Level name -> Rich style, for coloring the level token in the main stream.
25
+ LEVEL_STYLES = {
26
+ "TRACE": "dim",
27
+ "DEBUG": "dim cyan",
28
+ "INFO": "cyan",
29
+ "NOTICE": "cyan",
30
+ "WARN": "bold yellow",
31
+ "WARNING": "bold yellow",
32
+ "ERROR": "bold red",
33
+ "FATAL": "bold white on red",
34
+ "CRITICAL": "bold white on red",
35
+ "PANIC": "bold white on red",
36
+ }
37
+
38
+
39
+ @dataclass
40
+ class ParsedJsonLine:
41
+ raw: dict # the full decoded object, for the detail modal
42
+ level: str | None # as found in the data, original casing
43
+ message: str # falls back to a placeholder if no message-like key found
44
+ timestamp: str | None # already shortened to HH:MM:SS where parseable
45
+ extras: dict = field(default_factory=dict) # remaining top-level keys
46
+
47
+ @property
48
+ def extras_text(self) -> str:
49
+ return " ".join(f"{k}={_format_value(v)}" for k, v in self.extras.items())
50
+
51
+ @property
52
+ def display_text(self) -> str:
53
+ """Single-line readable rendering: `HH:MM:SS LEVEL message k=v k=v`."""
54
+ parts = []
55
+ if self.timestamp:
56
+ parts.append(self.timestamp)
57
+ if self.level:
58
+ parts.append(f"{self.level.upper():<5}")
59
+ parts.append(self.message)
60
+ if self.extras:
61
+ parts.append(self.extras_text)
62
+ return " ".join(parts)
63
+
64
+ @property
65
+ def pretty(self) -> str:
66
+ """Full pretty-printed JSON, for the detail modal."""
67
+ return json.dumps(self.raw, indent=2, sort_keys=False, default=str)
68
+
69
+
70
+ def parse_json_log_line(content: str) -> ParsedJsonLine | None:
71
+ """
72
+ Return a ParsedJsonLine if `content` is a single JSON object, else None.
73
+ Cheap to call on every line: bails before attempting to parse anything
74
+ that doesn't even look like an object.
75
+ """
76
+ stripped = content.strip()
77
+ if len(stripped) < 2 or stripped[0] != "{" or stripped[-1] != "}":
78
+ return None
79
+ try:
80
+ data = json.loads(stripped)
81
+ except (json.JSONDecodeError, ValueError, RecursionError):
82
+ return None
83
+ if not isinstance(data, dict) or not data:
84
+ return None
85
+
86
+ level_key = _first_key(data, _LEVEL_KEYS)
87
+ message_key = _first_key(data, _MESSAGE_KEYS)
88
+ time_key = _first_key(data, _TIME_KEYS)
89
+
90
+ level = str(data[level_key]) if level_key else None
91
+ message = str(data[message_key]) if message_key else "(no message field)"
92
+ timestamp = _short_time(data[time_key]) if time_key else None
93
+
94
+ used = {k for k in (level_key, message_key, time_key) if k is not None}
95
+ extras = {k: v for k, v in data.items() if k not in used}
96
+
97
+ return ParsedJsonLine(raw=data, level=level, message=message,
98
+ timestamp=timestamp, extras=extras)
99
+
100
+
101
+ def _first_key(data: dict, candidates: tuple[str, ...]) -> str | None:
102
+ for key in candidates:
103
+ if key in data and data[key] not in (None, ""):
104
+ return key
105
+ return None
106
+
107
+
108
+ def _short_time(value: object) -> str:
109
+ if isinstance(value, (int, float)):
110
+ try:
111
+ return datetime.fromtimestamp(value, tz=timezone.utc).strftime("%H:%M:%S")
112
+ except (ValueError, OSError, OverflowError):
113
+ return str(value)
114
+ s = str(value)
115
+ iso = s[:-1] + "+00:00" if s.endswith("Z") else s
116
+ try:
117
+ return datetime.fromisoformat(iso).strftime("%H:%M:%S")
118
+ except ValueError:
119
+ return s
120
+
121
+
122
+ def _format_value(value: object) -> str:
123
+ if isinstance(value, (dict, list)):
124
+ return json.dumps(value, separators=(",", ":"), default=str)
125
+ return str(value)
docker_orb/models.py ADDED
@@ -0,0 +1,132 @@
1
+ """
2
+ Core domain models for docker-orb.
3
+ All data passed between layers is typed via these dataclasses.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from enum import Enum
10
+
11
+
12
+ class LogMode(str, Enum):
13
+ STREAM = "stream"
14
+ DUMP = "dump"
15
+
16
+
17
+ # ─── Docker Compose objects ────────────────────────────────────────────────────
18
+
19
+ @dataclass
20
+ class Service:
21
+ name: str
22
+ project: str
23
+ container_count: int # number of containers currently backing this service
24
+
25
+
26
+ @dataclass
27
+ class Container:
28
+ name: str
29
+ project: str
30
+ service: str # owning service name
31
+ phase: str # Running, Pending, Failed, etc.
32
+ restart_count: int
33
+ ready: bool
34
+
35
+
36
+ @dataclass
37
+ class ContainerStatus:
38
+ """Snapshot of a container's health at a point in time."""
39
+ name: str
40
+ service: str
41
+ phase: str
42
+ restart_count: int
43
+ ready: bool
44
+ age_seconds: float
45
+
46
+ @property
47
+ def is_healthy(self) -> bool:
48
+ return self.phase == "Running" and self.ready
49
+
50
+
51
+ # ─── Log lines ────────────────────────────────────────────────────────────────
52
+
53
+ @dataclass
54
+ class LogLine:
55
+ container_name: str
56
+ content: str # raw log text
57
+ received_at: datetime = field(default_factory=datetime.now)
58
+ # The real per-line timestamp Docker attaches (from `docker logs
59
+ # --timestamps`), parsed and stripped back out of `content`. None
60
+ # when unavailable (dump mode doesn't request it) or unparseable.
61
+ # received_at is "when docker-orb read this"; log_timestamp is "when the
62
+ # container actually emitted it" — used to interleave a backfill burst
63
+ # (see ViewerApp._handle_backfill_line) since arrival order across
64
+ # concurrent per-container streams doesn't reflect true chronological order.
65
+ log_timestamp: datetime | None = None
66
+
67
+ @property
68
+ def display(self) -> str:
69
+ return f"[{self.container_name}] {self.content}"
70
+
71
+
72
+ # ─── String matching ─────────────────────────────────────────────────────────
73
+
74
+ @dataclass
75
+ class SavedStrings:
76
+ """
77
+ Global saved string lists, persisted to ~/.config/docker-orb/strings.yaml.
78
+ Strings prefixed and suffixed with '/' are treated as regex patterns.
79
+ """
80
+ filters: list[str] = field(default_factory=list)
81
+ highlights: list[str] = field(default_factory=list)
82
+ monitors: list[str] = field(default_factory=list)
83
+
84
+
85
+ # ─── Session config ───────────────────────────────────────────────────────────
86
+
87
+ @dataclass
88
+ class HealthConfig:
89
+ enabled: bool = False
90
+ interval_minutes: int = 5
91
+ restart_threshold: int = 1 # CLI-only tuning knob, default 1
92
+
93
+
94
+ @dataclass
95
+ class SessionConfig:
96
+ """
97
+ Complete configuration for one viewer session.
98
+ Produced by the wizard or assembled from CLI flags.
99
+ Persisted per-project to ~/.config/docker-orb/projects/<proj>.yaml.
100
+ """
101
+ project: str
102
+ services: list[str] # selected service names
103
+ mode: LogMode = LogMode.STREAM
104
+
105
+ tail: int | None = None # last N lines (dump mode only)
106
+ # `docker logs --since` value, e.g. "1h", "30m". Used by both modes:
107
+ # bounds how far back dump mode fetches (None = full history). In stream
108
+ # mode, a None/empty value is normalized to "1s" (see
109
+ # compose._normalize_since) so a live session only collects new lines
110
+ # instead of replaying a container's full buffered history.
111
+ since: str | None = None
112
+
113
+ # Active string sets for this session
114
+ filters: list[str] = field(default_factory=list)
115
+ highlights: list[str] = field(default_factory=list)
116
+ monitors: list[str] = field(default_factory=list) # stream only
117
+
118
+ # Case sensitivity (False = case-sensitive, True = ignore case)
119
+ filters_ignore_case: bool = False
120
+ highlights_ignore_case: bool = False
121
+ monitors_ignore_case: bool = False
122
+
123
+ health: HealthConfig = field(default_factory=HealthConfig)
124
+
125
+ # Display options
126
+ color_full_line: bool = False # True = color entire line; False = color container name prefix only
127
+ line_wrap: bool = True
128
+ json_format: bool = False # True = reformat detected JSON lines (level/message/time); False = raw
129
+ collapse_repeats: bool = False # True = collapse consecutive identical lines (journalctl-style)
130
+
131
+ # Config name — set when user chooses to save
132
+ name: str | None = None
File without changes