|
1 | | -#!/usr/bin/env bash |
2 | | -set -euo pipefail |
3 | | - |
4 | | -namespace="serge" |
5 | | -release="serge" |
6 | | -since="2h" |
7 | | -follow=0 |
8 | | -grep_pattern="" |
9 | | -expected_context="" |
10 | | -last_error=0 |
11 | | - |
12 | | -usage() { |
13 | | - cat <<'EOF' |
14 | | -Usage: deploy/scripts/logs.sh [options] |
15 | | -
|
16 | | -Options: |
17 | | - -n, --namespace NAME Kubernetes namespace (default: serge) |
18 | | - -r, --release NAME Helm release/app label (default: serge) |
19 | | - --since DURATION Log window, e.g. 30m, 2h (default: 2h) |
20 | | - -f, --follow Follow logs |
21 | | - --grep PATTERN Filter logs with grep -Ei |
22 | | - --last-error Print the last ERROR/Traceback block from recent logs |
23 | | - --context NAME Require this kubectl context before reading logs |
24 | | - -h, --help Show this help |
25 | | -EOF |
26 | | -} |
27 | | - |
28 | | -while [[ $# -gt 0 ]]; do |
29 | | - case "$1" in |
30 | | - -n|--namespace) |
31 | | - namespace="$2" |
32 | | - shift 2 |
33 | | - ;; |
34 | | - -r|--release) |
35 | | - release="$2" |
36 | | - shift 2 |
37 | | - ;; |
38 | | - --since) |
39 | | - since="$2" |
40 | | - shift 2 |
41 | | - ;; |
42 | | - -f|--follow) |
43 | | - follow=1 |
44 | | - shift |
45 | | - ;; |
46 | | - --grep) |
47 | | - grep_pattern="$2" |
48 | | - shift 2 |
49 | | - ;; |
50 | | - --last-error) |
51 | | - last_error=1 |
52 | | - shift |
53 | | - ;; |
54 | | - --context) |
55 | | - expected_context="$2" |
56 | | - shift 2 |
57 | | - ;; |
58 | | - -h|--help) |
59 | | - usage |
60 | | - exit 0 |
61 | | - ;; |
62 | | - *) |
63 | | - echo "unknown option: $1" >&2 |
64 | | - usage >&2 |
65 | | - exit 2 |
66 | | - ;; |
67 | | - esac |
68 | | -done |
69 | | - |
70 | | -if ! command -v kubectl >/dev/null 2>&1; then |
71 | | - echo "missing required command: kubectl" >&2 |
72 | | - exit 1 |
73 | | -fi |
74 | | - |
75 | | -if [[ -n "${grep_pattern}" ]] && ! command -v grep >/dev/null 2>&1; then |
76 | | - echo "missing required command: grep" >&2 |
77 | | - exit 1 |
78 | | -fi |
79 | | - |
80 | | -current_context="$(kubectl config current-context)" |
81 | | -if [[ -n "${expected_context}" && "${current_context}" != "${expected_context}" ]]; then |
82 | | - echo "refusing to read logs from context '${current_context}' (expected '${expected_context}')" >&2 |
83 | | - exit 1 |
84 | | -fi |
85 | | - |
86 | | -pod="$( |
87 | | - kubectl get pods -n "${namespace}" \ |
88 | | - -l "app=${release}" \ |
89 | | - --field-selector=status.phase=Running \ |
90 | | - --sort-by=.metadata.creationTimestamp \ |
91 | | - -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' \ |
92 | | - | tail -n 1 |
93 | | -)" |
94 | | - |
95 | | -if [[ -z "${pod}" ]]; then |
96 | | - echo "no running pod found in namespace '${namespace}' with label app=${release}" >&2 |
97 | | - exit 1 |
98 | | -fi |
99 | | - |
100 | | -pod_started="$(kubectl get pod "${pod}" -n "${namespace}" -o jsonpath='{.status.startTime}')" |
101 | | - |
102 | | -echo "Context: ${current_context}" >&2 |
103 | | -echo "Namespace: ${namespace}" >&2 |
104 | | -echo "Pod: ${pod}" >&2 |
105 | | -echo "Pod started: ${pod_started}" >&2 |
106 | | -echo "Since: ${since}" >&2 |
107 | | - |
108 | | -args=(logs -n "${namespace}" "${pod}" --since="${since}") |
109 | | -if [[ "${follow}" -eq 1 ]]; then |
110 | | - args+=(-f) |
111 | | -fi |
112 | | - |
113 | | -if [[ "${last_error}" -eq 1 ]]; then |
114 | | - if [[ "${follow}" -eq 1 ]]; then |
115 | | - echo "--last-error cannot be combined with --follow" >&2 |
116 | | - exit 2 |
117 | | - fi |
118 | | - kubectl "${args[@]}" | awk ' |
119 | | - function flush() { |
120 | | - if (in_block && block != "") { |
121 | | - last = block |
122 | | - } |
123 | | - block = "" |
124 | | - in_block = 0 |
125 | | - } |
126 | | -
|
127 | | - /^[0-9-]+ [0-9:,]+ ERROR / { |
128 | | - flush() |
129 | | - in_block = 1 |
130 | | - block = $0 "\n" |
131 | | - next |
132 | | - } |
133 | | -
|
134 | | - /^Traceback \(most recent call last\):/ { |
135 | | - if (!in_block) { |
136 | | - in_block = 1 |
137 | | - block = $0 "\n" |
138 | | - } else { |
139 | | - block = block $0 "\n" |
140 | | - } |
141 | | - next |
142 | | - } |
143 | | -
|
144 | | - in_block { |
145 | | - if ($0 ~ /^[0-9-]+ [0-9:,]+ (INFO|WARNING|ERROR|DEBUG) / || $0 ~ /^INFO:/) { |
146 | | - flush() |
147 | | - } |
148 | | - } |
149 | | -
|
150 | | - in_block { |
151 | | - block = block $0 "\n" |
152 | | - } |
153 | | -
|
154 | | - END { |
155 | | - flush() |
156 | | - if (last != "") { |
157 | | - printf "%s", last |
158 | | - } else { |
159 | | - exit 1 |
160 | | - } |
161 | | - } |
162 | | - ' || { |
163 | | - echo "no ERROR/Traceback block found for pod ${pod} in the last ${since}" >&2 |
164 | | - echo "note: this only covers logs retained for the current pod, which started at ${pod_started}" >&2 |
165 | | - exit 1 |
166 | | - } |
167 | | - exit 0 |
168 | | -fi |
169 | | - |
170 | | -if [[ -n "${grep_pattern}" ]]; then |
171 | | - kubectl "${args[@]}" | grep -Ei "${grep_pattern}" |
172 | | -else |
173 | | - kubectl "${args[@]}" |
174 | | -fi |
| 1 | +#!/usr/bin/env python3 |
| 2 | +import argparse |
| 3 | +import re |
| 4 | +import shutil |
| 5 | +import subprocess |
| 6 | +import sys |
| 7 | + |
| 8 | + |
| 9 | +class CommandError(Exception): |
| 10 | + def __init__(self, return_code): |
| 11 | + self.return_code = return_code |
| 12 | + |
| 13 | + |
| 14 | +def run_text(args): |
| 15 | + try: |
| 16 | + return subprocess.check_output(args, text=True).strip() |
| 17 | + except subprocess.CalledProcessError as exc: |
| 18 | + raise CommandError(exc.returncode) from exc |
| 19 | + |
| 20 | + |
| 21 | +def stream_logs(args, grep_pattern): |
| 22 | + proc = subprocess.Popen(args, stdout=subprocess.PIPE, text=True) |
| 23 | + assert proc.stdout is not None |
| 24 | + |
| 25 | + pattern = re.compile(grep_pattern, re.IGNORECASE) if grep_pattern else None |
| 26 | + try: |
| 27 | + for line in proc.stdout: |
| 28 | + if pattern is None or pattern.search(line): |
| 29 | + print(line, end="") |
| 30 | + finally: |
| 31 | + if proc.stdout: |
| 32 | + proc.stdout.close() |
| 33 | + |
| 34 | + return proc.wait() |
| 35 | + |
| 36 | + |
| 37 | +def latest_error_block(args, pod, pod_started, since): |
| 38 | + proc = subprocess.Popen(args, stdout=subprocess.PIPE, text=True) |
| 39 | + assert proc.stdout is not None |
| 40 | + |
| 41 | + log_line_re = re.compile(r"^[0-9-]+ [0-9:,]+ (INFO|WARNING|ERROR|DEBUG) ") |
| 42 | + error_re = re.compile(r"^[0-9-]+ [0-9:,]+ ERROR ") |
| 43 | + last = [] |
| 44 | + block = [] |
| 45 | + |
| 46 | + def flush(): |
| 47 | + nonlocal last, block |
| 48 | + if block: |
| 49 | + last = block |
| 50 | + block = [] |
| 51 | + |
| 52 | + for line in proc.stdout: |
| 53 | + if error_re.match(line): |
| 54 | + flush() |
| 55 | + block = [line] |
| 56 | + continue |
| 57 | + |
| 58 | + if line.startswith("Traceback (most recent call last):"): |
| 59 | + block.append(line) |
| 60 | + continue |
| 61 | + |
| 62 | + if block and (log_line_re.match(line) or line.startswith("INFO:")): |
| 63 | + flush() |
| 64 | + |
| 65 | + if block: |
| 66 | + block.append(line) |
| 67 | + |
| 68 | + proc.stdout.close() |
| 69 | + return_code = proc.wait() |
| 70 | + if return_code != 0: |
| 71 | + return return_code |
| 72 | + |
| 73 | + flush() |
| 74 | + if last: |
| 75 | + print("".join(last), end="") |
| 76 | + return 0 |
| 77 | + |
| 78 | + print(f"no ERROR/Traceback block found for pod {pod} in the last {since}", file=sys.stderr) |
| 79 | + print( |
| 80 | + f"note: this only covers logs retained for the current pod, which started at {pod_started}", |
| 81 | + file=sys.stderr, |
| 82 | + ) |
| 83 | + return 1 |
| 84 | + |
| 85 | + |
| 86 | +def main(): |
| 87 | + parser = argparse.ArgumentParser( |
| 88 | + prog="deploy/scripts/logs.sh", |
| 89 | + description="Find the current running Serge pod and print recent logs.", |
| 90 | + ) |
| 91 | + parser.add_argument("-n", "--namespace", default="serge", help="Kubernetes namespace") |
| 92 | + parser.add_argument("-r", "--release", default="serge", help="Helm release/app label") |
| 93 | + parser.add_argument("--since", default="2h", help="Log window, e.g. 30m, 2h") |
| 94 | + parser.add_argument("-f", "--follow", action="store_true", help="Follow logs") |
| 95 | + parser.add_argument("--grep", dest="grep_pattern", help="Filter logs with grep -Ei semantics") |
| 96 | + parser.add_argument("--last-error", action="store_true", help="Print the last ERROR/Traceback block") |
| 97 | + parser.add_argument("--context", dest="expected_context", help="Required kubectl context") |
| 98 | + args = parser.parse_args() |
| 99 | + |
| 100 | + if args.last_error and args.follow: |
| 101 | + print("--last-error cannot be combined with --follow", file=sys.stderr) |
| 102 | + return 2 |
| 103 | + |
| 104 | + if shutil.which("kubectl") is None: |
| 105 | + print("missing required command: kubectl", file=sys.stderr) |
| 106 | + return 1 |
| 107 | + |
| 108 | + try: |
| 109 | + current_context = run_text(["kubectl", "config", "current-context"]) |
| 110 | + except CommandError as exc: |
| 111 | + return exc.return_code |
| 112 | + if args.expected_context and current_context != args.expected_context: |
| 113 | + print( |
| 114 | + f"refusing to read logs from context '{current_context}' " |
| 115 | + f"(expected '{args.expected_context}')", |
| 116 | + file=sys.stderr, |
| 117 | + ) |
| 118 | + return 1 |
| 119 | + |
| 120 | + try: |
| 121 | + pod_output = run_text( |
| 122 | + [ |
| 123 | + "kubectl", |
| 124 | + "get", |
| 125 | + "pods", |
| 126 | + "-n", |
| 127 | + args.namespace, |
| 128 | + "-l", |
| 129 | + f"app={args.release}", |
| 130 | + "--field-selector=status.phase=Running", |
| 131 | + "--sort-by=.metadata.creationTimestamp", |
| 132 | + "-o", |
| 133 | + 'jsonpath={range .items[*]}{.metadata.name}{"\\n"}{end}', |
| 134 | + ] |
| 135 | + ) |
| 136 | + except CommandError as exc: |
| 137 | + return exc.return_code |
| 138 | + pods = [line for line in pod_output.splitlines() if line] |
| 139 | + if not pods: |
| 140 | + print( |
| 141 | + f"no running pod found in namespace '{args.namespace}' with label app={args.release}", |
| 142 | + file=sys.stderr, |
| 143 | + ) |
| 144 | + return 1 |
| 145 | + |
| 146 | + pod = pods[-1] |
| 147 | + try: |
| 148 | + pod_started = run_text( |
| 149 | + [ |
| 150 | + "kubectl", |
| 151 | + "get", |
| 152 | + "pod", |
| 153 | + pod, |
| 154 | + "-n", |
| 155 | + args.namespace, |
| 156 | + "-o", |
| 157 | + "jsonpath={.status.startTime}", |
| 158 | + ] |
| 159 | + ) |
| 160 | + except CommandError as exc: |
| 161 | + return exc.return_code |
| 162 | + |
| 163 | + print(f"Context: {current_context}", file=sys.stderr) |
| 164 | + print(f"Namespace: {args.namespace}", file=sys.stderr) |
| 165 | + print(f"Pod: {pod}", file=sys.stderr) |
| 166 | + print(f"Pod started: {pod_started}", file=sys.stderr) |
| 167 | + print(f"Since: {args.since}", file=sys.stderr) |
| 168 | + |
| 169 | + log_args = ["kubectl", "logs", "-n", args.namespace, pod, f"--since={args.since}"] |
| 170 | + if args.follow: |
| 171 | + log_args.append("-f") |
| 172 | + |
| 173 | + if args.last_error: |
| 174 | + return latest_error_block(log_args, pod, pod_started, args.since) |
| 175 | + |
| 176 | + return stream_logs(log_args, args.grep_pattern) |
| 177 | + |
| 178 | + |
| 179 | +if __name__ == "__main__": |
| 180 | + sys.exit(main()) |
0 commit comments