Repository navigation
Expand file tree
/
Copy pathdiskvet.sh
More file actions
executable file
·1768 lines (1701 loc) · 103 KB
/
Copy pathdiskvet.sh
File metadata and controls
executable file
·1768 lines (1701 loc) · 103 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/sh
# diskvet: a read-only check-up for the ClickHouse inside your
# Langfuse, SigNoz or ClickStack. Apache-2.0.
#
# What it does: runs the SELECT queries from checks.sql (system.* only) with
# readonly=2 and resource limits, then prints a Markdown report with ready fix
# commands. It never changes anything and never sends anything anywhere.
#
# Plain POSIX sh (dash, busybox ash, bash). Needs awk, sed, od, date, and either
# docker (for --docker), kubectl (for --k8s) or clickhouse-client / `clickhouse client`.
#
# sh diskvet.sh report --docker auto > report.md
# sh diskvet.sh --print-payload --docker auto # the JSON a future cloud would get
# sh diskvet.sh report --k8s auto > report.md
# sh diskvet.sh report --k8s auto -n langfuse > report.md
# sh diskvet.sh report --k8s langfuse/langfuse-clickhouse-0-0-0 > report.md
# sh diskvet.sh report --k8s signoz/chi-signoz-clickhouse-cluster-0-0-0 --context prod-eu > report.md
# sh diskvet.sh --print-payload --k8s auto -n trigger --env ./diskvet.env
# sh diskvet.sh report --replay raw.tsv # a --save-raw file from a --k8s run
# sh diskvet.sh --help
NAME=diskvet
VERSION=0.3.2
BETA_URL='https://github.com/Protemir/diskvet#early-access'
# Git Bash on Windows rewrites arguments that look like /paths before they
# reach docker.exe; this turns that off. It has no effect anywhere else.
MSYS_NO_PATHCONV=1
export MSYS_NO_PATHCONV
usage() {
cat <<EOF
$NAME $VERSION: read-only check-up for ClickHouse (system tables only)
Usage:
sh diskvet.sh report [connection] [--ttl-days N] Markdown report to stdout
sh diskvet.sh --print-payload [connection] [--env FILE] the exact JSON a snapshot would contain
sh diskvet.sh push not available yet
Connection (pick one):
--docker auto find the ClickHouse container: docker compose service
"clickhouse" in this folder, else the one running container
with image clickhouse-server, clickhouse or bitnami*/clickhouse
--docker NAME run clickhouse-client inside this container (docker exec -i)
--k8s auto find the one running ClickHouse pod (kubectl; all namespaces,
or -n NS); refuses if there are several
--k8s POD | NS/POD run clickhouse-client inside this pod (kubectl exec -i)
-n, --namespace NS namespace for --k8s
--container NAME container in the pod (default: the one with a ClickHouse image)
--context NAME kubectl context (default: the current one, kept for the whole run)
(nothing) use clickhouse-client on this machine
--host H --port P server address (inside the container or pod with --docker/--k8s)
--user U --password P ClickHouse user; with --docker/--k8s and no --user, the login in the
container's env is used (CLICKHOUSE_USER/PASSWORD, or Bitnami's
CLICKHOUSE_ADMIN_*), else clickhouse-client's own config.
--password is refused with --k8s (it would reach the audit log).
Other options:
--ttl-days N days of logs to keep in the generated TTL config (default 7)
--env FILE file with SALT=... for name hashing (default /etc/$NAME.env)
--checks FILE checks.sql to use (default: next to this script)
--save-raw FILE also save the raw query results (for bug reports; local names inside)
--replay FILE render from a file saved with --save-raw, without a server
--version, --help
Every query is in checks.sql: SELECT only, FROM system.* only (plus one probe,
SELECT getSetting('readonly')). Queries run with --readonly=2 and limits (30 s,
10000 rows, 2 threads, 500 MB). If the user's profile refuses the limits, they
run with --readonly=1 only; if the session can't be made read-only, nothing runs.
EOF
}
die() { printf '%s: %s\n' "$NAME" "$*" >&2; exit 2; }
note() { printf '%s: %s\n' "$NAME" "$*" >&2; }
# For the case patterns that check what may be typed: bash takes a range such
# as a-z in the locale's collation order, where it can match A too (macOS
# /bin/sh, bash 3.2 with en_US.UTF-8), so the characters are spelled out.
LOWER=abcdefghijklmnopqrstuvwxyz
UPPER=ABCDEFGHIJKLMNOPQRSTUVWXYZ
DIGITS=0123456789
# ---------------------------------------------------------------- arguments
cmd=report
docker_arg=""
host=""
port=""
user=""
password=""
password_set=""
k8s_arg=""
k8s_set=""
kns=""
kns_set=""
kpod=""
kctr=""
kctr_set=""
kctx=""
kctx_typed=""
env_file="/etc/$NAME.env"
ttl_days=7
checks_file=""
save_raw=""
replay=""
need_value() { [ "$2" -ge 2 ] || die "$1 needs a value (see --help)"; }
while [ $# -gt 0 ]; do
case $1 in
report) cmd=report ;;
--print-payload|print-payload) cmd=payload ;;
push|--push) cmd=push ;;
--docker) need_value "$1" $#; docker_arg=$2; shift ;;
--docker=*) docker_arg=${1#*=} ;;
--k8s) need_value "$1" $#; k8s_arg=$2; k8s_set=1; shift ;;
--k8s=*) k8s_arg=${1#*=}; k8s_set=1 ;;
-n|--namespace) need_value "$1" $#; kns=$2; kns_set=1; shift ;;
--namespace=*) kns=${1#*=}; kns_set=1 ;;
--container) need_value "$1" $#; kctr=$2; kctr_set=1; shift ;;
--container=*) kctr=${1#*=}; kctr_set=1 ;;
--context) need_value "$1" $#; kctx=$2; kctx_typed=1; shift ;;
--context=*) kctx=${1#*=}; kctx_typed=1 ;;
--host) need_value "$1" $#; host=$2; shift ;;
--host=*) host=${1#*=} ;;
--port) need_value "$1" $#; port=$2; shift ;;
--port=*) port=${1#*=} ;;
--user) need_value "$1" $#; user=$2; shift ;;
--user=*) user=${1#*=} ;;
--password) need_value "$1" $#; password=$2; password_set=1; shift ;;
--password=*) password=${1#*=}; password_set=1 ;;
--env) need_value "$1" $#; env_file=$2; shift ;;
--env=*) env_file=${1#*=} ;;
--ttl-days) need_value "$1" $#; ttl_days=$2; shift ;;
--ttl-days=*) ttl_days=${1#*=} ;;
--checks) need_value "$1" $#; checks_file=$2; shift ;;
--checks=*) checks_file=${1#*=} ;;
--save-raw) need_value "$1" $#; save_raw=$2; shift ;;
--save-raw=*) save_raw=${1#*=} ;;
--replay) need_value "$1" $#; replay=$2; shift ;;
--replay=*) replay=${1#*=} ;;
-h|--help|help) usage; exit 0 ;;
-V|--version|version) printf '%s %s\n' "$NAME" "$VERSION"; exit 0 ;;
*) die "unknown argument: $1 (see --help)" ;;
esac
shift
done
if [ "$cmd" = push ]; then
note "push is not available yet. Use 'report' for the local report or '--print-payload' to see what a snapshot would contain."
exit 2
fi
# A Kubernetes name (DNS-1123): k8s_name NAME MAXLEN [.]; with "." dots are
# allowed too (pod and PVC names). These names are printed into commands that
# people paste, so nothing else gets through.
k8s_name() {
case $1 in ''|-*|*-|.*|*.) return 1 ;; esac
if [ "${3:-}" = . ]; then
case $1 in *[!$LOWER$DIGITS.-]*) return 1 ;; esac
else
case $1 in *[!$LOWER$DIGITS-]*) return 1 ;; esac
fi
[ "${#1}" -le "$2" ]
}
# shq WORD: WORD as it must be typed into sh, in single quotes only when it
# needs them (a path with a space, say). For the commands diskvet prints.
shq() {
case $1 in
''|*[!$UPPER$LOWER$DIGITS._/:@+,=-]*) printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" ;;
*) printf '%s' "$1" ;;
esac
}
# --k8s: everything is checked here, before any kubectl call.
transport=local
[ -n "$docker_arg" ] && transport=docker
kauto=""
kto=${DISKVET_EXEC_TIMEOUT:-120}
if [ -n "$k8s_set" ]; then
[ -z "$docker_arg" ] || die "use either --docker or --k8s, not both"
[ -z "$password_set" ] || die "--password is not accepted with --k8s: kubectl puts the command line into the exec request URL, and the API server can keep it in its audit log. Leave out --password: diskvet then uses the pod's own login. Or use kubectl port-forward and --host 127.0.0.1 --port 9000 --user U --password P (README: Kubernetes)."
if [ "$k8s_arg" = auto ]; then
kauto=1
else
# a leading pod/ or pods/ (kubectl's TYPE/NAME) is dropped: pod/x, ns/pod/x
_a=$k8s_arg
case $_a in
pod/*|pods/*) _a=${_a#*/} ;;
*/pod/*) _a=${_a%%/pod/*}/${_a#*/pod/} ;;
*/pods/*) _a=${_a%%/pods/*}/${_a#*/pods/} ;;
esac
case $_a in
''|/*|*/|*/*/*) die "--k8s takes auto, POD or NAMESPACE/POD" ;;
*/*)
if [ -n "$kns_set" ] && [ "$kns" != "${_a%/*}" ]; then die "namespace given twice: -n $kns and --k8s $k8s_arg"; fi
kns=${_a%/*}; kns_set=1; kpod=${_a#*/} ;;
*) kpod=$_a ;;
esac
fi
[ -z "$kauto" ] || [ -z "$kctr_set" ] || die "--container needs a pod: --k8s NAMESPACE/POD --container NAME"
if [ -n "$kns_set" ]; then k8s_name "$kns" 63 || die "not a valid Kubernetes name: $kns"; fi
if [ -n "$kpod" ]; then k8s_name "$kpod" 253 . || die "not a valid Kubernetes name: $kpod"; fi
if [ -n "$kctr_set" ]; then k8s_name "$kctr" 63 || die "not a valid Kubernetes name: $kctr"; fi
if [ -n "$kctx_typed" ]; then
case $kctx in
''|*[!$UPPER$LOWER$DIGITS._:/@-]*) die "--context: diskvet prints this name into the fix commands, so it accepts only letters, digits and . _ : / @ -" ;;
esac
fi
if [ -n "$replay" ] && { [ -n "$kauto" ] || [ -z "$kns" ]; }; then
die "with --replay, name the pod: --k8s NAMESPACE/POD"
fi
transport=k8s
elif [ -n "$kns_set$kctr_set$kctx_typed" ]; then
die "-n/--namespace, --container and --context only work with --k8s"
fi
# the time limit of every docker call and kubectl exec (bounded)
if [ "$transport" != local ]; then
case $kto in
''|*[!$DIGITS]*) die "DISKVET_EXEC_TIMEOUT must be a whole number of seconds (5 or more)" ;;
esac
[ "$kto" -ge 5 ] 2>/dev/null || die "DISKVET_EXEC_TIMEOUT must be a whole number of seconds (5 or more)"
fi
case $ttl_days in
''|*[!$DIGITS]*) die "--ttl-days must be a whole number of days" ;;
esac
[ "$ttl_days" -ge 1 ] || die "--ttl-days must be at least 1"
# --k8s auto with several pods prints one command per pod (k8s_find): it repeats
# the typed options that change the run, so each command runs what was asked for.
kcarry=""
if [ -n "$kauto" ]; then
if [ -n "$host" ]; then kcarry="$kcarry --host $(shq "$host")"; fi
if [ -n "$port" ]; then kcarry="$kcarry --port $(shq "$port")"; fi
if [ -n "$user" ]; then kcarry="$kcarry --user $(shq "$user")"; fi
if [ "$ttl_days" != 7 ]; then kcarry="$kcarry --ttl-days $ttl_days"; fi
if [ "$env_file" != "/etc/$NAME.env" ]; then kcarry="$kcarry --env $(shq "$env_file")"; fi
if [ -n "$checks_file" ]; then kcarry="$kcarry --checks $(shq "$checks_file")"; fi
fi
if [ -z "$checks_file" ]; then
case $0 in
*/*) checks_file=${0%/*}/checks.sql ;;
*) checks_file=./checks.sql ;;
esac
fi
# ---------------------------------------------------------------- temp dir
tmp=$(mktemp -d 2>/dev/null) || tmp=""
if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
tmp=${TMPDIR:-/tmp}/$NAME.$$
(umask 077 && mkdir "$tmp") || die "cannot create a temporary directory"
fi
trap 'rm -rf "$tmp"' EXIT
# Ctrl-C or TERM: background jobs of a non-interactive sh ignore SIGINT, so the
# kubectl or docker run by bounded() and its watchdog are killed here.
_bp=""; _bw=""; timed_out=""
trap '[ -n "$_bp" ] && kill "$_bp" 2>/dev/null; [ -n "$_bw" ] && kill "$_bw" 2>/dev/null; exit 130' INT TERM
# ---------------------------------------------------------------- helpers
# First useful line of a clickhouse-client / docker error, without noise.
err_line() {
_l=$(sed -n '/Code: [0-9]/{s/.*\(Code: [0-9]\)/\1/;p;q;}' "$1" 2>/dev/null)
[ -n "$_l" ] || _l=$(sed -n '/[^[:space:]]/{p;q;}' "$1" 2>/dev/null)
printf '%s' "$_l" \
| sed 's/DB::Exception: Received from [^ ]* //; s/DB::Exception: //g' \
| tr '\t\r' ' ' | cut -c1-240
}
now_iso=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
now_human=$(date -u '+%Y-%m-%d %H:%M UTC')
unreachable_json() {
printf '{"schema": 1, "agent": "%s/%s", "sent_at": "%s", "status": "ch_unreachable"}\n' \
"$NAME" "$VERSION" "$now_iso"
}
# Inside the container or pod, when no user was given: the image's
# CLICKHOUSE_USER / CLICKHOUSE_PASSWORD, else Bitnami's CLICKHOUSE_ADMIN_USER
# with CLICKHOUSE_ADMIN_PASSWORD or CLICKHOUSE_ADMIN_PASSWORD_FILE, else nothing
# (clickhouse-client's own config, as the ClickHouse operator sets it up).
# The password stays inside the container; never --user default --password ''.
# Single quotes on purpose: this runs inside the container, not here.
# shellcheck disable=SC2016
inner='m=$1; shift
if [ "$m" = env ]; then
if [ -n "${CLICKHOUSE_USER:-}" ] || [ -n "${CLICKHOUSE_PASSWORD:-}" ]; then
if [ -n "${CLICKHOUSE_PASSWORD:-}" ]; then set -- --password "$CLICKHOUSE_PASSWORD" "$@"; fi
if [ -n "${CLICKHOUSE_USER:-}" ]; then set -- --user "$CLICKHOUSE_USER" "$@"; fi
elif [ -n "${CLICKHOUSE_ADMIN_USER:-}" ]; then
p=${CLICKHOUSE_ADMIN_PASSWORD:-}
if [ -z "$p" ] && [ -r "${CLICKHOUSE_ADMIN_PASSWORD_FILE:-}" ]; then p=$(cat "$CLICKHOUSE_ADMIN_PASSWORD_FILE"); fi
if [ -n "$p" ]; then set -- --password "$p" "$@"; fi
set -- --user "$CLICKHOUSE_ADMIN_USER" "$@"
fi
fi
if command -v clickhouse-client >/dev/null 2>&1; then exec clickhouse-client "$@"; fi
exec clickhouse client "$@"'
safe_flags="--readonly=2 --max_execution_time=30 --max_result_rows=10000 --result_overflow_mode=break --max_threads=2 --max_memory_usage=500000000 --log_comment=$NAME"
run_mode="Queries ran with readonly=2 and resource limits."
container=""
client=""
# bounded WHAT IN OUT ERR CMD...: CMD <IN >OUT 2>ERR, killed after $kto s (then
# status 124, timed_out=WHAT and "WHAT timed out after N s" in ERR). The explicit
# <IN beats the /dev/null stdin POSIX gives background jobs. The watchdog's stdio
# is /dev/null, so "| tee" and "| cat" pipelines close on time, and its sleep is
# killed through the TERM trap, so no sleep is left behind. Once the time is up
# it ignores TERM, so the timed-out mark is always written. Only that mark means
# a timeout: kubectl exec and docker exec pass on the status of the command in
# the pod or container, and clickhouse-client exits with its error code mod 256
# (Code 380 is status 124 too).
bounded() {
_bwhat=$1 _bi=$2 _bo=$3 _be=$4; shift 4
rm -f "$tmp/timedout"; timed_out=""
"$@" <"$_bi" >"$_bo" 2>"$_be" &
_bp=$!
(
trap 'kill "$_bs" 2>/dev/null; exit 0' TERM
sleep "$kto" & _bs=$!
if wait "$_bs"; then trap '' TERM; kill "$_bp" 2>/dev/null && : >"$tmp/timedout"; fi
) </dev/null >/dev/null 2>&1 &
_bw=$!
wait "$_bp"; _brc=$?
kill "$_bw" 2>/dev/null; wait "$_bw" 2>/dev/null
_bp=""; _bw=""
if [ -e "$tmp/timedout" ]; then
rm -f "$tmp/timedout"; timed_out=$_bwhat
printf '%s timed out after %s s\n' "$_bwhat" "$kto" >>"$_be"
return 124
fi
return "$_brc"
}
# kubectl: every call goes through kc (get, config) or kx (exec), with the
# context pinned once at the start, so a "kubectl config use-context" in another
# terminal can't move a running check to another cluster.
kc() { if [ -n "$kctx" ]; then set -- --context "$kctx" "$@"; fi; kubectl "$@"; }
kx() { # kx IN OUT ERR CMD...: CMD inside the pod, never with a TTY
_ki=$1 _ko=$2 _ke=$3; shift 3
set -- exec -i -n "$kns" "$kpod" -c "$kctr" -- "$@"
if [ -n "$kctx" ]; then set -- --context "$kctx" "$@"; fi
bounded "kubectl exec" "$_ki" "$_ko" "$_ke" kubectl "$@"
}
# docker: every call goes through dk (compose, ps, inspect) or dx (exec), with
# the same time limit as kubectl exec, so a hung daemon or container can't hang
# the run. Once find_container picked Compose v1, "dk ... compose" runs docker-compose.
compose=""
dk() { # dk OUT ERR ARGS...: docker ARGS </dev/null >OUT 2>ERR
_do=$1 _de=$2; shift 2
if [ "$1" = compose ] && [ "$compose" = v1 ]; then shift; set -- docker-compose "$@"; else set -- docker "$@"; fi
bounded "$1 $2" /dev/null "$_do" "$_de" "$@"
}
dx() { # dx IN OUT ERR ARGS...: docker exec -i ARGS, never with a TTY
_xi=$1 _xo=$2 _xe=$3; shift 3
bounded "docker exec" "$_xi" "$_xo" "$_xe" docker exec -i "$@"
}
die_unreachable() { [ "$cmd" = payload ] && unreachable_json; die "$@"; }
# What to try when the probe fails inside a pod or container (empty for local).
exec_hint() {
if [ "$transport" = docker ]; then
if grep -q 'timed out after' "$1"; then
printf ' (Docker or the container did not answer. Set DISKVET_EXEC_TIMEOUT to wait longer.)'
fi
return 0
fi
[ "$transport" = k8s ] || return 0
if grep -qi 'pods/exec' "$1" && grep -qi 'forbidden' "$1"; then
printf ' (kubectl exec needs the create verb on pods/exec in namespace %s: README, Kubernetes, Permissions. Without it, use kubectl port-forward and --host 127.0.0.1 with a read-only user.)' "$kns"
elif grep -q 'executable file not found' "$1"; then
printf ' (the container has no sh, for example a distroless image: use kubectl port-forward and --host 127.0.0.1 instead)'
elif grep -Eq 'clickhouse(-client)?: not found' "$1"; then
printf ' (container %s has no clickhouse-client; pass --container NAME for the ClickHouse container)' "$kctr"
elif grep -q 'Code: 516' "$1"; then
printf " (diskvet used the pod's own login: its clickhouse-client config, CLICKHOUSE_USER or CLICKHOUSE_ADMIN_USER. ClickHouse refused it. Pass --user for a user that needs no password from inside the pod, or use kubectl port-forward with --user and --password.)"
elif grep -q 'timed out after' "$1"; then
printf ' (the API server or the pod did not answer. Set DISKVET_EXEC_TIMEOUT to wait longer; if your API server is older than your kubectl, try KUBECTL_REMOTE_COMMAND_WEBSOCKETS=false.)'
fi
}
# run_query SQL_FILE OUT_FILE ERR_FILE [extra clickhouse-client args...]
run_query() {
_in=$1; _out=$2; _err=$3; shift 3
if [ -n "$password" ]; then set -- --password "$password" "$@"; fi
if [ -n "$user" ]; then set -- --user "$user" "$@"; fi
if [ -n "$port" ]; then set -- --port "$port" "$@"; fi
if [ -n "$host" ]; then set -- --host "$host" "$@"; fi
# $safe_flags is a list of --name=value words without spaces: split on purpose.
# shellcheck disable=SC2086
set -- $safe_flags --format=TSV "$@"
if [ -n "$user" ] || [ -n "$password" ]; then _m='explicit'; else _m='env'; fi
if [ "$transport" = k8s ]; then
kx "$_in" "$_out" "$_err" sh -c "$inner" sh "$_m" "$@"
elif [ -n "$container" ]; then
dx "$_in" "$_out" "$_err" "$container" sh -c "$inner" sh "$_m" "$@"
else
# $client may be "clickhouse client": split on purpose.
# shellcheck disable=SC2086
$client "$@" <"$_in" >"$_out" 2>"$_err"
fi
}
# --docker auto: the compose service "clickhouse" of this folder (Compose v2, else
# v1), else the one running container with a ClickHouse image, the images --k8s
# looks for (clickhouse-server, clickhouse, bitnami*/clickhouse, any registry).
# Sets container. Never prompts: several containers or none is an error, and a
# container found by its image gets one note on stderr.
find_container() {
_id=""
if dk "$tmp/cv" "$tmp/cv.err" compose version; then
compose=v2
elif command -v docker-compose >/dev/null 2>&1; then
compose=v1
dk "$tmp/cv" "$tmp/cv.err" compose version || compose=""
fi
if [ -n "$compose" ]; then
if dk "$tmp/cps" "$tmp/cps.err" compose ps -q clickhouse; then
_id=$(tr -d '\r' <"$tmp/cps" | sed -n '1p')
elif [ -n "$timed_out" ]; then
die_unreachable "cannot list the compose services here: $(err_line "$tmp/cps.err")"
fi
fi
if [ -n "$_id" ]; then container=$_id; return 0; fi
dk "$tmp/ps" "$tmp/ps.err" ps --format '{{.ID}} {{.Image}} {{.Names}}' \
|| die_unreachable "cannot list containers: $(err_line "$tmp/ps.err")"
tr -d '\r' <"$tmp/ps" | awk '$2 ~ /(^|\/)clickhouse(-server)?([:@]|$)/ { print $1, $3, $2 }' >"$tmp/found"
_n=$(grep -c . "$tmp/found")
if [ "$_n" -eq 0 ]; then
die_unreachable "no running ClickHouse container found (looked for compose service 'clickhouse' here and for images clickhouse-server, clickhouse or bitnami*/clickhouse). Pass --docker <container name>."
fi
if [ "$_n" -gt 1 ]; then
die_unreachable "found $_n ClickHouse containers: $(awk '{ printf "%s%s", (NR > 1) ? ", " : "", $2 }' "$tmp/found"). Pass --docker <container name> with one of them."
fi
read -r container _name _image <"$tmp/found"
note "no compose service 'clickhouse' is running in this folder, so diskvet picked container $_name by its image ($_image). Pass --docker NAME to check another one."
}
# --k8s: one list call gives all diskvet needs to pick the pod, by names, images,
# volumes and labels only (never env values, never Secrets). <none> is an empty field.
K8S_COLS='custom-columns=NS:.metadata.namespace,POD:.metadata.name,PHASE:.status.phase,CTRS:.spec.containers[*].name,IMAGES:.spec.containers[*].image,PVCS:.spec.volumes[*].persistentVolumeClaim.claimName,ROLE:.metadata.labels.clickhouse\.com/role,CHC:.metadata.labels.clickhouse\.com/cluster,CHI:.metadata.labels.clickhouse\.altinity\.com/chi,CHART:.metadata.labels.helm\.sh/chart,OWNER:.metadata.ownerReferences[*].kind'
# awk: a Kubernetes name, as k8s_name checks it (lbl: namespace, container,
# group; dom: pod, PVC).
K8S_AWK='function lbl(s) { return length(s) <= 63 && s ~ /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/ }
function dom(s) { return length(s) <= 253 && s ~ /^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/ }
'
tab=$(printf '\t')
# k8s_pick EXPLICIT: reads $tmp/pods (the columns above), prints one line per usable pod:
# POD NS POD CTR FLAVOR PVCS GROUP PHASE (tab-separated; PVCS comma-separated)
# For a named pod (EXPLICIT=1) an unusable one gives NOCTR NS POD "CTR CTR ..."
# instead, and a Keeper or version-probe pod ROLE NS POD ROLE. Unexpected columns: ERR.
k8s_pick() {
tr -d '\r' <"$tmp/pods" | awk -v explicit="$1" -v want="$kctr" "$K8S_AWK"'
function none(s) { return (s == "<none>") ? "" : s }
NF == 0 { next }
NF != 11 { print "ERR"; exit }
{
nc = split(none($4), cn, ","); ni = split(none($5), im, ",")
if (nc != ni) { print "ERR"; exit }
if (!lbl($1) || !dom($2)) next
role = none($7)
# Keeper and version-probe pods of the ClickHouse operator
if (role != "" && role != "clickhouse-server") { if (explicit) print "ROLE\t" $1 "\t" $2 "\t" role; next }
# backup and probe jobs run the server image too
if (!explicit && index(none($11), "Job")) next
pick = 0; hits = 0
for (i = 1; i <= nc; i++) {
if (want != "") { if (cn[i] == want) { pick = i; hits++ } }
else if (im[i] ~ /(^|\/)clickhouse(-server)?([:@]|$)/) { pick = i; hits++ }
}
if (explicit && want == "" && hits == 0 && nc == 1) { pick = 1; hits = 1 }
if (hits != 1 || !lbl(cn[pick])) {
if (explicit) {
s = ""
for (i = 1; i <= nc; i++) s = (s == "") ? cn[i] : s " " cn[i]
print "NOCTR\t" $1 "\t" $2 "\t" s
}
next
}
grp = ""
if (role == "clickhouse-server") { fl = "official"; grp = none($8) }
else if (none($9) != "") { fl = "altinity"; grp = none($9) }
else if (im[pick] ~ "(^|/)bitnami[^/]*/clickhouse([:@]|$)") {
chart = none($10)
if (chart ~ /^clickhouse-9\./) fl = "bitnami9"
else if (chart ~ /^clickhouse-[5-8]\./) fl = "bitnami8"
else fl = "bitnami"
} else fl = "plain"
if (!lbl(grp)) grp = ""
pv = ""; np = split(none($6), pvs, ",")
for (i = 1; i <= np; i++) if (dom(pvs[i])) pv = (pv == "") ? pvs[i] : pv "," pvs[i]
print "POD\t" $1 "\t" $2 "\t" cn[pick] "\t" fl "\t" pv "\t" grp "\t" $3
}'
}
# Find the pod and container for --k8s (auto or named) and pin the context.
# Sets kns, kpod, kctr, kflavor, kpvc and kgroup. Never prompts: one note on stderr.
k8s_find() {
command -v kubectl >/dev/null 2>&1 || die_unreachable "kubectl is not installed or not in PATH"
# Git Bash: MSYS_NO_PATHCONV (top of this file) also stops the conversion of
# KUBECONFIG=/c/Users/... (or a : list) that kubectl.exe needs, and kubectl
# would then run with an empty config. cygpath does it; no cygpath, no change.
case ${KUBECONFIG:-} in
/*) if command -v cygpath >/dev/null 2>&1; then
_kcfg=$(cygpath -w -p "$KUBECONFIG" 2>/dev/null | tr -d '\r')
if [ -n "$_kcfg" ]; then KUBECONFIG=$_kcfg; export KUBECONFIG; fi
fi ;;
esac
# the context is resolved once and passed on every call
[ -n "$kctx_typed" ] || kctx=$(kc config current-context 2>/dev/null | tr -d '\r')
if [ -n "$kauto" ]; then
_where="in any namespace you can list"
if [ -n "$kns" ]; then set -- -n "$kns"; _where="in namespace $kns"; else set -- -A; fi
if ! kc get pods "$@" --field-selector=status.phase=Running --request-timeout=30s --no-headers -o "$K8S_COLS" >"$tmp/pods" 2>"$tmp/pods.err"; then
if [ -z "$kns" ] && grep -q 'Forbidden' "$tmp/pods.err"; then
note "not allowed to list pods in all namespaces; looking in your current namespace only (pass -n NAMESPACE to choose)"
_where="in your current namespace"
kc get pods --field-selector=status.phase=Running --request-timeout=30s --no-headers -o "$K8S_COLS" >"$tmp/pods" 2>"$tmp/pods.err" \
|| die_unreachable "cannot list pods: $(err_line "$tmp/pods.err")"
else
die_unreachable "cannot list pods: $(err_line "$tmp/pods.err")"
fi
fi
k8s_pick 0 >"$tmp/picked"
else
set -- get pod "$kpod"
if [ -n "$kns" ]; then set -- "$@" -n "$kns"; fi
kc "$@" --request-timeout=30s --no-headers -o "$K8S_COLS" >"$tmp/pods" 2>"$tmp/pods.err" \
|| die_unreachable "cannot find pod ${kns:+$kns/}$kpod: $(err_line "$tmp/pods.err")"
k8s_pick 1 >"$tmp/picked"
fi
if grep -q '^ERR' "$tmp/picked"; then
die_unreachable "cannot read kubectl's output (unexpected columns); please report this with kubectl version"
fi
_n=$(grep -c "^POD$tab" "$tmp/picked")
if [ "$_n" -gt 1 ]; then
if [ "$cmd" = payload ]; then unreachable_json; _run=--print-payload; _to=payload; _ext=json
else _run=report; _to=report; _ext=md; fi
note "found $_n ClickHouse pods. Each has its own disk and system logs, so diskvet checks one pod per run:"
_me=$(shq "$0")
grep "^POD$tab" "$tmp/picked" | cut -f2,3 | while IFS=$tab read -r _pns _ppod; do
printf ' sh %s %s --k8s %s/%s%s%s > %s-%s.%s\n' "$_me" "$_run" "$_pns" "$_ppod" "${kctx_typed:+ --context $kctx}" "$kcarry" "$_to" "$_ppod" "$_ext"
done >&2
exit 2
fi
if [ "$_n" -eq 0 ]; then
[ -z "$kauto" ] || die_unreachable "no running ClickHouse pod found $_where (looked for a container with image clickhouse-server, clickhouse or bitnami*/clickhouse; keeper, operator, backup and job pods are skipped). Pass --k8s NAMESPACE/POD, and --container NAME for a custom image."
IFS='|' read -r _k _pns _ppod _x <<EOF
$(sed -n '1p' "$tmp/picked" | tr '\t' '|')
EOF
case $_k in
ROLE) die_unreachable "pod $_pns/$_ppod is not a ClickHouse server: its clickhouse.com/role is $_x (a Keeper or version-probe pod of the ClickHouse operator)" ;;
NOCTR)
[ -z "$kctr" ] || die_unreachable "pod $_pns/$_ppod has no container '$kctr' (it has: $_x)"
die_unreachable "pod $_pns/$_ppod has containers $_x, and not exactly one with a ClickHouse image; pass --container NAME" ;;
esac
die_unreachable "cannot read kubectl's output (unexpected columns); please report this with kubectl version"
fi
IFS='|' read -r _k kns kpod kctr kflavor kpvc kgroup _phase <<EOF
$(grep "^POD$tab" "$tmp/picked" | tr '\t' '|')
EOF
[ "$_phase" = Running ] || die_unreachable "pod $kns/$kpod is $_phase, not Running"
note "kubectl context ${kctx:-(none)} · pod $kns/$kpod · container $kctr"
}
# Salted hashes of your own database and table names, for --print-payload.
# The rows of the "tables" query go through `clickhouse local` (inside the
# container with --docker, inside the pod with --k8s, else on this machine):
# the salt travels only on stdin, so it never reaches the server's query_log,
# text_log or log files, never shows up in the process list and never in the
# kubectl exec URL. Columns 4 and 5 of each row come in
# as "keep this name" flags and go out as the name or its hash:
# db_ / t_ + 16 hex of sipHash64(salt, db) / sipHash64(salt, db, table).
hash_names() {
_st='salt String, c1 String, db String, tbl String, keep_db UInt8, keep_tbl UInt8, c6 String, c7 String, c8 String, c9 String, c10 String, c11 String'
_sql="SELECT c1, db, tbl,
if(keep_db = 1, db, concat('db_', leftPad(lower(hex(sipHash64(salt, db))), 16, '0'))),
if(keep_tbl = 1, tbl, concat('t_', leftPad(lower(hex(sipHash64(salt, db, tbl))), 16, '0'))),
c6, c7, c8, c9, c10, c11
FROM table"
tr -d '\r' <"$tmp/o.tables" | while IFS= read -r _line; do printf '%s\t%s\n' "$salt" "$_line"; done >"$tmp/h.in"
: >"$tmp/h.out"
[ -s "$tmp/h.in" ] || return 0
if [ "$transport" = k8s ]; then
kx "$tmp/h.in" "$tmp/h.out" "$tmp/h.err" sh -c 'cd /tmp && exec clickhouse local "$@"' sh \
--input-format TSV --output-format TSV --structure "$_st" --query "$_sql" || return 1
elif [ -n "$container" ]; then
dx "$tmp/h.in" "$tmp/h.out" "$tmp/h.err" -w /tmp "$container" clickhouse local --input-format TSV --output-format TSV \
--structure "$_st" --query "$_sql" || return 1
else
if command -v clickhouse-local >/dev/null 2>&1; then _local=clickhouse-local
elif command -v clickhouse >/dev/null 2>&1; then _local="clickhouse local"
else printf 'clickhouse-local not found (needed to hash names on this machine)\n' >"$tmp/h.err"; return 1
fi
# From the temp dir, so a config.xml in the current folder is not picked up.
# $_local may be "clickhouse local": split on purpose.
# shellcheck disable=SC2086
(cd "$tmp" && $_local --input-format TSV --output-format TSV \
--structure "$_st" --query "$_sql" <"$tmp/h.in" >"$tmp/h.out" 2>"$tmp/h.err") || return 1
fi
# every row must come back, with the names replaced
[ "$(grep -c . "$tmp/h.in")" = "$(tr -d '\r' <"$tmp/h.out" | grep -c .)" ] || {
printf 'clickhouse local returned a different number of rows\n' >>"$tmp/h.err"; return 1; }
}
# ---------------------------------------------------------------- run the checks
stream=$tmp/stream.tsv
container_name=""
dead=""
if [ -n "$replay" ]; then
[ -r "$replay" ] || die "cannot read $replay"
if tr -d '\r' <"$replay" | grep -q "^@@${tab}_target$tab"; then
# saved from a --k8s run: it names its pod, and the report prints those
# names into commands, so they must be what a live run writes. The check
# reads the stream the report is rendered from, without any CR (a live
# run writes none), so a CR inside a name can't reach a printed command.
[ -z "$docker_arg" ] || die "$replay was saved from a --k8s run; leave out --docker"
[ "$transport" != k8s ] || die "$replay was saved from a --k8s run and already names its pod; leave out --k8s"
tr -d '\r' <"$replay" >"$stream" || die "cannot read $replay"
awk -F '\t' "$K8S_AWK"'
function pvcs(s, a, k, i) {
if (s == "" || s == "?") return 1
k = split(s, a, ",")
for (i = 1; i <= k; i++) if (!dom(a[i])) return 0
return 1
}
$1 == "_target" { nt++; good = (NF == 9 && $2 == "k8s" && lbl($3) && dom($4) && lbl($5) && $6 ~ /^[a-z0-9]+$/ && pvcs($7) && ($8 == "" || lbl($8)) && $9 ~ "^[A-Za-z0-9._:/@-]*$") }
END { exit !(nt == 1 && good) }' "$stream" || die "cannot read the pod from $replay: its _target line is not what diskvet writes"
elif [ "$transport" = k8s ]; then
# render as a report about this pod (flavor and volumes unknown)
{ printf '@@\t_target\tok\n_target\tk8s\t%s\t%s\t%s\tplain\t?\t\t%s\n' "$kns" "$kpod" "${kctr:-clickhouse}" "${kctx_typed:+$kctx}"
cat "$replay"; } >"$stream" || die "cannot read $replay"
else
cp "$replay" "$stream" || die "cannot read $replay"
fi
container_name=$docker_arg
[ "$container_name" = auto ] && container_name=clickhouse
run_mode="Rendered from saved query results (--replay)."
else
[ -r "$checks_file" ] || die "cannot read $checks_file (keep checks.sql next to the script or pass --checks FILE)"
if [ "$transport" = k8s ]; then
k8s_find
elif [ -n "$docker_arg" ]; then
command -v docker >/dev/null 2>&1 || die_unreachable "docker is not installed or not in PATH"
if [ "$docker_arg" = auto ]; then find_container; else container=$docker_arg; fi
# --type container: an image, volume or network of that name is not it
if dk "$tmp/name" "$tmp/name.err" inspect --type container --format '{{.Name}}' "$container"; then
# Docker prints /name, Podman's docker prints name
container_name=$(tr -d '\r' <"$tmp/name" | sed -e '1!d' -e 's|^/||')
elif [ -n "$timed_out" ] || ! grep -qi 'no such' "$tmp/name.err"; then
die_unreachable "cannot inspect container '$container': $(err_line "$tmp/name.err")"
fi
[ -n "$container_name" ] || die_unreachable "container '$container' not found (docker inspect failed)"
else
if command -v clickhouse-client >/dev/null 2>&1; then
client=clickhouse-client
elif command -v clickhouse >/dev/null 2>&1; then
client="clickhouse client"
else
die "clickhouse-client not found. Use --docker auto, or install clickhouse-client."
fi
fi
# Split checks.sql into one file per query; the index keeps the order.
# Ids starting with _ are reserved for diskvet's own sections (_target).
awk -v dir="$tmp" '
{ sub(/\r$/, "") }
/^-- @query / {
if (f != "") close(f)
if ($3 !~ /^[a-z0-9][a-z0-9_]*$/) { bad = 1; exit 1 }
f = dir "/q." $3
print $3, $4 > (dir "/index")
next
}
f != "" { print > f }
END { if (bad) exit 1 }
' "$checks_file" || die "cannot parse $checks_file"
[ -s "$tmp/index" ] || die "no queries found in $checks_file"
# Probe (the only query that is not in checks.sql): can we connect, and is
# the session really read-only? Try readonly=2 with the limits; if the
# user's profile refuses extra settings (readonly=1 profile, or constraints
# on the limits), readonly=1 alone; else no flags, but only if the profile
# itself is read-only.
printf "SELECT getSetting('readonly')\n" >"$tmp/probe.sql"
probe() {
ro=""
run_query "$tmp/probe.sql" "$tmp/probe.out" "$tmp/probe.err" || return 1
ro=$(tr -d '\r\n\t ' <"$tmp/probe.out")
}
refused() { grep -Eq 'Code: (164|452)|READONLY|SETTING_CONSTRAINT_VIOLATION' "$tmp/probe.err"; }
cannot_run() {
[ "$cmd" = payload ] && unreachable_json
note "cannot run queries: $*"
exit 3
}
if probe; then
[ "$ro" = 2 ] || cannot_run "readonly=2 did not take effect (the server reports readonly=$ro), so nothing was run"
elif refused; then
safe_flags="--readonly=1"
if probe; then
[ "$ro" = 1 ] || cannot_run "readonly=1 did not take effect (the server reports readonly=$ro), so nothing was run"
run_mode="Queries ran with readonly=1. The server refused the limit flags for this user (read-only profile or setting constraints), so the user's own limits apply."
note "the server refused the limit flags for this user: running with --readonly=1 only, the user's own limits apply"
elif refused; then
safe_flags=""
if probe; then
case $ro in
1|2) run_mode="Queries ran under the user's read-only profile (readonly=$ro). The server refused extra settings, so the profile's own limits apply."
note "the server refused all extra settings for this user: running under its read-only profile (readonly=$ro)" ;;
*) cannot_run "the server refuses readonly=2 and readonly=1 for this user, and the user is not read-only, so nothing was run. Use a read-only user (README, variant B)." ;;
esac
else
cannot_run "$(err_line "$tmp/probe.err")$(exec_hint "$tmp/probe.err")"
fi
else
cannot_run "$(err_line "$tmp/probe.err")$(exec_hint "$tmp/probe.err")"
fi
else
cannot_run "$(err_line "$tmp/probe.err")$(exec_hint "$tmp/probe.err")"
fi
salt=""
if [ "$cmd" = payload ]; then
if [ -r "$env_file" ]; then
salt=$(sed -n 's/^[[:space:]]*SALT[[:space:]]*=[[:space:]]*//p' "$env_file" | sed -n '1p' | tr -d "\"' \r")
fi
if [ -n "$salt" ]; then
case $salt in
*[!$DIGITS$UPPER${LOWER}_-]*) die "SALT in $env_file may contain only letters, digits, '_' and '-'" ;;
esac
[ "${#salt}" -ge 32 ] || die "SALT in $env_file is too short (${#salt} characters, need at least 32; 64 random hex characters are best, see README)"
else
salt=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
[ -n "$salt" ] || die "cannot read /dev/urandom for a salt"
note "no SALT= in $env_file: using a one-time random salt. Hashed names (db_..., t_...) will differ on every run."
fi
fi
: >"$stream"
if [ "$transport" = k8s ]; then
# the pod the report is about: names only, the context only when typed
printf '@@\t_target\tok\n_target\tk8s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$kns" "$kpod" "$kctr" "$kflavor" "$kpvc" "$kgroup" "${kctx_typed:+$kctx}" >>"$stream"
fi
# answered ERR: the error in ERR is ClickHouse's answer to the query (a Code,
# but none of a lost connection: 32 end of stream, 209 socket timeout, 210
# network error), so the connection still works. Anything else (a docker or
# kubectl error, no error at all) is checked with the probe once more.
answered() {
case $(err_line "$1") in
'Code: 32'[!$DIGITS]*|'Code: 209'[!$DIGITS]*|'Code: 210'[!$DIGITS]*) return 1 ;;
'Code: '[$DIGITS]*) return 0 ;;
esac
return 1
}
# lost WHY ERROR: the connection is gone partway (or an exec timed out): no
# more queries, the checks left say "skipped: WHY", and the run exits with 4
lost() {
dead=$1
note "lost the connection partway: $2. The checks left are skipped, so this $cmd is incomplete (exit code 4)."
}
while read -r qid qtag; do
[ -n "$qid" ] || continue
if [ "$qtag" = payload ] && [ "$cmd" != payload ]; then continue; fi
# after that, don't wait for every other check too
if [ -n "$dead" ]; then
printf '@@\t%s\tfail\t%s\t%s\n' "$qid" "${qtag:-required}" "skipped: $dead" >>"$stream"
continue
fi
if run_query "$tmp/q.$qid" "$tmp/o.$qid" "$tmp/e.$qid"; then
if [ "$qid" = tables ]; then
# never let a real name through: without hashes the rows are dropped
if hash_names; then
printf '@@\t%s\tok\n' "$qid" >>"$stream"
tr -d '\r' <"$tmp/h.out" >>"$stream"
else
printf '@@\t%s\tfail\t%s\t%s\n' "$qid" "${qtag:-required}" "cannot hash table names: $(err_line "$tmp/h.err")" >>"$stream"
note "cannot hash table names, so the payload has no tables: $(err_line "$tmp/h.err")"
if [ -n "$timed_out" ]; then lost "an earlier $timed_out timed out" "$(err_line "$tmp/h.err")"; fi
fi
continue
fi
printf '@@\t%s\tok\n' "$qid" >>"$stream"
tr -d '\r' <"$tmp/o.$qid" >>"$stream"
else
printf '@@\t%s\tfail\t%s\t%s\n' "$qid" "${qtag:-required}" "$(err_line "$tmp/e.$qid")" >>"$stream"
# the watchdog's mark, not status 124 (see bounded)
if [ -n "$timed_out" ]; then
lost "an earlier $timed_out timed out" "$(err_line "$tmp/e.$qid")"
elif ! answered "$tmp/e.$qid" && ! run_query "$tmp/probe.sql" "$tmp/probe.out" "$tmp/probe.err"; then
if [ -n "$timed_out" ]; then lost "an earlier $timed_out timed out" "$(err_line "$tmp/probe.err")"
else lost "the connection to ClickHouse was lost" "$(err_line "$tmp/probe.err")"; fi
fi
fi
done <"$tmp/index"
fi
if [ -n "$save_raw" ]; then
cp "$stream" "$save_raw" || note "cannot write $save_raw"
fi
# ---------------------------------------------------------------- render
cat >"$tmp/render.awk" <<'__RENDER_AWK__'
# Renders the query results (one stream, "@@" lines mark each query) into the
# Markdown report or the JSON payload. POSIX awk: works in gawk, mawk, busybox.
BEGIN {
FS = "\t"
KiB = 1024; MiB = 1048576; GiB = 1073741824; TiB = 1099511627776
RANK["NOT_RUN"] = 0; RANK["OK"] = 1; RANK["INFO"] = 2; RANK["WARN"] = 3; RANK["CRITICAL"] = 4
T[1] = "System logs without TTL"
T[2] = "Disk space not in ClickHouse table parts"
T[3] = "Disk usage and rough forecast"
T[4] = "Growth per day"
T[5] = "Too many parts"
T[6] = "Inactive and detached parts"
T[7] = "Deleted rows and stuck mutations"
}
{ sub(/\r$/, "") }
/^@@\t/ {
q = $2
qstat[q] = $3
if ($3 == "fail") { qtag[q] = $4; qerr[q] = $5 }
if (!(q in seen)) { seen[q] = 1; qorder[++nq] = q }
next
}
$0 != "" { q = $1; n[q]++; row[q, n[q]] = $0 }
END {
context()
if (mode == "payload") payload(); else report()
}
# ---------------------------------------------------------------- helpers
function isnum(x) { return x ~ /^-?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?$/ }
function isint(x) { return x ~ /^[0-9]+$/ }
function num(x) { return isnum(x) ? x + 0 : 0 }
function ok(q) { return qstat[q] == "ok" }
function worse(a, b) { return (RANK[b] > RANK[a]) ? b : a }
function pct(a, b) { return (b > 0) ? 100 * a / b : 0 }
function fpct(p) { return (p >= 10 || p == 0) ? sprintf("%.0f%%", p) : sprintf("%.1f%%", p) }
function i0(x) { return sprintf("%.0f", x) }
function hs(b) {
b = b + 0
if (b >= TiB) return sprintf("%.1f TiB", b / TiB)
if (b >= GiB) return sprintf("%.1f GiB", b / GiB)
if (b >= MiB) return sprintf("%.1f MiB", b / MiB)
if (b >= KiB) return sprintf("%.1f KiB", b / KiB)
return sprintf("%.0f B", b)
}
function hn(x) {
x = x + 0
if (x >= 1e9) return sprintf("%.1fB", x / 1e9)
if (x >= 1e6) return sprintf("%.1fM", x / 1e6)
if (x >= 1e4) return sprintf("%.1fk", x / 1e3)
return sprintf("%.0f", x)
}
function hage(m) {
m = m + 0
if (m >= 2880) return sprintf("%.0f d", m / 1440)
if (m >= 120) return sprintf("%.0f h", m / 60)
return sprintf("%.0f min", m)
}
function cell(s) { gsub(/\|/, "\\|", s); return s }
function notrun(q) { return "Could not run: " qerr[q] "\n" }
# Values arrive TSV-escaped (\\ \' \t \n ...). ClickHouse reads the same escapes
# inside 'strings' and `identifiers`, so they are kept as they are; only a
# character TSV does not escape needs it: ' (never raw in TSV) and `.
function bsq(s, q, out, i, c) {
out = ""
for (i = 1; i <= length(s); i++) {
c = substr(s, i, 1)
if (c == "\\") { out = out substr(s, i, 2); i++; continue } # an escape: keep both characters
if (c == q) out = out "\\"
out = out c
}
return out
}
function sq(s) { return bsq(s, "'") }
function ident(s) {
# quote a database/table name for SQL when it is not a plain identifier
if (s ~ /^[A-Za-z_][A-Za-z0-9_]*$/) return s
return "`" bsq(s, "`") "`"
}
function fq(db, t) { return ident(db) "." ident(t) }
function shcmd(s) {
if (kt) return kp " sh -c '" s "'"
if (ctr != "") return "docker exec " ctr " sh -c '" s "'"
return "sudo sh -c '" s "'"
}
# For the YAML and XML the Kubernetes report prints inside chart values.
function spaces(k, s) { s = ""; while (k-- > 0) s = s " "; return s }
# ind(s, pfx): pfx in front of every line of s (lines end in \n); empty lines stay empty
function ind(s, pfx, out, k, i, ln) {
out = ""
k = split(s, ln, "\n")
for (i = 1; i <= k; i++) {
if (i == k && ln[i] == "") break
if (ln[i] != "") out = out pfx ln[i]
out = out "\n"
}
return out
}
# a YAML double-quoted string: only " and \ need a backslash (no newline can be in s)
function yq(s, out, i, c) {
out = ""
for (i = 1; i <= length(s); i++) {
c = substr(s, i, 1)
if (c == "\\" || c == "\"") out = out "\\"
out = out c
}
return "\"" out "\""
}
function jstr(s) { gsub(/[^A-Za-z0-9_.:\/+-]/, "_", s); return "\"" s "\"" }
function jint(x) { return isint(x) ? x : "0" }
# Data disk, drop limit, version, product: used by several checks.
function context( i, f, best) {
ver = ""; product = "other"; product_bytes = 0; syslog_bytes = 0; y9999 = 0; ran_as = ""
if (ok("passport") && n["passport"] > 0) {
split(row["passport", 1], f, "\t")
ver = f[2]; uptime = f[3]; product = f[4]; replicated = f[5]
product_bytes = num(f[6]); syslog_bytes = num(f[7]); y9999 = num(f[8]); ran_as = f[9]
}
# --k8s: the pod the report is about (the _target section). The names were
# checked when they were written; kp/kpit/kget start the printed commands.
kt = 0
if (mode != "payload" && ok("_target") && n["_target"] > 0) {
split(row["_target", 1], f, "\t")
if (f[2] == "k8s") {
kt = 1; kns = f[3]; kpod = f[4]; kctr = f[5]; kflavor = f[6]; kpvc = f[7]; kgroup = f[8]; kctx = f[9]
if (kflavor !~ /^(official|altinity|bitnami8|bitnami9|bitnami|plain)$/) kflavor = "plain"
kget = "kubectl" ((kctx != "") ? " --context " kctx : "")
kp = kget " exec -n " kns " " kpod " -c " kctr " --"
kpit = kget " exec -it -n " kns " " kpod " -c " kctr " --"
hctx = (kctx != "") ? " --kube-context " kctx : ""
}
}
split(ver, vp, ".")
vmaj = vp[1] + 0; vmin = vp[2] + 0
PRODUCT_LABEL["langfuse"] = "Langfuse"; PRODUCT_LABEL["signoz"] = "SigNoz"
PRODUCT_LABEL["clickstack"] = "ClickStack"; PRODUCT_LABEL["other"] = "other"
plabel = (product in PRODUCT_LABEL) ? PRODUCT_LABEL[product] : "other"
dlabel = (product == "other") ? "your" : plabel
drop_limit = 50000000000; drop_known = 0
if (ok("drop_limit") && n["drop_limit"] > 0) {
split(row["drop_limit", 1], f, "\t")
if (isint(f[3])) { drop_limit = f[3] + 0; drop_known = 1 }
}
dd_name = ""; dd_total = 0; dd_free = 0; dd_path = "/var/lib/clickhouse/"
if (ok("disk_now")) {
best = 0
for (i = 1; i <= n["disk_now"]; i++) {
split(row["disk_now", i], f, "\t")
if (f[2] == "default" || best == 0) {
best = i; dd_name = f[2]; dd_path = f[3]; dd_total = num(f[4]); dd_free = num(f[5])
if (f[2] == "default") break
}
}
}
if (dd_path !~ /\/$/) dd_path = dd_path "/"
# The path comes from the server (or a --replay file) and goes into printed
# sh -c '...' commands, where a quote or a $( in it would run where the
# command is pasted: anything but a plain path becomes a placeholder.
if (dd_path !~ "^/[-A-Za-z0-9._/+:@,=]*$") dd_path = "<data path>/"
flag_path = dd_path "flags/force_drop_table"
}
function limit_text( s) {
s = (drop_limit == 50000000000) ? "50 GB" : hs(drop_limit)
if (!drop_known) s = s ", the default: this user can't read system.server_settings"
return s
}
# SQL (and the one-time flag when needed) to TRUNCATE or DROP one table.
# DROP gets SYNC: system is an Atomic database, where a plain DROP leaves the
# data on disk for database_atomic_delay_before_drop_table_sec (480 s).
# SYNC goes before SETTINGS (after it: Code 62). TRUNCATE frees at once.
function drop_block(verb, fqname, bytes, s, sy) {
sy = (verb == "DROP") ? " SYNC" : ""
if (drop_limit > 0 && bytes > 0.95 * drop_limit) {
if (bytes > drop_limit) {
s = fqname " (" hs(bytes) ") is over the drop limit (max_table_size_to_drop = " limit_text() ")"
s = s ", so ClickHouse refuses a plain " verb "."
} else {
s = fqname " (" hs(bytes) ") is close to the drop limit (max_table_size_to_drop = " limit_text() ")"
s = s " and may pass it by the time you run " verb "."
}
s = s " Create the one-time flag right before it"
s = s " (the first " verb " that needs the flag uses it up; create it again before the next big table):\n"
s = s "```sh\n" shcmd("touch " flag_path " && chmod 666 " flag_path) "\n```\n"
s = s "```sql\n" verb " TABLE " fqname sy ";\n```\n"
if (vmaj == 0 || vmaj >= 24)
s = s "Or, on ClickHouse 24.1 and newer, without the flag: `" verb " TABLE " fqname sy " SETTINGS max_table_size_to_drop = 0;`\n"
return s