| 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 | } |