@cryptotaxi247 / netdata-1 / commits / 22b9a36ec

Add offline Function test modes for NetFlow and systemd journal (#22638)

Costa Tsaousis committed Jun 6, 2026 at 23:13 UTC 22b9a36eceeb6389275e9f2a64478256ca63c62e
14 files changed +1353 -54
src/collectors/systemd-journal.plugin/README.md
+29
@@ -36,6 +36,35 @@ This plugin is a Netdata Function Plugin. A free Netdata Cloud account is requir
36
37 The plugin is designed for native package installations, source installations, and Docker installations (Debian-based). If using Docker, make sure you're using the Debian-based containers.
38
39 +## Offline Function test mode
40 +
41 +Fixture harnesses can execute the Function query path directly against an existing
42 +journal directory:
43 +
44 +```sh
45 +systemd-journal.plugin --test systemd-journal --dir <journal-dir> [--timeout <seconds>] < payload.json
46 +```
47 +
48 +Requirements:
49 +
50 +- `<journal-dir>` is scanned recursively for systemd journal files.
51 +- stdin is the JSON Function request body (non-empty, maximum 16 MiB).
52 +- `--request` is not supported and fails with usage output.
53 +- `--timeout <seconds>` controls the offline Function execution timeout. It
54 + defaults to `60`; use `--timeout 0` to map to a very large finite timeout for
55 + long-running fixture comparisons.
56 +- stdout contains only the raw JSON Function response.
57 +- errors are written to stderr and return non-zero.
58 +
59 +For example, an info request can use this payload:
60 +
61 +```json
62 +{"info":true}
63 +```
64 +
65 +Function output includes volatile fields such as versions and timing-derived values.
66 +Test harnesses should normalize those fields before comparing fixture outputs.
67 +
68 ## Journal sources
69
70 The plugin automatically detects available journal sources based on the journal files in `/var/log/journal` (persistent logs) and `/run/log/journal` (volatile logs).
src/collectors/systemd-journal.plugin/systemd-internals.h
+11
@@ -97,6 +97,8 @@ void available_journal_file_sources_to_json_array(BUFFER *wb);
97 bool nd_journal_files_completed_once(void);
98 void nd_journal_files_registry_update(void);
99 void nd_journal_directory_scan_recursively(DICTIONARY *files, DICTIONARY *dirs, const char *dirname, int depth);
100 +void nd_journal_set_scan_progress_enabled(bool enabled);
101 +void nd_journal_use_single_directory(const char *path);
102
103 FACET_ROW_SEVERITY syslog_priority_to_facet_severity(FACETS *facets, FACET_ROW *row, void *data);
104
@@ -124,6 +126,15 @@ struct journal_directory {
126 extern struct journal_directory journal_directories[MAX_JOURNAL_DIRECTORIES];
127
128 void nd_journal_init_files_and_directories(void);
129 +BUFFER *function_systemd_journal_result(
130 + const char *transaction,
131 + char *function,
132 + usec_t *stop_monotonic_ut,
133 + bool *cancelled,
134 + BUFFER *payload,
135 + HTTP_ACCESS access __maybe_unused,
136 + const char *source __maybe_unused,
137 + void *data __maybe_unused);
138 void function_systemd_journal(
139 const char *transaction,
140 char *function,
src/collectors/systemd-journal.plugin/systemd-journal-files.c
+26 -3
@@ -11,6 +11,29 @@ DICTIONARY *nd_journal_files_registry = NULL;
11 DICTIONARY *used_hashes_registry = NULL;
12
13 static usec_t systemd_journal_session = 0;
14 +static bool nd_journal_scan_progress_enabled = true;
15 +
16 +void nd_journal_set_scan_progress_enabled(bool enabled)
17 +{
18 + nd_journal_scan_progress_enabled = enabled;
19 +}
20 +
21 +static void nd_journal_scan_progress(void)
22 +{
23 + if (nd_journal_scan_progress_enabled)
24 + send_newline_and_flush(&stdout_mutex);
25 +}
26 +
27 +void nd_journal_use_single_directory(const char *path)
28 +{
29 + string_freez(journal_directories[0].path);
30 + journal_directories[0].path = string_strdupz(path);
31 +
32 + for (size_t i = 1; i < MAX_JOURNAL_DIRECTORIES; i++) {
33 + string_freez(journal_directories[i].path);
34 + journal_directories[i].path = NULL;
35 + }
36 +}
37
38 void buffer_json_journal_versions(BUFFER *wb)
39 {
@@ -659,14 +682,14 @@ void nd_journal_directory_scan_recursively(DICTIONARY *files, DICTIONARY *dirs,
682 if (files)
683 dictionary_set(files, full_path, NULL, 0);
684
662 - send_newline_and_flush(&stdout_mutex);
685 + nd_journal_scan_progress();
686 } else if (entry->d_type == DT_LNK) {
687 struct stat info;
688 if (stat(full_path, &info) == -1)
689 continue;
690
691 + // Journal discovery intentionally follows symlinked journal directories.
692 if (S_ISDIR(info.st_mode)) {
669 - // The symbolic link points to a directory
693 char resolved_path[FILENAME_MAX + 1];
694 if (realpath(full_path, resolved_path) != NULL) {
695 nd_journal_directory_scan_recursively(files, dirs, resolved_path, depth + 1);
@@ -675,7 +698,7 @@ void nd_journal_directory_scan_recursively(DICTIONARY *files, DICTIONARY *dirs,
698 if (files)
699 dictionary_set(files, full_path, NULL, 0);
700
678 - send_newline_and_flush(&stdout_mutex);
701 + nd_journal_scan_progress();
702 }
703 }
704 }
src/collectors/systemd-journal.plugin/systemd-journal.c
+27 -11
@@ -16,8 +16,8 @@
16 #define JOURNAL_KEY_ND_JOURNAL_PROCESS "ND_JOURNAL_PROCESS"
17
18 // functions needed by LQS
19 -static __always_inline
20 -SD_JOURNAL_FILE_SOURCE_TYPE get_internal_source_type(const char *value) {
19 +static __always_inline SD_JOURNAL_FILE_SOURCE_TYPE get_internal_source_type(const char *value)
20 +{
21 if (strcmp(value, ND_SD_JF_SOURCE_ALL_NAME) == 0)
22 return ND_SD_JF_ALL;
23 else if (strcmp(value, ND_SD_JF_SOURCE_LOCAL_NAME) == 0)
@@ -87,8 +87,7 @@ SD_JOURNAL_FILE_SOURCE_TYPE get_internal_source_type(const char *value) {
87 "|_UID" \
88 "|_GID" \
89 "|_COMM" \
90 - "|_EXE" /* "|_CMDLINE" */ \
91 - "|_CAP_EFFECTIVE" /* "|_AUDIT_SESSION" */ \
90 + "|_EXE" /* "|_CMDLINE" "|_CAP_EFFECTIVE" "|_AUDIT_SESSION" */ \
91 "|_AUDIT_LOGINUID" \
92 "|_SYSTEMD_CGROUP" \
93 "|_SYSTEMD_SLICE" \
@@ -101,8 +100,7 @@ SD_JOURNAL_FILE_SOURCE_TYPE get_internal_source_type(const char *value) {
100 "|_BOOT_ID" \
101 "|_MACHINE_ID" /* "|_SYSTEMD_INVOCATION_ID" */ \
102 "|_HOSTNAME" \
104 - "|_TRANSPORT" \
105 - "|_STREAM_ID" /* "|LINE_BREAK" */ \
103 + "|_TRANSPORT" /* "|_STREAM_ID" "|LINE_BREAK" */ \
104 "|_NAMESPACE" \
105 "|_RUNTIME_SCOPE" \
106 \
@@ -155,8 +153,8 @@ SD_JOURNAL_FILE_SOURCE_TYPE get_internal_source_type(const char *value) {
153
154 #include "systemd-journal-execute.h"
155
158 -static
159 -void systemd_journal_register_transformations(LOGS_QUERY_STATUS *lqs) {
156 +static void systemd_journal_register_transformations(LOGS_QUERY_STATUS *lqs)
157 +{
158 FACETS *facets = lqs->facets;
159 LOGS_QUERY_REQUEST *rq = &lqs->rq;
160
@@ -257,7 +255,7 @@ void systemd_journal_register_transformations(LOGS_QUERY_STATUS *lqs) {
255 NULL);
256 }
257
260 -void function_systemd_journal(
258 +BUFFER *function_systemd_journal_result(
259 const char *transaction,
260 char *function,
261 usec_t *stop_monotonic_ut,
@@ -292,7 +290,7 @@ void function_systemd_journal(
290 };
291 LOGS_QUERY_STATUS *lqs = &tmp_fqs;
292
295 - CLEAN_BUFFER *wb = lqs_create_output_buffer();
293 + BUFFER *wb = lqs_create_output_buffer();
294
295 // ------------------------------------------------------------------------
296 // parse the parameters
@@ -317,9 +315,27 @@ void function_systemd_journal(
315 }
316 }
317
318 + lqs_cleanup(lqs);
319 +
320 + return wb;
321 +}
322 +
323 +void function_systemd_journal(
324 + const char *transaction,
325 + char *function,
326 + usec_t *stop_monotonic_ut,
327 + bool *cancelled,
328 + BUFFER *payload,
329 + HTTP_ACCESS access,
330 + const char *source,
331 + void *data)
332 +{
333 + BUFFER *wb = function_systemd_journal_result(
334 + transaction, function, stop_monotonic_ut, cancelled, payload, access, source, data);
335 +
336 netdata_mutex_lock(&stdout_mutex);
337 pluginsd_function_result_to_stdout(transaction, wb);
338 netdata_mutex_unlock(&stdout_mutex);
339
324 - lqs_cleanup(lqs);
340 + buffer_free(wb);
341 }
src/collectors/systemd-journal.plugin/systemd-main.c
+375 -1
@@ -3,6 +3,8 @@
3 #include "systemd-internals.h"
4
5 #define ND_SD_JOURNAL_WORKER_THREADS 5
6 +#define ND_SD_JOURNAL_TEST_TIMEOUT_DISABLED_SECONDS (100ULL * 365ULL * 24ULL * 60ULL * 60ULL)
7 +#define ND_SD_JOURNAL_TEST_MAX_REQUEST_BYTES (16ULL * 1024ULL * 1024ULL)
8
9 netdata_mutex_t stdout_mutex;
10
@@ -16,6 +18,370 @@ static void __attribute__((destructor)) destroy_mutex(void) {
18
19 static bool plugin_should_exit = false;
20
21 +struct systemd_journal_test_command {
22 + bool enabled;
23 + const char *function_name;
24 + const char *backend_dir;
25 + uint64_t timeout_seconds;
26 + bool timeout_seconds_set;
27 +};
28 +
29 +static void systemd_journal_test_usage(FILE *stream)
30 +{
31 + fprintf(
32 + stream,
33 + "usage: systemd-journal.plugin --test systemd-journal --dir <journal-dir> [--timeout <seconds>] < payload.json\n");
34 +}
35 +
36 +static bool test_option_present(int argc, char **argv)
37 +{
38 + for (int i = 1; i < argc; i++) {
39 + if (strcmp(argv[i], "--test") == 0 || strncmp(argv[i], "--test=", strlen("--test=")) == 0)
40 + return true;
41 + }
42 +
43 + return false;
44 +}
45 +
46 +static int set_required_option_once(const char **slot, const char *value, const char *option)
47 +{
48 + if (*slot) {
49 + fprintf(stderr, "duplicate %s\n", option);
50 + systemd_journal_test_usage(stderr);
51 + return 2;
52 + }
53 +
54 + if (!value || !*value) {
55 + fprintf(stderr, "missing value for %s\n", option);
56 + systemd_journal_test_usage(stderr);
57 + return 2;
58 + }
59 +
60 + *slot = value;
61 + return 0;
62 +}
63 +
64 +static int set_timeout_option_once(uint64_t *slot, bool *slot_set, const char *value)
65 +{
66 + if (*slot_set) {
67 + fprintf(stderr, "duplicate --timeout\n");
68 + systemd_journal_test_usage(stderr);
69 + return 2;
70 + }
71 +
72 + if (!value || !*value) {
73 + fprintf(stderr, "missing value for --timeout\n");
74 + systemd_journal_test_usage(stderr);
75 + return 2;
76 + }
77 +
78 + for (const char *s = value; *s; s++) {
79 + if (*s < '0' || *s > '9') {
80 + fprintf(stderr, "invalid value for --timeout '%s'; expected seconds\n", value);
81 + systemd_journal_test_usage(stderr);
82 + return 2;
83 + }
84 + }
85 +
86 + errno = 0;
87 + unsigned long long parsed = strtoull(value, NULL, 10);
88 + if (errno == ERANGE) {
89 + fprintf(stderr, "invalid value for --timeout '%s'; expected seconds\n", value);
90 + systemd_journal_test_usage(stderr);
91 + return 2;
92 + }
93 +
94 +#if ULLONG_MAX > UINT64_MAX
95 + if (parsed > UINT64_MAX) {
96 + fprintf(stderr, "invalid value for --timeout '%s'; expected seconds\n", value);
97 + systemd_journal_test_usage(stderr);
98 + return 2;
99 + }
100 +#endif
101 +
102 + *slot = (uint64_t)parsed;
103 + *slot_set = true;
104 + return 0;
105 +}
106 +
107 +static int reject_request_option(void)
108 +{
109 + fprintf(stderr, "--request is no longer supported; pass the request payload on stdin\n");
110 + systemd_journal_test_usage(stderr);
111 + return 2;
112 +}
113 +
114 +static int parse_systemd_journal_test_command(int argc, char **argv, struct systemd_journal_test_command *cmd)
115 +{
116 + *cmd = (struct systemd_journal_test_command){0};
117 + if (!test_option_present(argc, argv))
118 + return 0;
119 +
120 + cmd->enabled = true;
121 +
122 + for (int i = 1; i < argc; i++) {
123 + const char *arg = argv[i];
124 +
125 + if (strcmp(arg, "--test") == 0) {
126 + if (++i >= argc)
127 + return set_required_option_once(&cmd->function_name, NULL, "--test");
128 +
129 + int rc = set_required_option_once(&cmd->function_name, argv[i], "--test");
130 + if (rc)
131 + return rc;
132 + }
133 + else if (strncmp(arg, "--test=", strlen("--test=")) == 0) {
134 + int rc = set_required_option_once(&cmd->function_name, arg + strlen("--test="), "--test");
135 + if (rc)
136 + return rc;
137 + }
138 + else if (strcmp(arg, "--dir") == 0) {
139 + if (++i >= argc)
140 + return set_required_option_once(&cmd->backend_dir, NULL, "--dir");
141 +
142 + int rc = set_required_option_once(&cmd->backend_dir, argv[i], "--dir");
143 + if (rc)
144 + return rc;
145 + }
146 + else if (strncmp(arg, "--dir=", strlen("--dir=")) == 0) {
147 + int rc = set_required_option_once(&cmd->backend_dir, arg + strlen("--dir="), "--dir");
148 + if (rc)
149 + return rc;
150 + }
151 + else if (strcmp(arg, "--request") == 0) {
152 + return reject_request_option();
153 + }
154 + else if (strncmp(arg, "--request=", strlen("--request=")) == 0) {
155 + return reject_request_option();
156 + }
157 + else if (strcmp(arg, "--timeout") == 0) {
158 + if (++i >= argc)
159 + return set_timeout_option_once(&cmd->timeout_seconds, &cmd->timeout_seconds_set, NULL);
160 +
161 + int rc = set_timeout_option_once(&cmd->timeout_seconds, &cmd->timeout_seconds_set, argv[i]);
162 + if (rc)
163 + return rc;
164 + }
165 + else if (strncmp(arg, "--timeout=", strlen("--timeout=")) == 0) {
166 + int rc = set_timeout_option_once(
167 + &cmd->timeout_seconds, &cmd->timeout_seconds_set, arg + strlen("--timeout="));
168 + if (rc)
169 + return rc;
170 + }
171 + else if (strcmp(arg, "-h") == 0 || strcmp(arg, "--help") == 0) {
172 + systemd_journal_test_usage(stderr);
173 + return 2;
174 + }
175 + else {
176 + fprintf(stderr, "unsupported systemd journal test option '%s'\n", arg);
177 + systemd_journal_test_usage(stderr);
178 + return 2;
179 + }
180 + }
181 +
182 + if (!cmd->function_name) {
183 + fprintf(stderr, "missing required --test\n");
184 + systemd_journal_test_usage(stderr);
185 + return 2;
186 + }
187 +
188 + if (!cmd->backend_dir) {
189 + fprintf(stderr, "missing required --dir\n");
190 + systemd_journal_test_usage(stderr);
191 + return 2;
192 + }
193 +
194 + if (!cmd->timeout_seconds_set)
195 + cmd->timeout_seconds = ND_SD_JOURNAL_DEFAULT_TIMEOUT;
196 +
197 + return 0;
198 +}
199 +
200 +static uint64_t systemd_journal_effective_timeout_seconds(uint64_t timeout_seconds)
201 +{
202 + return timeout_seconds ? timeout_seconds : ND_SD_JOURNAL_TEST_TIMEOUT_DISABLED_SECONDS;
203 +}
204 +
205 +static usec_t systemd_journal_test_stop_monotonic_usec(uint64_t timeout_seconds)
206 +{
207 + usec_t now_ut = now_monotonic_usec();
208 + uint64_t effective_timeout_seconds = systemd_journal_effective_timeout_seconds(timeout_seconds);
209 + uint64_t max_timeout_seconds = (UINT64_MAX - now_ut) / USEC_PER_SEC;
210 +
211 + if (effective_timeout_seconds > max_timeout_seconds)
212 + return UINT64_MAX;
213 +
214 + return now_ut + effective_timeout_seconds * USEC_PER_SEC;
215 +}
216 +
217 +static DIR *open_systemd_journal_test_backend_directory(const char *path, char *fd_path, size_t fd_path_size)
218 +{
219 + struct stat path_st, dir_st;
220 +
221 + // Pin the explicit --dir backend root; symlinked journal trees are handled by the shared scanner.
222 + if (!path || !*path) {
223 + errno = EINVAL;
224 + return NULL;
225 + }
226 +
227 + if (lstat(path, &path_st) == -1)
228 + return NULL;
229 +
230 + if (S_ISLNK(path_st.st_mode)) {
231 + errno = ELOOP;
232 + return NULL;
233 + }
234 +
235 + if (!S_ISDIR(path_st.st_mode)) {
236 + errno = ENOTDIR;
237 + return NULL;
238 + }
239 +
240 + DIR *dir = opendir(path);
241 + if (!dir)
242 + return NULL;
243 +
244 + int fd = dirfd(dir);
245 + if (fd == -1) {
246 + int saved_errno = errno;
247 + closedir(dir);
248 + errno = saved_errno;
249 + return NULL;
250 + }
251 +
252 + if (fstat(fd, &dir_st) == -1) {
253 + int saved_errno = errno;
254 + closedir(dir);
255 + errno = saved_errno;
256 + return NULL;
257 + }
258 +
259 + if (!S_ISDIR(dir_st.st_mode)) {
260 + closedir(dir);
261 + errno = ENOTDIR;
262 + return NULL;
263 + }
264 +
265 + if (path_st.st_dev != dir_st.st_dev || path_st.st_ino != dir_st.st_ino) {
266 + closedir(dir);
267 + errno = EAGAIN;
268 + return NULL;
269 + }
270 +
271 + int written = snprintfz(fd_path, fd_path_size, "/proc/self/fd/%d", fd);
272 + if (written < 0 || (size_t)written >= fd_path_size) {
273 + closedir(dir);
274 + errno = ENAMETOOLONG;
275 + return NULL;
276 + }
277 +
278 + return dir;
279 +}
280 +
281 +static BUFFER *read_request_payload_from_stdin(void)
282 +{
283 + BUFFER *payload = buffer_create(8192, NULL);
284 + size_t total = 0;
285 + while (true) {
286 + char buffer[8192];
287 + ssize_t bytes_read = read(STDIN_FILENO, buffer, sizeof(buffer));
288 + if (bytes_read == -1) {
289 + if (errno == EINTR)
290 + continue;
291 +
292 + fprintf(stderr, "failed to read request payload from stdin: %s\n", strerror(errno));
293 + buffer_free(payload);
294 + return NULL;
295 + }
296 +
297 + if (bytes_read == 0)
298 + break;
299 +
300 + if ((uint64_t)total + (uint64_t)bytes_read > ND_SD_JOURNAL_TEST_MAX_REQUEST_BYTES) {
301 + fprintf(
302 + stderr,
303 + "request payload from stdin is too large: max %llu bytes\n",
304 + (unsigned long long)ND_SD_JOURNAL_TEST_MAX_REQUEST_BYTES);
305 + buffer_free(payload);
306 + return NULL;
307 + }
308 +
309 + buffer_memcat(payload, buffer, (size_t)bytes_read);
310 + total += (size_t)bytes_read;
311 + }
312 +
313 + if (total == 0) {
314 + fprintf(stderr, "request payload from stdin is empty\n");
315 + buffer_free(payload);
316 + return NULL;
317 + }
318 +
319 + payload->content_type = CT_APPLICATION_JSON;
320 +
321 + return payload;
322 +}
323 +
324 +static int run_systemd_journal_test_command(const struct systemd_journal_test_command *cmd)
325 +{
326 + if (strcmp(cmd->function_name, ND_SD_JOURNAL_FUNCTION_NAME) != 0) {
327 + fprintf(
328 + stderr,
329 + "unsupported systemd journal test function '%s' (expected '%s')\n",
330 + cmd->function_name,
331 + ND_SD_JOURNAL_FUNCTION_NAME);
332 + return 2;
333 + }
334 +
335 + char backend_dir_path[FILENAME_MAX];
336 + DIR *backend_dir =
337 + open_systemd_journal_test_backend_directory(cmd->backend_dir, backend_dir_path, sizeof(backend_dir_path));
338 + if (!backend_dir) {
339 + fprintf(
340 + stderr,
341 + "systemd journal backend directory '%s' cannot be opened: %s\n",
342 + cmd->backend_dir,
343 + strerror(errno));
344 + return 1;
345 + }
346 +
347 + CLEAN_BUFFER *payload = read_request_payload_from_stdin();
348 + if (!payload) {
349 + closedir(backend_dir);
350 + return 1;
351 + }
352 +
353 + bool cancelled = false;
354 + usec_t stop_monotonic_ut = systemd_journal_test_stop_monotonic_usec(cmd->timeout_seconds);
355 +
356 + nd_journal_set_scan_progress_enabled(false);
357 + nd_journal_use_single_directory(backend_dir_path);
358 + nd_journal_files_registry_update();
359 +
360 + char *function = strdupz(cmd->function_name);
361 + BUFFER *result = function_systemd_journal_result(
362 + "test", function, &stop_monotonic_ut, &cancelled, payload, HTTP_ACCESS_ALL, "test-cli", NULL);
363 + freez(function);
364 +
365 + int rc = 1;
366 + if (result) {
367 + if (buffer_strlen(result))
368 + fwrite(buffer_tostring(result), buffer_strlen(result), 1, stdout);
369 + fprintf(stdout, "\n");
370 + fflush(stdout);
371 +
372 + if (result->response_code >= HTTP_RESP_OK && result->response_code < 300)
373 + rc = 0;
374 +
375 + buffer_free(result);
376 + }
377 + else {
378 + fprintf(stderr, "systemd journal test function returned no result\n");
379 + }
380 +
381 + closedir(backend_dir);
382 + return rc;
383 +}
384 +
385 static bool journal_data_directories_exist()
386 {
387 struct stat st;
@@ -26,8 +392,13 @@ static bool journal_data_directories_exist()
392 return false;
393 }
394
29 -int main(int argc __maybe_unused, char **argv __maybe_unused)
395 +int main(int argc, char **argv)
396 {
397 + struct systemd_journal_test_command test_command = {0};
398 + int test_parse_rc = parse_systemd_journal_test_command(argc, argv, &test_command);
399 + if (test_parse_rc)
400 + exit(test_parse_rc);
401 +
402 nd_thread_tag_set("sd-jrnl.plugin");
403 nd_log_initialize_for_external_plugins("systemd-journal.plugin");
404 netdata_threads_init_for_external_plugins(0);
@@ -42,6 +413,9 @@ int main(int argc __maybe_unused, char **argv __maybe_unused)
413 nd_sd_journal_annotations_init();
414 nd_journal_init_files_and_directories();
415
416 + if (test_command.enabled)
417 + exit(run_systemd_journal_test_command(&test_command));
418 +
419 if (!journal_data_directories_exist()) {
420 nd_log_collector(NDLP_INFO, "unable to locate journal data directories. Exiting...");
421 fprintf(stdout, "DISABLE\n");
src/crates/netflow-plugin/README.md
+29
@@ -7,6 +7,35 @@ Rust NetFlow/IPFIX/sFlow ingestion and query plugin.
7 It stores flow entries in journal tiers under the Netdata cache directory and exposes
8 `flows:netflow`.
9
10 +## Offline Function test mode
11 +
12 +Fixture harnesses can execute the Function query path directly against an existing
13 +NetFlow backend directory:
14 +
15 +```sh
16 +netflow-plugin --test flows:netflow --dir <flows-dir> [--timeout <seconds>] [--no-persist] < payload.json
17 +```
18 +
19 +Requirements:
20 +
21 +- `<flows-dir>` is the NetFlow backend root containing the `raw`, `1m`, `5m`, and `1h` tier directories.
22 +- stdin is the JSON Function request body (non-empty, maximum 16 MiB).
23 +- `--request` is not supported and fails with usage output.
24 +- `--timeout <seconds>` controls the offline Function execution timeout. It
25 + defaults to `30`; use `--timeout 0` to map to a very large finite timeout for
26 + long-running fixture comparisons.
27 +- stdout contains only the raw JSON Function response.
28 +- errors are written to stderr and return non-zero.
29 +
30 +Use `--no-persist` for shared fixture datasets. It prevents the test run from writing
31 +facet state or sidecar files under `<flows-dir>` while keeping facet data in memory for
32 +the Function response. Without `--no-persist`, the plugin may refresh facet state under
33 +the backend directory, matching normal runtime behavior.
34 +
35 +Function output includes volatile fields such as collection timestamps and runtime
36 +statistics. Test harnesses should normalize those fields before comparing fixture
37 +outputs.
38 +
39 ## Configuration
40
41 When running under Netdata, config is loaded from `netflow.yaml` in:
src/crates/netflow-plugin/src/api.rs
+2 -4
@@ -1,8 +1,6 @@
1 mod flows;
2
3 pub(crate) use flows::NetflowFlowsHandler;
4 +pub(crate) use flows::{FLOWS_FUNCTION_NAME, FlowsFunctionResponse};
5 #[cfg(test)]
5 -pub(crate) use flows::{
6 - FLOWS_FUNCTION_VERSION, FLOWS_UPDATE_EVERY_SECONDS, FlowsFunctionResponse,
7 - flows_required_params,
8 -};
6 +pub(crate) use flows::{FLOWS_FUNCTION_VERSION, FLOWS_UPDATE_EVERY_SECONDS, flows_required_params};
src/crates/netflow-plugin/src/api/flows.rs
+2 -1
@@ -3,7 +3,8 @@ mod model;
3 mod params;
4
5 pub(crate) use handler::NetflowFlowsHandler;
6 +pub(crate) use model::{FLOWS_FUNCTION_NAME, FlowsFunctionResponse};
7 #[cfg(test)]
7 -pub(crate) use model::{FLOWS_FUNCTION_VERSION, FLOWS_UPDATE_EVERY_SECONDS, FlowsFunctionResponse};
8 +pub(crate) use model::{FLOWS_FUNCTION_VERSION, FLOWS_UPDATE_EVERY_SECONDS};
9 #[cfg(test)]
10 pub(crate) use params::flows_required_params;
src/crates/netflow-plugin/src/api/flows/handler.rs
+7 -5
@@ -9,9 +9,9 @@ use std::sync::Arc;
9 use tokio::task;
10
11 use super::model::{
12 - FLOWS_FUNCTION_VERSION, FLOWS_SCHEMA_VERSION, FLOWS_UPDATE_EVERY_SECONDS, FlowAutocompleteData,
13 - FlowAutocompleteResponse, FlowMetricsData, FlowMetricsResponse, FlowsData,
14 - FlowsFunctionResponse, FlowsResponse,
12 + FLOWS_FUNCTION_NAME, FLOWS_FUNCTION_VERSION, FLOWS_SCHEMA_VERSION, FLOWS_UPDATE_EVERY_SECONDS,
13 + FlowAutocompleteData, FlowAutocompleteResponse, FlowMetricsData, FlowMetricsResponse,
14 + FlowsData, FlowsFunctionResponse, FlowsResponse,
15 };
16 use super::params::{accepted_params, flows_required_params};
17
@@ -264,8 +264,10 @@ impl FunctionHandler for NetflowFlowsHandler {
264 }
265
266 fn declaration(&self) -> FunctionDeclaration {
267 - let mut func_decl =
268 - FunctionDeclaration::new("flows:netflow", "NetFlow/IPFIX/sFlow flow analysis data");
267 + let mut func_decl = FunctionDeclaration::new(
268 + FLOWS_FUNCTION_NAME,
269 + "NetFlow/IPFIX/sFlow flow analysis data",
270 + );
271 func_decl.global = true;
272 func_decl.tags = Some("flows".to_string());
273 func_decl.access =
src/crates/netflow-plugin/src/api/flows/model.rs
+11
@@ -3,6 +3,7 @@ use serde_json::Value;
3 use std::collections::HashMap;
4
5 pub(crate) const FLOWS_SCHEMA_VERSION: &str = "2.0";
6 +pub(crate) const FLOWS_FUNCTION_NAME: &str = "flows:netflow";
7 pub(crate) const FLOWS_FUNCTION_VERSION: u32 = 4;
8 pub(crate) const FLOWS_UPDATE_EVERY_SECONDS: u32 = 60;
9
@@ -128,3 +129,13 @@ pub(crate) enum FlowsFunctionResponse {
129 Metrics(FlowMetricsResponse),
130 Autocomplete(FlowAutocompleteResponse),
131 }
132 +
133 +impl FlowsFunctionResponse {
134 + pub(crate) fn status(&self) -> u32 {
135 + match self {
136 + Self::Table(response) => response.status,
137 + Self::Metrics(response) => response.status,
138 + Self::Autocomplete(response) => response.status,
139 + }
140 + }
141 +}
src/crates/netflow-plugin/src/facet_runtime.rs
+161 -23
@@ -31,7 +31,7 @@ pub(crate) use contribution::{
31 FacetFileContribution, FacetValueSink, append_record_facet_values,
32 facet_contribution_from_flow_fields,
33 };
34 -use sidecar::{delete_sidecar_files, search_sidecar, write_sidecar_files};
34 +use sidecar::{delete_sidecar_files, search_sidecar, sidecar_path, write_sidecar_files};
35 use store::{FacetStore, FacetStoreValueRef, PersistedFacetStore};
36
37 const FACET_STATE_VERSION: u32 = 5;
@@ -97,10 +97,31 @@ pub(crate) struct FacetRuntime {
97 ready: AtomicBool,
98 ready_notify: Notify,
99 state_path: PathBuf,
100 + persistence: FacetPersistence,
101 +}
102 +
103 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104 +enum FacetPersistence {
105 + Enabled,
106 + ReadOnly,
107 +}
108 +
109 +impl FacetPersistence {
110 + fn writes_enabled(self) -> bool {
111 + matches!(self, Self::Enabled)
112 + }
113 }
114
115 impl FacetRuntime {
116 pub(crate) fn new(base_dir: &Path) -> Self {
117 + Self::with_persistence(base_dir, FacetPersistence::Enabled)
118 + }
119 +
120 + pub(crate) fn new_read_only(base_dir: &Path) -> Self {
121 + Self::with_persistence(base_dir, FacetPersistence::ReadOnly)
122 + }
123 +
124 + fn with_persistence(base_dir: &Path, persistence: FacetPersistence) -> Self {
125 let state_path = base_dir.join(FACET_STATE_FILE_NAME);
126 let loaded = load_persisted_state(&state_path);
127 let ready = loaded.is_some();
@@ -115,6 +136,7 @@ impl FacetRuntime {
136 ready: AtomicBool::new(ready),
137 ready_notify: Notify::new(),
138 state_path,
139 + persistence,
140 }
141 }
142
@@ -221,7 +243,9 @@ impl FacetRuntime {
243 .collect::<Vec<_>>();
244 for path in removed_archived {
245 state.indexed_archived_paths.remove(&path);
224 - delete_sidecar_files(Path::new(&path));
246 + if self.persistence.writes_enabled() {
247 + delete_sidecar_files(Path::new(&path));
248 + }
249 state.rebuild_archived = true;
250 state.dirty = true;
251 }
@@ -233,7 +257,9 @@ impl FacetRuntime {
257
258 for (path, contribution) in archived_scans {
259 merge_global_contribution(&mut state.archived_fields, &contribution);
236 - write_sidecar_files(Path::new(&path), &contribution)?;
260 + if self.persistence.writes_enabled() {
261 + write_sidecar_files(Path::new(&path), &contribution)?;
262 + }
263 if state.indexed_archived_paths.insert(path) {
264 state.dirty = true;
265 }
@@ -248,7 +274,7 @@ impl FacetRuntime {
274 state.rebuild_archived = false;
275
276 publish_locked(&self.snapshot, &state);
251 - persist_state_locked(&self.state_path, &mut state)?;
277 + self.persist_state_locked(&mut state)?;
278 drop(state);
279 self.mark_ready();
280 Ok(())
@@ -314,11 +340,13 @@ impl FacetRuntime {
340 if let Some(contribution) = contribution {
341 merge_global_contribution(&mut state.archived_fields, &contribution);
342 rebuild_published_fields(&mut state);
317 - write_sidecar_files(archived_path, &contribution)?;
343 + if self.persistence.writes_enabled() {
344 + write_sidecar_files(archived_path, &contribution)?;
345 + }
346 state.indexed_archived_paths.insert(archived_path_str);
347 state.dirty = true;
348 publish_locked(&self.snapshot, &state);
321 - persist_state_locked(&self.state_path, &mut state)?;
349 + self.persist_state_locked(&mut state)?;
350 }
351
352 Ok(())
@@ -342,7 +370,9 @@ impl FacetRuntime {
370 state.rebuild_archived = true;
371 changed = true;
372 }
345 - delete_sidecar_files(path);
373 + if self.persistence.writes_enabled() {
374 + delete_sidecar_files(path);
375 + }
376 }
377
378 if changed {
@@ -351,7 +381,7 @@ impl FacetRuntime {
381 publish_locked(&self.snapshot, &state);
382 }
383 state.dirty = true;
354 - persist_state_locked(&self.state_path, &mut state)?;
384 + self.persist_state_locked(&mut state)?;
385 }
386
387 Ok(())
@@ -364,7 +394,7 @@ impl FacetRuntime {
394 };
395 let match_kind = spec.autocomplete_match;
396
367 - let (promoted, mut matches, archived_paths) = {
397 + let (use_sidecars, mut matches, archived_paths) = {
398 let state = self
399 .state
400 .lock()
@@ -378,7 +408,10 @@ impl FacetRuntime {
408 term,
409 match_kind,
410 );
381 - let archived_matches = if !spec.uses_sidecar || !published.autocomplete {
411 + let use_sidecars = spec.uses_sidecar && published.autocomplete;
412 + let archived_matches = if use_sidecars {
413 + Vec::new()
414 + } else {
415 state
416 .archived_fields
417 .get(normalized.as_str())
@@ -386,8 +419,6 @@ impl FacetRuntime {
419 store.autocomplete_matches(term, FACET_AUTOCOMPLETE_LIMIT, match_kind)
420 })
421 .unwrap_or_default()
389 - } else {
390 - Vec::new()
422 };
423 let archived_paths = state
424 .indexed_archived_paths
@@ -395,30 +426,49 @@ impl FacetRuntime {
426 .cloned()
427 .collect::<Vec<_>>();
428 (
398 - published.autocomplete,
429 + use_sidecars,
430 merge_autocomplete_values(active_matches, archived_matches),
431 archived_paths,
432 )
433 };
434
404 - if spec.uses_sidecar && promoted && matches.len() < FACET_AUTOCOMPLETE_LIMIT {
405 - for path in archived_paths {
435 + if use_sidecars && matches.len() < FACET_AUTOCOMPLETE_LIMIT {
436 + let mut missing_sidecar = false;
437 + for path in &archived_paths {
438 + let journal_path = Path::new(path);
439 + if !sidecar_path(journal_path, normalized.as_str()).exists() {
440 + missing_sidecar = true;
441 + continue;
442 + }
443 +
444 let needed = FACET_AUTOCOMPLETE_LIMIT.saturating_sub(matches.len());
445 if needed == 0 {
446 break;
447 }
410 - let sidecar_matches = search_sidecar(
411 - Path::new(&path),
412 - normalized.as_str(),
413 - term,
414 - needed,
415 - match_kind,
416 - )?;
448 + let sidecar_matches =
449 + search_sidecar(journal_path, normalized.as_str(), term, needed, match_kind)?;
450 matches = merge_autocomplete_values(matches, sidecar_matches);
451 if matches.len() >= FACET_AUTOCOMPLETE_LIMIT {
452 break;
453 }
454 }
455 +
456 + if missing_sidecar && matches.len() < FACET_AUTOCOMPLETE_LIMIT {
457 + let archived_matches = {
458 + let state = self
459 + .state
460 + .lock()
461 + .map_err(|_| anyhow::anyhow!("facet runtime lock poisoned"))?;
462 + state
463 + .archived_fields
464 + .get(normalized.as_str())
465 + .map(|store| {
466 + store.autocomplete_matches(term, FACET_AUTOCOMPLETE_LIMIT, match_kind)
467 + })
468 + .unwrap_or_default()
469 + };
470 + matches = merge_autocomplete_values(matches, archived_matches);
471 + }
472 }
473
474 Ok(matches)
@@ -429,7 +479,7 @@ impl FacetRuntime {
479 .state
480 .lock()
481 .map_err(|_| anyhow::anyhow!("facet runtime lock poisoned"))?;
432 - persist_state_locked(&self.state_path, &mut state)
482 + self.persist_state_locked(&mut state)
483 }
484
485 fn mark_ready(&self) {
@@ -438,6 +488,15 @@ impl FacetRuntime {
488 self.ready_notify.notify_waiters();
489 }
490 }
491 +
492 + fn persist_state_locked(&self, state: &mut FacetState) -> Result<()> {
493 + if self.persistence.writes_enabled() {
494 + persist_state_locked(&self.state_path, state)
495 + } else {
496 + state.dirty = false;
497 + Ok(())
498 + }
499 + }
500 }
501
502 impl FacetState {
@@ -1217,6 +1276,85 @@ mod tests {
1276 );
1277 }
1278
1279 + #[test]
1280 + fn read_only_runtime_does_not_write_state_or_sidecars() {
1281 + let tmp = tempfile::tempdir().expect("create temp dir");
1282 + let runtime = FacetRuntime::new_read_only(tmp.path());
1283 + let archived_path = tmp.path().join("flows-promoted.journal");
1284 +
1285 + for value in 0..120 {
1286 + let mut fields = FlowFields::new();
1287 + fields.insert("SRC_AS_NAME", format!("AS{value:03} EXAMPLE"));
1288 + let contribution = facet_contribution_from_flow_fields(&fields);
1289 + runtime
1290 + .observe_active_contribution(&archived_path, &contribution)
1291 + .expect("observe contribution");
1292 + }
1293 +
1294 + runtime
1295 + .observe_rotation(
1296 + &archived_path,
1297 + &tmp.path().join("flows-promoted-next.journal"),
1298 + )
1299 + .expect("rotate file");
1300 +
1301 + assert!(
1302 + !tmp.path().join(FACET_STATE_FILE_NAME).exists(),
1303 + "read-only facet runtime must not write facet state"
1304 + );
1305 + assert!(
1306 + !super::sidecar::sidecar_path(&archived_path, "SRC_AS_NAME").exists(),
1307 + "read-only facet runtime must not write sidecars"
1308 + );
1309 +
1310 + let results = runtime
1311 + .autocomplete("SRC_AS_NAME", "AS11")
1312 + .expect("autocomplete values");
1313 +
1314 + assert!(
1315 + results.iter().any(|value| value == "AS110 EXAMPLE"),
1316 + "read-only mode should search promoted archived values from memory"
1317 + );
1318 + }
1319 +
1320 + #[test]
1321 + fn read_only_runtime_reads_existing_sidecar_files() {
1322 + let tmp = tempfile::tempdir().expect("create temp dir");
1323 + let archived_path = tmp.path().join("flows-promoted.journal");
1324 + let runtime = FacetRuntime::new(tmp.path());
1325 +
1326 + for value in 0..120 {
1327 + let mut fields = FlowFields::new();
1328 + fields.insert("SRC_AS_NAME", format!("AS{value:03} EXAMPLE"));
1329 + let contribution = facet_contribution_from_flow_fields(&fields);
1330 + runtime
1331 + .observe_active_contribution(&archived_path, &contribution)
1332 + .expect("observe contribution");
1333 + }
1334 +
1335 + runtime
1336 + .observe_rotation(
1337 + &archived_path,
1338 + &tmp.path().join("flows-promoted-next.journal"),
1339 + )
1340 + .expect("rotate file");
1341 +
1342 + let runtime = FacetRuntime::new_read_only(tmp.path());
1343 + {
1344 + let mut state = runtime.state.lock().expect("lock facet state");
1345 + state.archived_fields.clear();
1346 + }
1347 +
1348 + let results = runtime
1349 + .autocomplete("SRC_AS_NAME", "AS11")
1350 + .expect("autocomplete values");
1351 +
1352 + assert!(
1353 + results.iter().any(|value| value == "AS110 EXAMPLE"),
1354 + "read-only mode should search existing sidecar files"
1355 + );
1356 + }
1357 +
1358 #[test]
1359 fn runtime_autocomplete_text_field_uses_substring_matching() {
1360 let tmp = tempfile::tempdir().expect("create temp dir");
src/crates/netflow-plugin/src/main.rs
+37 -6
@@ -22,6 +22,7 @@ mod rollup;
22 mod routing;
23 #[cfg(test)]
24 mod startup_memory_tests;
25 +mod test_cli;
26 mod tiering;
27
28 pub(crate) use api::NetflowFlowsHandler;
@@ -46,6 +47,30 @@ fn main() {
47 std::process::exit(1);
48 }
49
50 + match test_cli::TestCommand::parse_from_env_args() {
51 + Ok(Some(command)) => {
52 + let worker_threads = runtime_worker_threads();
53 + let max_blocking_threads = runtime_blocking_threads(worker_threads);
54 + let runtime = match build_tokio_runtime(worker_threads, max_blocking_threads) {
55 + Ok(runtime) => runtime,
56 + Err(err) => {
57 + eprintln!("failed to build tokio runtime: {}", err);
58 + std::process::exit(1);
59 + }
60 + };
61 + if let Err(err) = runtime.block_on(test_cli::run(command)) {
62 + eprintln!("{err:#}");
63 + std::process::exit(1);
64 + }
65 + return;
66 + }
67 + Ok(None) => {}
68 + Err(err) => {
69 + eprintln!("{err}");
70 + std::process::exit(2);
71 + }
72 + }
73 +
74 #[cfg(all(target_os = "linux", target_env = "gnu"))]
75 let glibc_arena_max = memory_allocator::limit_glibc_arenas_for_process();
76
@@ -79,12 +104,7 @@ fn main() {
104 "configured netflow tokio runtime"
105 );
106
82 - let runtime = match tokio::runtime::Builder::new_multi_thread()
83 - .enable_all()
84 - .worker_threads(worker_threads)
85 - .max_blocking_threads(max_blocking_threads)
86 - .build()
87 - {
107 + let runtime = match build_tokio_runtime(worker_threads, max_blocking_threads) {
108 Ok(runtime) => runtime,
109 Err(err) => {
110 eprintln!("failed to build tokio runtime: {}", err);
@@ -370,6 +390,17 @@ fn runtime_blocking_threads(worker_threads: usize) -> usize {
390 MIN_RUNTIME_BLOCKING_THREADS.max(worker_threads)
391 }
392
393 +fn build_tokio_runtime(
394 + worker_threads: usize,
395 + max_blocking_threads: usize,
396 +) -> std::io::Result<tokio::runtime::Runtime> {
397 + tokio::runtime::Builder::new_multi_thread()
398 + .enable_all()
399 + .worker_threads(worker_threads)
400 + .max_blocking_threads(max_blocking_threads)
401 + .build()
402 +}
403 +
404 #[cfg(test)]
405 #[path = "main_tests.rs"]
406 mod tests;
src/crates/netflow-plugin/src/plugin_config/runtime.rs
+15
@@ -20,6 +20,21 @@ impl PluginConfig {
20 Ok(cfg)
21 }
22
23 + pub(crate) fn for_test_backend_dir(backend_dir: &Path) -> Result<Self> {
24 + let mut cfg = Self::default();
25 + cfg.journal.journal_dir = backend_dir
26 + .to_str()
27 + .with_context(|| {
28 + format!(
29 + "netflow test backend directory {} is not valid UTF-8",
30 + backend_dir.display()
31 + )
32 + })?
33 + .to_string();
34 + cfg.validate()?;
35 + Ok(cfg)
36 + }
37 +
38 pub(super) fn auto_detect_geoip_databases(&mut self) {
39 let intel_dirs = [
40 self.inferred_cache_dir().join(TOPOLOGY_IP_INTEL_DIR),
src/crates/netflow-plugin/src/test_cli.rs new
+621
@@ -0,0 +1,621 @@
1 +use crate::api::{FLOWS_FUNCTION_NAME, FlowsFunctionResponse, NetflowFlowsHandler};
2 +use crate::{facet_runtime, ingest, plugin_config, query};
3 +use anyhow::{Context, Result, bail};
4 +use rt::ProgressState;
5 +use std::ffi::{OsStr, OsString};
6 +use std::io::{self, Read, Write};
7 +use std::path::PathBuf;
8 +use std::sync::Arc;
9 +use std::time::Duration;
10 +use tokio_util::sync::CancellationToken;
11 +
12 +const USAGE: &str = "usage: netflow-plugin --test flows:netflow --dir <flows-dir> [--timeout <seconds>] [--no-persist] < payload.json";
13 +const DEFAULT_TIMEOUT_SECONDS: u64 = 30;
14 +const DISABLED_TIMEOUT_SECONDS: u64 = 100 * 365 * 24 * 60 * 60;
15 +const MAX_REQUEST_BYTES: u64 = 16 * 1024 * 1024;
16 +
17 +#[derive(Debug, Clone, PartialEq, Eq)]
18 +pub(crate) struct TestCommand {
19 + pub(crate) function_name: String,
20 + pub(crate) backend_dir: PathBuf,
21 + pub(crate) timeout_seconds: u64,
22 + pub(crate) no_persist: bool,
23 +}
24 +
25 +impl TestCommand {
26 + pub(crate) fn parse_from_env_args() -> std::result::Result<Option<Self>, String> {
27 + parse_from_os(std::env::args_os().skip(1))
28 + }
29 +}
30 +
31 +pub(crate) async fn run(command: TestCommand) -> Result<()> {
32 + let request_bytes = read_request_payload_from_stdin(io::stdin().lock())?;
33 + let response = execute(command, &request_bytes).await?;
34 + let stdout = io::stdout();
35 + let mut handle = stdout.lock();
36 + write_json_response(&response, &mut handle)?;
37 + ensure_success_status(response.status())
38 +}
39 +
40 +pub(crate) async fn execute(
41 + command: TestCommand,
42 + request_bytes: &[u8],
43 +) -> Result<FlowsFunctionResponse> {
44 + if command.function_name != FLOWS_FUNCTION_NAME {
45 + bail!(
46 + "unsupported netflow test function `{}` (expected `{}`)",
47 + command.function_name,
48 + FLOWS_FUNCTION_NAME
49 + );
50 + }
51 +
52 + let effective_timeout = effective_timeout_seconds(command.timeout_seconds);
53 + let cancellation = CancellationToken::new();
54 + match tokio::time::timeout(
55 + Duration::from_secs(effective_timeout),
56 + execute_inner(command, request_bytes, cancellation.clone()),
57 + )
58 + .await
59 + {
60 + Ok(result) => result,
61 + Err(_) => {
62 + cancellation.cancel();
63 + bail!(
64 + "netflow test function timed out after {} seconds",
65 + effective_timeout
66 + );
67 + }
68 + }
69 +}
70 +
71 +async fn execute_inner(
72 + command: TestCommand,
73 + request_bytes: &[u8],
74 + cancellation: CancellationToken,
75 +) -> Result<FlowsFunctionResponse> {
76 + let request = serde_json::from_slice::<query::FlowsRequest>(request_bytes)
77 + .context("failed to parse request payload from stdin")?;
78 +
79 + let config = plugin_config::PluginConfig::for_test_backend_dir(&command.backend_dir)?;
80 + ensure_tier_directories_exist(&config)?;
81 +
82 + let facet_runtime = if command.no_persist {
83 + facet_runtime::FacetRuntime::new_read_only(&config.journal.base_dir())
84 + } else {
85 + facet_runtime::FacetRuntime::new(&config.journal.base_dir())
86 + };
87 + let facet_runtime = Arc::new(facet_runtime);
88 + let (query_service, _notify_rx) =
89 + query::FlowQueryService::new_with_facet_runtime(&config, Arc::clone(&facet_runtime))
90 + .await?;
91 + let query_service = Arc::new(query_service);
92 + query_service.initialize_facets().await?;
93 +
94 + let handler = NetflowFlowsHandler::new(
95 + Arc::new(ingest::IngestMetrics::default()),
96 + Arc::clone(&query_service),
97 + );
98 + let execution = if request.is_autocomplete_mode() {
99 + None
100 + } else {
101 + Some(query::QueryExecutionContext::new(
102 + ProgressState::default(),
103 + cancellation.clone(),
104 + ))
105 + };
106 +
107 + handler
108 + .handle_request_with_execution(execution, request)
109 + .await
110 + .map_err(Into::into)
111 +}
112 +
113 +pub(crate) fn write_json_response(
114 + response: &FlowsFunctionResponse,
115 + writer: &mut impl Write,
116 +) -> Result<()> {
117 + serde_json::to_writer(&mut *writer, response).context("failed to write JSON response")?;
118 + writer
119 + .write_all(b"\n")
120 + .context("failed to finish JSON response")?;
121 + writer.flush().context("failed to flush JSON response")
122 +}
123 +
124 +fn ensure_tier_directories_exist(config: &plugin_config::PluginConfig) -> Result<()> {
125 + for tier_dir in config.journal.all_tier_dirs() {
126 + if !tier_dir.is_dir() {
127 + bail!(
128 + "netflow backend tier directory {} does not exist",
129 + tier_dir.display()
130 + );
131 + }
132 + }
133 + Ok(())
134 +}
135 +
136 +fn effective_timeout_seconds(timeout_seconds: u64) -> u64 {
137 + if timeout_seconds == 0 {
138 + DISABLED_TIMEOUT_SECONDS
139 + } else {
140 + timeout_seconds
141 + }
142 +}
143 +
144 +fn ensure_success_status(status: u32) -> Result<()> {
145 + if !(200..300).contains(&status) {
146 + bail!("netflow test function returned status {status}");
147 + }
148 +
149 + Ok(())
150 +}
151 +
152 +fn read_request_payload_from_stdin(reader: impl Read) -> Result<Vec<u8>> {
153 + let mut request_bytes = Vec::new();
154 + reader
155 + .take(MAX_REQUEST_BYTES + 1)
156 + .read_to_end(&mut request_bytes)
157 + .context("failed to read request payload from stdin")?;
158 +
159 + if request_bytes.is_empty() {
160 + bail!("request payload from stdin is empty");
161 + }
162 +
163 + if request_bytes.len() as u64 > MAX_REQUEST_BYTES {
164 + bail!(
165 + "request payload from stdin is too large: more than {} bytes",
166 + MAX_REQUEST_BYTES
167 + );
168 + }
169 +
170 + Ok(request_bytes)
171 +}
172 +
173 +#[cfg(test)]
174 +fn parse_from(
175 + args: impl IntoIterator<Item = String>,
176 +) -> std::result::Result<Option<TestCommand>, String> {
177 + parse_from_os(args.into_iter().map(OsString::from))
178 +}
179 +
180 +fn parse_from_os(
181 + args: impl IntoIterator<Item = OsString>,
182 +) -> std::result::Result<Option<TestCommand>, String> {
183 + let args = args.into_iter().collect::<Vec<_>>();
184 + if !args
185 + .iter()
186 + .any(|arg| arg == OsStr::new("--test") || strip_os_prefix(arg, "--test=").is_some())
187 + {
188 + return Ok(None);
189 + }
190 +
191 + let mut function_name = None;
192 + let mut backend_dir = None;
193 + let mut timeout_seconds = None;
194 + let mut no_persist = false;
195 + let mut idx = 0;
196 +
197 + while idx < args.len() {
198 + let arg = &args[idx];
199 + if arg == OsStr::new("--test") {
200 + idx += 1;
201 + let value = args
202 + .get(idx)
203 + .ok_or_else(|| format!("missing value for --test\n{USAGE}"))?;
204 + set_once(
205 + &mut function_name,
206 + required_os_string_value(value, "--test")?,
207 + "--test",
208 + )?;
209 + } else if arg == OsStr::new("--dir") {
210 + idx += 1;
211 + let value = args
212 + .get(idx)
213 + .ok_or_else(|| format!("missing value for --dir\n{USAGE}"))?;
214 + set_once(
215 + &mut backend_dir,
216 + PathBuf::from(required_os_string_value(value, "--dir")?),
217 + "--dir",
218 + )?;
219 + } else if arg == OsStr::new("--request") {
220 + return Err(format!(
221 + "--request is no longer supported; pass the request payload on stdin\n{USAGE}"
222 + ));
223 + } else if arg == OsStr::new("--timeout") {
224 + idx += 1;
225 + let value = args
226 + .get(idx)
227 + .ok_or_else(|| format!("missing value for --timeout\n{USAGE}"))?;
228 + set_once(
229 + &mut timeout_seconds,
230 + parse_timeout_seconds(&required_os_string_value(value, "--timeout")?)?,
231 + "--timeout",
232 + )?;
233 + } else if arg == OsStr::new("--no-persist") {
234 + if no_persist {
235 + return Err(format!("duplicate --no-persist\n{USAGE}"));
236 + }
237 + no_persist = true;
238 + } else if let Some(value) = strip_os_prefix(arg, "--test=") {
239 + set_once(
240 + &mut function_name,
241 + required_os_string_value(&value, "--test")?,
242 + "--test",
243 + )?;
244 + } else if let Some(value) = strip_os_prefix(arg, "--dir=") {
245 + set_once(
246 + &mut backend_dir,
247 + PathBuf::from(required_os_string_value(&value, "--dir")?),
248 + "--dir",
249 + )?;
250 + } else if strip_os_prefix(arg, "--request=").is_some() {
251 + return Err(format!(
252 + "--request is no longer supported; pass the request payload on stdin\n{USAGE}"
253 + ));
254 + } else if let Some(value) = strip_os_prefix(arg, "--timeout=") {
255 + set_once(
256 + &mut timeout_seconds,
257 + parse_timeout_seconds(&required_os_string_value(&value, "--timeout")?)?,
258 + "--timeout",
259 + )?;
260 + } else if arg == OsStr::new("-h") || arg == OsStr::new("--help") {
261 + return Err(USAGE.to_string());
262 + } else {
263 + return Err(format!(
264 + "unsupported netflow test option `{}`\n{USAGE}",
265 + arg.to_string_lossy()
266 + ));
267 + }
268 + idx += 1;
269 + }
270 +
271 + Ok(Some(TestCommand {
272 + function_name: required(function_name, "--test")?,
273 + backend_dir: required(backend_dir, "--dir")?,
274 + timeout_seconds: timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS),
275 + no_persist,
276 + }))
277 +}
278 +
279 +fn set_once<T>(slot: &mut Option<T>, value: T, option: &str) -> std::result::Result<(), String> {
280 + if slot.is_some() {
281 + return Err(format!("duplicate {option}\n{USAGE}"));
282 + }
283 + *slot = Some(value);
284 + Ok(())
285 +}
286 +
287 +fn required_os_option_value<'a>(
288 + value: &'a OsStr,
289 + option: &str,
290 +) -> std::result::Result<&'a OsStr, String> {
291 + if value.is_empty() {
292 + return Err(format!("missing value for {option}\n{USAGE}"));
293 + }
294 +
295 + Ok(value)
296 +}
297 +
298 +fn required_os_string_value(value: &OsStr, option: &str) -> std::result::Result<String, String> {
299 + let value = required_os_option_value(value, option)?;
300 + value
301 + .to_str()
302 + .map(ToString::to_string)
303 + .ok_or_else(|| format!("invalid value for {option}; expected UTF-8\n{USAGE}"))
304 +}
305 +
306 +fn required<T>(slot: Option<T>, option: &str) -> std::result::Result<T, String> {
307 + slot.ok_or_else(|| format!("missing required {option}\n{USAGE}"))
308 +}
309 +
310 +fn parse_timeout_seconds(value: &str) -> std::result::Result<u64, String> {
311 + value
312 + .parse::<u64>()
313 + .map_err(|_| format!("invalid value for --timeout `{value}`; expected seconds\n{USAGE}"))
314 +}
315 +
316 +#[cfg(unix)]
317 +fn strip_os_prefix(value: &OsStr, prefix: &str) -> Option<OsString> {
318 + use std::os::unix::ffi::{OsStrExt, OsStringExt};
319 +
320 + value
321 + .as_bytes()
322 + .strip_prefix(prefix.as_bytes())
323 + .map(|suffix| OsString::from_vec(suffix.to_vec()))
324 +}
325 +
326 +#[cfg(not(unix))]
327 +fn strip_os_prefix(value: &OsStr, prefix: &str) -> Option<OsString> {
328 + value.to_str()?.strip_prefix(prefix).map(OsString::from)
329 +}
330 +
331 +#[cfg(test)]
332 +mod tests {
333 + use super::*;
334 + use std::fs;
335 +
336 + fn parse(args: &[&str]) -> std::result::Result<Option<TestCommand>, String> {
337 + parse_from(args.iter().map(|arg| (*arg).to_string()))
338 + }
339 +
340 + #[test]
341 + fn parser_ignores_normal_plugin_arguments_when_test_is_absent() {
342 + assert_eq!(parse(&["--netflow-enabled", "false"]).unwrap(), None);
343 + }
344 +
345 + #[cfg(unix)]
346 + #[test]
347 + fn parser_ignores_non_utf8_normal_arguments_when_test_is_absent() {
348 + use std::ffi::OsString;
349 + use std::os::unix::ffi::OsStringExt;
350 +
351 + let args = [
352 + OsString::from("--netflow-enabled"),
353 + OsString::from_vec(vec![0xff]),
354 + ];
355 +
356 + assert_eq!(parse_from_os(args).unwrap(), None);
357 + }
358 +
359 + #[cfg(unix)]
360 + #[test]
361 + fn parser_rejects_non_utf8_spaced_backend_dir() {
362 + use std::ffi::OsString;
363 + use std::os::unix::ffi::OsStringExt;
364 +
365 + let err = parse_from_os([
366 + OsString::from("--test"),
367 + OsString::from("flows:netflow"),
368 + OsString::from("--dir"),
369 + OsString::from_vec(b"flows-\xff".to_vec()),
370 + ])
371 + .expect_err("non-UTF-8 backend directory should fail");
372 +
373 + assert!(err.contains("invalid value for --dir; expected UTF-8"));
374 + }
375 +
376 + #[cfg(unix)]
377 + #[test]
378 + fn parser_rejects_non_utf8_equals_backend_dir() {
379 + use std::ffi::OsString;
380 + use std::os::unix::ffi::OsStringExt;
381 +
382 + let mut dir_arg = b"--dir=".to_vec();
383 + dir_arg.extend_from_slice(b"flows-\xff");
384 +
385 + let err = parse_from_os([
386 + OsString::from("--test=flows:netflow"),
387 + OsString::from_vec(dir_arg),
388 + ])
389 + .expect_err("non-UTF-8 backend directory should fail");
390 +
391 + assert!(err.contains("invalid value for --dir; expected UTF-8"));
392 + }
393 +
394 + #[test]
395 + fn parser_accepts_stdin_test_command() {
396 + let command = parse(&["--test", "flows:netflow", "--dir", "flows", "--no-persist"])
397 + .unwrap()
398 + .expect("test command");
399 +
400 + assert_eq!(command.function_name, "flows:netflow");
401 + assert_eq!(command.backend_dir, PathBuf::from("flows"));
402 + assert_eq!(command.timeout_seconds, DEFAULT_TIMEOUT_SECONDS);
403 + assert!(command.no_persist);
404 + }
405 +
406 + #[test]
407 + fn parser_accepts_equals_form() {
408 + let command = parse(&["--test=flows:netflow", "--dir=flows", "--timeout=0"])
409 + .unwrap()
410 + .expect("test command");
411 +
412 + assert_eq!(command.function_name, "flows:netflow");
413 + assert_eq!(command.backend_dir, PathBuf::from("flows"));
414 + assert_eq!(command.timeout_seconds, 0);
415 + assert_eq!(
416 + effective_timeout_seconds(command.timeout_seconds),
417 + DISABLED_TIMEOUT_SECONDS
418 + );
419 + assert!(!command.no_persist);
420 + }
421 +
422 + #[test]
423 + fn parser_accepts_spaced_timeout() {
424 + let command = parse(&[
425 + "--test",
426 + "flows:netflow",
427 + "--dir",
428 + "flows",
429 + "--timeout",
430 + "120",
431 + ])
432 + .unwrap()
433 + .expect("test command");
434 +
435 + assert_eq!(command.timeout_seconds, 120);
436 + assert_eq!(effective_timeout_seconds(command.timeout_seconds), 120);
437 + }
438 +
439 + #[test]
440 + fn parser_rejects_missing_required_options() {
441 + let err = parse(&["--test", "flows:netflow"])
442 + .expect_err("missing options should fail")
443 + .to_string();
444 + assert!(err.contains("missing required --dir"));
445 + }
446 +
447 + #[test]
448 + fn parser_rejects_empty_required_values() {
449 + for args in [
450 + ["--test=", "--dir=flows"].as_slice(),
451 + ["--test", "", "--dir=flows"].as_slice(),
452 + ["--test=flows:netflow", "--dir="].as_slice(),
453 + ["--test=flows:netflow", "--dir", ""].as_slice(),
454 + ] {
455 + let err = parse(args).expect_err("empty required option should fail");
456 + assert!(err.contains("missing value for --"));
457 + }
458 + }
459 +
460 + #[test]
461 + fn parser_rejects_request_option() {
462 + for args in [
463 + [
464 + "--test=flows:netflow",
465 + "--dir=flows",
466 + "--request=payload.json",
467 + ]
468 + .as_slice(),
469 + [
470 + "--test=flows:netflow",
471 + "--dir=flows",
472 + "--request",
473 + "payload.json",
474 + ]
475 + .as_slice(),
476 + ] {
477 + let err = parse(args).expect_err("--request should fail");
478 + assert!(err.contains("--request is no longer supported"));
479 + }
480 + }
481 +
482 + #[test]
483 + fn parser_rejects_unknown_test_options() {
484 + let err = parse(&["--test", "flows:netflow", "--dir", "flows", "--unknown"])
485 + .expect_err("unknown option should fail");
486 +
487 + assert!(err.contains("unsupported netflow test option"));
488 + }
489 +
490 + #[test]
491 + fn parser_rejects_invalid_timeout() {
492 + let err = parse(&[
493 + "--test",
494 + "flows:netflow",
495 + "--dir",
496 + "flows",
497 + "--timeout",
498 + "-1",
499 + ])
500 + .expect_err("invalid timeout should fail");
501 +
502 + assert!(err.contains("invalid value for --timeout"));
503 + }
504 +
505 + #[test]
506 + fn parser_rejects_duplicate_timeout() {
507 + let err = parse(&[
508 + "--test",
509 + "flows:netflow",
510 + "--dir",
511 + "flows",
512 + "--timeout",
513 + "30",
514 + "--timeout=60",
515 + ])
516 + .expect_err("duplicate timeout should fail");
517 +
518 + assert!(err.contains("duplicate --timeout"));
519 + }
520 +
521 + #[test]
522 + fn success_status_accepts_2xx() {
523 + ensure_success_status(200).expect("200 should pass");
524 + ensure_success_status(299).expect("299 should pass");
525 + }
526 +
527 + #[test]
528 + fn success_status_rejects_non_2xx() {
529 + let err = ensure_success_status(400).expect_err("400 should fail");
530 + assert!(err.to_string().contains("returned status 400"));
531 + }
532 +
533 + #[test]
534 + fn read_request_payload_from_stdin_rejects_empty_input() {
535 + let err = read_request_payload_from_stdin(io::Cursor::new(Vec::new()))
536 + .expect_err("empty request should fail");
537 + assert!(err.to_string().contains("is empty"));
538 + }
539 +
540 + #[test]
541 + fn read_request_payload_from_stdin_rejects_oversized_input() {
542 + let oversized = vec![b' '; (MAX_REQUEST_BYTES + 1) as usize];
543 + let err = read_request_payload_from_stdin(io::Cursor::new(oversized))
544 + .expect_err("oversized request should fail");
545 + assert!(err.to_string().contains("is too large"));
546 + }
547 +
548 + #[test]
549 + fn read_request_payload_from_stdin_reads_payload() {
550 + let request = read_request_payload_from_stdin(io::Cursor::new(br#"{"after":1}"#))
551 + .expect("read request payload");
552 + assert_eq!(request, br#"{"after":1}"#);
553 + }
554 +
555 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
556 + async fn execute_empty_backend_writes_raw_json_without_persisting_facets() {
557 + let tmp = tempfile::tempdir().expect("create temp dir");
558 + let backend_dir = tmp.path().join("flows");
559 + for tier in ["raw", "1m", "5m", "1h"] {
560 + fs::create_dir_all(backend_dir.join(tier)).expect("create tier dir");
561 + }
562 +
563 + let response = execute(
564 + TestCommand {
565 + function_name: FLOWS_FUNCTION_NAME.to_string(),
566 + backend_dir: backend_dir.clone(),
567 + timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
568 + no_persist: true,
569 + },
570 + br#"{"after":1,"before":2,"group_by":["PROTOCOL"],"top_n":"100"}"#,
571 + )
572 + .await
573 + .expect("execute test CLI request");
574 +
575 + let mut stdout = Vec::new();
576 + write_json_response(&response, &mut stdout).expect("write JSON response");
577 + let output = String::from_utf8(stdout).expect("stdout should be UTF-8 JSON");
578 +
579 + assert!(
580 + output.trim_start().starts_with('{'),
581 + "test CLI stdout should start with JSON object, got {output:?}"
582 + );
583 + assert!(
584 + !output.starts_with("TRUST_DURATIONS"),
585 + "test CLI stdout must not include PLUGINSD protocol lines"
586 + );
587 +
588 + let value = serde_json::from_str::<serde_json::Value>(&output).expect("parse JSON output");
589 + assert_eq!(value["status"], 200);
590 + assert_eq!(value["type"], "flows");
591 + assert!(
592 + value["data"]["flows"].as_array().is_some_and(Vec::is_empty),
593 + "empty backend should return an empty flows array"
594 + );
595 + assert!(
596 + !backend_dir.join("facet-state.bin").exists(),
597 + "--no-persist must not write facet state under the fixture backend"
598 + );
599 + }
600 +
601 + #[cfg(unix)]
602 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
603 + async fn execute_rejects_non_utf8_backend_dir() {
604 + use std::ffi::OsString;
605 + use std::os::unix::ffi::OsStringExt;
606 +
607 + let err = execute(
608 + TestCommand {
609 + function_name: FLOWS_FUNCTION_NAME.to_string(),
610 + backend_dir: PathBuf::from(OsString::from_vec(b"flows-\xff".to_vec())),
611 + timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
612 + no_persist: true,
613 + },
614 + br#"{"after":1,"before":2,"group_by":["PROTOCOL"],"top_n":"100"}"#,
615 + )
616 + .await
617 + .expect_err("non-UTF-8 backend directory should fail");
618 +
619 + assert!(err.to_string().contains("is not valid UTF-8"));
620 + }
621 +}