master
sh 474 lines 12.3 KB
Raw
1 #!/usr/bin/env bash
2 set -euo pipefail
3
4 # Colors for output
5 RED='\033[0;31m'
6 YELLOW='\033[1;33m'
7 GRAY='\033[0;90m'
8 NC='\033[0m' # No Color
9
10 # Execute command with visibility
11 run() {
12 local errexit_set=0
13 case $- in
14 *e*) errexit_set=1 ;;
15 esac
16
17 # Print the command being executed
18 printf >&2 '%s%s >%s ' "$GRAY" "$(pwd)" "$NC"
19 printf >&2 '%s' "$YELLOW"
20 printf >&2 "%q " "$@"
21 printf >&2 '%s\n' "$NC"
22
23 # Execute the command
24 set +e
25 "$@"
26 local exit_code=$?
27 if [ $errexit_set -eq 1 ]; then
28 set -e
29 else
30 set +e
31 fi
32 if [ $exit_code -ne 0 ]; then
33 echo -e >&2 "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
34 echo -e >&2 "${RED}[ERROR]${NC} Command failed with exit code ${exit_code}: ${YELLOW}$1${NC}"
35 echo -e >&2 "${RED} Full command:${NC} $*"
36 echo -e >&2 "${RED} Working dir:${NC} $(pwd)"
37 echo -e >&2 "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
38 return $exit_code
39 fi
40 }
41
42 # Execute command in background with visibility
43 LAST_BG_PID=""
44 run_bg() {
45 printf >&2 '%s%s >%s ' "$GRAY" "$(pwd)" "$NC"
46 printf >&2 '%s' "$YELLOW"
47 printf >&2 "%q " "$@"
48 printf >&2 '%s\n' "$NC"
49 "$@" &
50 LAST_BG_PID=$!
51 }
52
53 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
54 FUNCTIONS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
55 REPO_ROOT="$(cd "$FUNCTIONS_DIR/../../../.." && pwd)"
56
57 WORKDIR=""
58 PROJECT=""
59 COMPOSE=()
60 COMPOSE_STARTED=""
61 USED_PORTS=""
62
63 cleanup() {
64 local exit_code=$?
65 set +e
66 if [ -n "${COMPOSE_STARTED:-}" ]; then
67 run "${COMPOSE[@]}" down -v --remove-orphans
68 fi
69 if [ "$exit_code" -eq 0 ]; then
70 run rm -rf "$WORKDIR"
71 else
72 echo "E2E failed. Keeping workspace: $WORKDIR" >&2
73 fi
74 exit $exit_code
75 }
76
77 init_workspace() {
78 local db="$1"
79 WORKDIR="$(mktemp -d "/tmp/netdata-functions-e2e-${db}.XXXXXX")"
80 local project_suffix
81 project_suffix="$(basename "$WORKDIR")"
82 project_suffix="$(printf '%s' "$project_suffix" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-')"
83 project_suffix="${project_suffix%-}"
84 PROJECT="netdata-func-e2e-${db}-${project_suffix}"
85 COMPOSE=(docker compose -f "$WORKDIR/docker-compose.yml" -p "$PROJECT")
86
87 run cp -a "$FUNCTIONS_DIR/docker-compose.yml" "$FUNCTIONS_DIR/seed" "$FUNCTIONS_DIR/config" "$WORKDIR/"
88 : > "$WORKDIR/.env"
89 }
90
91 compose_up() {
92 run "${COMPOSE[@]}" up -d "$@"
93 COMPOSE_STARTED="yes"
94 }
95
96 compose_run() {
97 run "${COMPOSE[@]}" run --rm "$@"
98 }
99
100 wait_healthy() {
101 local service="$1"
102 local timeout="${2:-60}"
103 local start=$SECONDS
104
105 while true; do
106 local cid
107 cid=$("${COMPOSE[@]}" ps -q "$service")
108 if [ -z "$cid" ]; then
109 if [ $((SECONDS - start)) -ge "$timeout" ]; then
110 echo "No container found for service: $service" >&2
111 return 1
112 fi
113 sleep 2
114 continue
115 fi
116
117 local status
118 status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid")"
119 if [ "$status" = "healthy" ]; then
120 return 0
121 fi
122 if [ $((SECONDS - start)) -ge "$timeout" ]; then
123 echo "Timed out waiting for $service to be healthy" >&2
124 return 1
125 fi
126 sleep 2
127 done
128 }
129
130 write_env() {
131 local key="$1"
132 local value="$2"
133 echo "${key}=${value}" >> "$WORKDIR/.env"
134 export "${key}=${value}"
135 }
136
137 pick_free_port() {
138 if command -v python3 >/dev/null 2>&1; then
139 python3 - <<'PY'
140 import socket
141 s = socket.socket()
142 s.bind(("", 0))
143 print(s.getsockname()[1])
144 s.close()
145 PY
146 elif command -v python >/dev/null 2>&1; then
147 python - <<'PY'
148 import socket
149 s = socket.socket()
150 s.bind(("", 0))
151 print(s.getsockname()[1])
152 s.close()
153 PY
154 else
155 echo "python3 (or python) is required to select a free port" >&2
156 return 1
157 fi
158 }
159
160 reserve_port() {
161 local port
162 while true; do
163 port="$(pick_free_port)"
164 case " $USED_PORTS " in
165 *" $port "*) ;;
166 *)
167 USED_PORTS="${USED_PORTS} ${port}"
168 echo "$port"
169 return 0
170 ;;
171 esac
172 done
173 }
174
175 replace_in_file() {
176 local file="$1"
177 local search="$2"
178 local replace="$3"
179 run sed -i "s|$search|$replace|g" "$file"
180 }
181
182 build_plugin() {
183 run bash -c "cd \"$REPO_ROOT/src/go\" && go build -o \"$WORKDIR/go.d.plugin\" ./cmd/godplugin"
184 }
185
186 validate() {
187 local input="$1"
188 shift
189 (cd "$REPO_ROOT/src/go" && run go run ./tools/functions-validation/validate --input "$input" "$@")
190 }
191
192 run_info_method() {
193 local module="$1"
194 local method="$2"
195 local output="$WORKDIR/${module}-${method}-info.json"
196 run "$WORKDIR/go.d.plugin" \
197 --config-dir "$WORKDIR/config" \
198 --function "${module}:${method}" \
199 --function-args info \
200 > "$output"
201 validate "$output"
202 }
203
204 run_info() {
205 local module="$1"
206 run_info_method "$module" "top-queries"
207 }
208
209 run_function() {
210 local module="$1"
211 local method="$2"
212 local args="${3:-__job:local}"
213 local require_rows="${4:-true}"
214 local output="$WORKDIR/${module}-${method}.json"
215
216 run "$WORKDIR/go.d.plugin" \
217 --config-dir "$WORKDIR/config" \
218 --function "${module}:${method}" \
219 --function-args "$args" \
220 > "$output"
221
222 if [ "$require_rows" = "true" ]; then
223 validate "$output" --min-rows 1
224 else
225 validate "$output"
226 fi
227
228 echo "$output"
229 }
230
231 run_top_queries() {
232 local module="$1"
233 local output="$WORKDIR/${module}-top-queries.json"
234 run "$WORKDIR/go.d.plugin" \
235 --config-dir "$WORKDIR/config" \
236 --function "${module}:top-queries" \
237 --function-args __job:local \
238 > "$output"
239 validate "$output" --min-rows 1
240 }
241
242 run_running_queries() {
243 local module="$1"
244 local min_rows="${2:-1}"
245 local output="$WORKDIR/${module}-running-queries.json"
246 run "$WORKDIR/go.d.plugin" \
247 --config-dir "$WORKDIR/config" \
248 --function "${module}:running-queries" \
249 --function-args __job:local \
250 > "$output"
251 validate "$output" --min-rows "$min_rows"
252 }
253
254 has_min_rows() {
255 local input="$1"
256 local min_rows="${2:-1}"
257 if command -v python3 >/dev/null 2>&1; then
258 python3 - "$input" "$min_rows" <<'PY'
259 import json
260 import sys
261
262 path = sys.argv[1]
263 min_rows = int(sys.argv[2])
264 with open(path, "r", encoding="utf-8") as fh:
265 data = json.load(fh)
266 rows = data.get("data", [])
267 sys.exit(0 if len(rows) >= min_rows else 1)
268 PY
269 else
270 python - "$input" "$min_rows" <<'PY'
271 import json
272 import sys
273
274 path = sys.argv[1]
275 min_rows = int(sys.argv[2])
276 with open(path, "r", encoding="utf-8") as fh:
277 data = json.load(fh)
278 rows = data.get("data", [])
279 sys.exit(0 if len(rows) >= min_rows else 1)
280 PY
281 fi
282 }
283
284 # Assert column visibility rules:
285 # - If fewer than 5 columns exist, ALL must be visible
286 # - Otherwise, at least 5 columns must be visible
287 assert_column_visibility() {
288 local input="$1"
289 local context="${2:-response}"
290
291 if command -v python3 >/dev/null 2>&1; then
292 python3 - "$input" "$context" <<'PY'
293 import json
294 import sys
295
296 path = sys.argv[1]
297 context = sys.argv[2]
298
299 with open(path, "r", encoding="utf-8") as fh:
300 doc = json.load(fh)
301
302 columns = doc.get("columns") or {}
303
304 # Build list of columns with their visibility
305 col_list = []
306 if isinstance(columns, dict):
307 for field, col in columns.items():
308 if isinstance(col, dict):
309 col_list.append({"field": field, "visible": col.get("visible", False)})
310 else:
311 for col in columns:
312 if isinstance(col, dict):
313 col_list.append({"field": col.get("field", ""), "visible": col.get("visible", False)})
314
315 total = len(col_list)
316 visible_count = sum(1 for c in col_list if c["visible"] is True)
317
318 if total < 5:
319 # All columns must be visible
320 if visible_count != total:
321 invisible = [c["field"] for c in col_list if c["visible"] is not True]
322 raise SystemExit(
323 f"{context}: All {total} columns must be visible (fewer than 5 total), "
324 f"but only {visible_count} are visible. Invisible columns: {invisible}"
325 )
326 else:
327 # At least 5 columns must be visible
328 if visible_count < 5:
329 invisible = [c["field"] for c in col_list if c["visible"] is not True]
330 raise SystemExit(
331 f"{context}: At least 5 columns must be visible, "
332 f"but only {visible_count} of {total} are visible. Invisible columns: {invisible}"
333 )
334 PY
335 else
336 python - "$input" "$context" <<'PY'
337 import json
338 import sys
339
340 path = sys.argv[1]
341 context = sys.argv[2]
342
343 with open(path, "r") as fh:
344 doc = json.load(fh)
345
346 columns = doc.get("columns") or {}
347
348 col_list = []
349 if isinstance(columns, dict):
350 for field, col in columns.items():
351 if isinstance(col, dict):
352 col_list.append({"field": field, "visible": col.get("visible", False)})
353 else:
354 for col in columns:
355 if isinstance(col, dict):
356 col_list.append({"field": col.get("field", ""), "visible": col.get("visible", False)})
357
358 total = len(col_list)
359 visible_count = sum(1 for c in col_list if c["visible"] is True)
360
361 if total < 5:
362 if visible_count != total:
363 invisible = [c["field"] for c in col_list if c["visible"] is not True]
364 raise SystemExit(
365 "%s: All %d columns must be visible (fewer than 5 total), "
366 "but only %d are visible. Invisible columns: %s" % (context, total, visible_count, invisible)
367 )
368 else:
369 if visible_count < 5:
370 invisible = [c["field"] for c in col_list if c["visible"] is not True]
371 raise SystemExit(
372 "%s: At least 5 columns must be visible, "
373 "but only %d of %d are visible. Invisible columns: %s" % (context, visible_count, total, invisible)
374 )
375 PY
376 fi
377 }
378
379 # Assert UniqueKey column has non-empty values in all rows
380 assert_unique_key_populated() {
381 local input="$1"
382 local context="${2:-response}"
383
384 if command -v python3 >/dev/null 2>&1; then
385 python3 - "$input" "$context" <<'PY'
386 import json
387 import sys
388
389 path = sys.argv[1]
390 context = sys.argv[2]
391
392 with open(path, "r", encoding="utf-8") as fh:
393 doc = json.load(fh)
394
395 columns = doc.get("columns") or {}
396 data = doc.get("data") or []
397
398 # Find UniqueKey column index
399 unique_key_idx = None
400 unique_key_field = None
401
402 if isinstance(columns, dict):
403 for field, col in columns.items():
404 if isinstance(col, dict) and col.get("unique_key") is True:
405 unique_key_idx = col.get("index")
406 unique_key_field = field
407 break
408 else:
409 for idx, col in enumerate(columns):
410 if isinstance(col, dict) and col.get("unique_key") is True:
411 unique_key_idx = idx
412 unique_key_field = col.get("field", f"column_{idx}")
413 break
414
415 if unique_key_idx is None:
416 # No UniqueKey column defined, skip check
417 sys.exit(0)
418
419 # Check all rows have non-empty UniqueKey
420 for i, row in enumerate(data):
421 if unique_key_idx >= len(row):
422 raise SystemExit(f"{context}: Row {i} missing UniqueKey column (index {unique_key_idx})")
423 val = row[unique_key_idx]
424 if val is None or str(val).strip() == "":
425 raise SystemExit(
426 f"{context}: Row {i} has empty UniqueKey ({unique_key_field}) - "
427 f"deduplication will fail. This may indicate NULL digest handling is broken."
428 )
429 PY
430 else
431 python - "$input" "$context" <<'PY'
432 import json
433 import sys
434
435 path = sys.argv[1]
436 context = sys.argv[2]
437
438 with open(path, "r") as fh:
439 doc = json.load(fh)
440
441 columns = doc.get("columns") or {}
442 data = doc.get("data") or []
443
444 unique_key_idx = None
445 unique_key_field = None
446
447 if isinstance(columns, dict):
448 for field, col in columns.items():
449 if isinstance(col, dict) and col.get("unique_key") is True:
450 unique_key_idx = col.get("index")
451 unique_key_field = field
452 break
453 else:
454 for idx, col in enumerate(columns):
455 if isinstance(col, dict) and col.get("unique_key") is True:
456 unique_key_idx = idx
457 unique_key_field = col.get("field", "column_%d" % idx)
458 break
459
460 if unique_key_idx is None:
461 sys.exit(0)
462
463 for i, row in enumerate(data):
464 if unique_key_idx >= len(row):
465 raise SystemExit("%s: Row %d missing UniqueKey column (index %d)" % (context, i, unique_key_idx))
466 val = row[unique_key_idx]
467 if val is None or str(val).strip() == "":
468 raise SystemExit(
469 "%s: Row %d has empty UniqueKey (%s) - "
470 "deduplication will fail. This may indicate NULL digest handling is broken." % (context, i, unique_key_field)
471 )
472 PY
473 fi
474 }