Support environment variable expansion in host labels (#21796)
Allow ${VAR} and ${VAR:-default} syntax in [host labels] section of netdata.conf. Variables are resolved from the environment at label load/reload time. The raw ${VAR} template is preserved in the config store so netdata.conf regeneration keeps the original syntax. Includes unit tests covering all corner cases.
Costa Tsaousis committed
Feb 25, 2026 at 00:06 UTC
9a4c6a80ed3cc363d57576c1a50fd9f641198aea
4 files changed
+250
-3
docs/netdata-agent/configuration/organize-systems-metrics-and-alerts.md
+19
-2
@@ -143,13 +143,30 @@ Add your own labels to categorize systems by any criteria you need.
143
- Values cannot contain: `!` ` ` `'` `"` `*`
144
:::
145
146
-3. Enable your labels without restarting Netdata:
146
+3. You can use environment variables in label values:
147
+
148
+ ```text
149
+ [host labels]
150
+ region = ${REGION}
151
+ rack = ${RACK:-unknown}
152
+ env = ${DEPLOYMENT_ENV:-production}
153
+ location = ${DC}-${RACK:-default}
154
+ ```
155
+
156
+ | Syntax | Behavior |
157
+ |--------|----------|
158
+ | `${VAR}` | Replaced with the value of `VAR`. If unset or empty, the label value becomes `[none]` |
159
+ | `${VAR:-default}` | Replaced with the value of `VAR`. If unset or empty, uses `default` |
160
+
161
+ Environment variables are resolved when labels are loaded or reloaded. You can mix them with literal text (e.g., `${DC}-${RACK}`).
162
+
163
+4. Enable your labels without restarting Netdata:
164
165
```bash
166
netdatacli reload-labels
167
```
168
152
-4. Verify your labels at `http://HOST-IP:19999/api/v1/info`
169
+5. Verify your labels at `http://HOST-IP:19999/api/v1/info`
170
171
### Stream labels from Child to Parent
172
src/daemon/main.c
+1
@@ -403,6 +403,7 @@ int netdata_main(int argc, char **argv) {
403
if (dictionary_unittest(10000)) return 1;
404
if (aral_unittest(10000)) return 1;
405
if (rrdlabels_unittest()) return 1;
406
+ if (rrdhost_labels_unittest()) return 1;
407
if (ctx_unittest()) return 1;
408
if (uuid_unittest()) return 1;
409
if (dyncfg_unittest()) return 1;
src/database/rrdhost-labels.c
+229
-1
@@ -16,8 +16,86 @@ void rrdhost_set_is_parent_label(void) {
16
}
17
}
18
19
+// expand ${VAR} and ${VAR:-default} patterns in src, writing result to dst
20
+static void env_expand_labels_value(const char *src, char *dst, size_t dst_size) {
21
+ if(!src || !dst || dst_size < 1) return;
22
+
23
+ const char *s = src;
24
+ char *d = dst;
25
+ char *end = dst + dst_size - 1;
26
+
27
+ while(*s && d < end) {
28
+ if(s[0] == '$' && s[1] == '{') {
29
+ const char *closing = strchr(s + 2, '}');
30
+ if(!closing) {
31
+ // no closing brace — copy rest literally
32
+ while(*s && d < end)
33
+ *d++ = *s++;
34
+ break;
35
+ }
36
+
37
+ size_t content_len = closing - (s + 2);
38
+ char *var_name = mallocz(content_len + 1);
39
+ memcpy(var_name, s + 2, content_len);
40
+ var_name[content_len] = '\0';
41
+
42
+ // check for :- default separator
43
+ char *default_val = NULL;
44
+ char *sep = strstr(var_name, ":-");
45
+ if(sep) {
46
+ *sep = '\0';
47
+ default_val = sep + 2;
48
+ }
49
+
50
+ const char *env_val = getenv(var_name);
51
+ const char *resolved;
52
+
53
+ if(env_val && *env_val)
54
+ resolved = env_val;
55
+ else if(default_val)
56
+ resolved = default_val;
57
+ else {
58
+ nd_log(NDLS_DAEMON, NDLP_WARNING,
59
+ "RRDLABEL: environment variable '%s' is not set and no default provided", var_name);
60
+ resolved = "";
61
+ }
62
+
63
+ size_t rlen = strlen(resolved);
64
+ size_t available = end - d;
65
+ size_t to_copy = rlen < available ? rlen : available;
66
+ memcpy(d, resolved, to_copy);
67
+ d += to_copy;
68
+
69
+ freez(var_name);
70
+ s = closing + 1;
71
+ }
72
+ else {
73
+ *d++ = *s++;
74
+ }
75
+ }
76
+
77
+ *d = '\0';
78
+}
79
+
80
+// check if value contains any ${...} pattern worth expanding
81
+static bool value_has_env_variables(const char *value) {
82
+ const char *p = value;
83
+ while((p = strchr(p, '$')) != NULL) {
84
+ if(p[1] == '{') return true;
85
+ p++;
86
+ }
87
+ return false;
88
+}
89
+
90
static bool config_label_cb(void *data __maybe_unused, const char *name, const char *value) {
20
- rrdlabels_add(localhost->rrdlabels, name, value, RRDLABEL_SRC_CONFIG);
91
+ if(value_has_env_variables(value)) {
92
+ char expanded[RRDLABELS_MAX_VALUE_LENGTH + 1];
93
+ env_expand_labels_value(value, expanded, sizeof(expanded));
94
+ rrdlabels_add(localhost->rrdlabels, name, expanded, RRDLABEL_SRC_CONFIG);
95
+ }
96
+ else
97
+ rrdlabels_add(localhost->rrdlabels, name, value, RRDLABEL_SRC_CONFIG);
98
+
99
return true;
100
}
101
@@ -103,3 +181,153 @@ void reload_host_labels(void) {
181
182
stream_send_host_labels(localhost);
183
}
184
+
185
+// ----------------------------------------------------------------------------
186
+// unit tests
187
+
188
+static int env_expand_unittest_check(const char *src, const char *expected, const char *test_name) {
189
+ char buf[RRDLABELS_MAX_VALUE_LENGTH + 1];
190
+ env_expand_labels_value(src, buf, sizeof(buf));
191
+
192
+ int err = strcmp(buf, expected) != 0;
193
+ fprintf(stderr, " env_expand(%s): %s, expected '%s', got '%s'\n",
194
+ test_name, err ? "FAILED" : "OK", expected, buf);
195
+ return err;
196
+}
197
+
198
+int rrdhost_labels_unittest(void) {
199
+ fprintf(stderr, "\n%s() tests\n", __FUNCTION__);
200
+ int errors = 0;
201
+
202
+ // --- set up test env vars ---
203
+ setenv("ND_TEST_VAR", "hello", 1);
204
+ setenv("ND_TEST_DC", "us-east", 1);
205
+ setenv("ND_TEST_RACK", "rack42", 1);
206
+ setenv("ND_TEST_EMPTY", "", 1);
207
+ unsetenv("ND_TEST_UNSET");
208
+
209
+ // no variables — pass through unchanged
210
+ errors += env_expand_unittest_check("plain value", "plain value", "plain text");
211
+ errors += env_expand_unittest_check("", "", "empty string");
212
+
213
+ // basic variable expansion
214
+ errors += env_expand_unittest_check("${ND_TEST_VAR}", "hello", "${VAR} set");
215
+ errors += env_expand_unittest_check("prefix-${ND_TEST_VAR}", "prefix-hello", "prefix + ${VAR}");
216
+ errors += env_expand_unittest_check("${ND_TEST_VAR}-suffix", "hello-suffix", "${VAR} + suffix");
217
+ errors += env_expand_unittest_check("pre-${ND_TEST_VAR}-post", "pre-hello-post", "prefix + ${VAR} + suffix");
218
+
219
+ // multiple variables
220
+ errors += env_expand_unittest_check("${ND_TEST_DC}-${ND_TEST_RACK}", "us-east-rack42", "two vars adjacent");
221
+ errors += env_expand_unittest_check("${ND_TEST_DC}/${ND_TEST_RACK}/${ND_TEST_VAR}", "us-east/rack42/hello", "three vars");
222
+ errors += env_expand_unittest_check("dc=${ND_TEST_DC} rack=${ND_TEST_RACK}", "dc=us-east rack=rack42", "vars with literal labels");
223
+
224
+ // default values — variable is set (default ignored)
225
+ errors += env_expand_unittest_check("${ND_TEST_VAR:-fallback}", "hello", "default ignored when var set");
226
+ errors += env_expand_unittest_check("${ND_TEST_DC:-other}", "us-east", "default ignored when var set (2)");
227
+
228
+ // default values — variable is unset
229
+ errors += env_expand_unittest_check("${ND_TEST_UNSET:-fallback}", "fallback", "default used when var unset");
230
+ errors += env_expand_unittest_check("pre-${ND_TEST_UNSET:-fallback}-post", "pre-fallback-post", "default with surrounding text");
231
+
232
+ // default values — variable is empty (treated same as unset)
233
+ errors += env_expand_unittest_check("${ND_TEST_EMPTY:-fallback}", "fallback", "default used when var empty");
234
+
235
+ // unset variable, no default — empty string
236
+ errors += env_expand_unittest_check("${ND_TEST_UNSET}", "", "unset var no default = empty");
237
+ errors += env_expand_unittest_check("pre-${ND_TEST_UNSET}-post", "pre--post", "unset var no default with text");
238
+
239
+ // empty default — should resolve to empty string
240
+ errors += env_expand_unittest_check("${ND_TEST_UNSET:-}", "", "empty default");
241
+ errors += env_expand_unittest_check("pre-${ND_TEST_UNSET:-}-post", "pre--post", "empty default with text");
242
+
243
+ // malformed syntax — no closing brace, copy literally
244
+ errors += env_expand_unittest_check("${ND_TEST_UNCLOSED", "${ND_TEST_UNCLOSED", "no closing brace");
245
+ errors += env_expand_unittest_check("pre-${ND_TEST_UNCLOSED", "pre-${ND_TEST_UNCLOSED", "no closing brace with prefix");
246
+
247
+ // dollar sign not followed by brace — literal
248
+ errors += env_expand_unittest_check("$notavar", "$notavar", "$ without {");
249
+ errors += env_expand_unittest_check("price is $5", "price is $5", "$ with digit");
250
+ errors += env_expand_unittest_check("$$", "$$", "double dollar");
251
+ errors += env_expand_unittest_check("$", "$", "lone dollar at end");
252
+
253
+ // empty variable name: ${} — getenv("") returns NULL, no default → empty
254
+ errors += env_expand_unittest_check("${}", "", "empty var name");
255
+ errors += env_expand_unittest_check("${:-fallback}", "fallback", "empty var name with default");
256
+
257
+ // default containing :- (only first :- is the separator)
258
+ errors += env_expand_unittest_check("${ND_TEST_UNSET:-a:-b}", "a:-b", "default containing :-");
259
+
260
+ // no recursive expansion — env value containing ${...} is NOT re-expanded
261
+ setenv("ND_TEST_NESTED", "${ND_TEST_VAR}", 1);
262
+ errors += env_expand_unittest_check("${ND_TEST_NESTED}", "${ND_TEST_VAR}", "no recursive expansion");
263
+
264
+ // default containing ${...} is NOT re-expanded
265
+ errors += env_expand_unittest_check("${ND_TEST_UNSET:-${ND_TEST_VAR}}", "${ND_TEST_VAR}", "no expansion in default");
266
+
267
+ // buffer overflow protection — expand into small buffer
268
+ {
269
+ char tiny[8];
270
+ env_expand_labels_value("${ND_TEST_DC}", tiny, sizeof(tiny));
271
+ // "us-east" is 7 chars, buffer is 8 (7+null) — should fit exactly
272
+ int err = strcmp(tiny, "us-east") != 0;
273
+ fprintf(stderr, " env_expand(small buffer exact fit): %s, expected 'us-east', got '%s'\n",
274
+ err ? "FAILED" : "OK", tiny);
275
+ errors += err;
276
+ }
277
+ {
278
+ char tiny[5];
279
+ env_expand_labels_value("${ND_TEST_DC}", tiny, sizeof(tiny));
280
+ // "us-east" is 7 chars, buffer is 5 (4+null) — should truncate to "us-e"
281
+ int err = strcmp(tiny, "us-e") != 0;
282
+ fprintf(stderr, " env_expand(small buffer truncation): %s, expected 'us-e', got '%s'\n",
283
+ err ? "FAILED" : "OK", tiny);
284
+ errors += err;
285
+ }
286
+ {
287
+ char tiny[5];
288
+ env_expand_labels_value("abcdefghij", tiny, sizeof(tiny));
289
+ // plain text truncation — should truncate to "abcd"
290
+ int err = strcmp(tiny, "abcd") != 0;
291
+ fprintf(stderr, " env_expand(plain text truncation): %s, expected 'abcd', got '%s'\n",
292
+ err ? "FAILED" : "OK", tiny);
293
+ errors += err;
294
+ }
295
+
296
+ // value_has_env_variables() tests
297
+ {
298
+ struct {
299
+ const char *input;
300
+ bool expected;
301
+ } detect_tests[] = {
302
+ { "plain", false },
303
+ { "", false },
304
+ { "$notvar", false },
305
+ { "$", false },
306
+ { "${VAR}", true },
307
+ { "pre${VAR}post", true },
308
+ { "${A}${B}", true },
309
+ { "$${}", true }, // second $ starts ${
310
+ { "${", true }, // has ${ even without closing }
311
+ { NULL, false }
312
+ };
313
+ for(int i = 0; detect_tests[i].input; i++) {
314
+ bool result = value_has_env_variables(detect_tests[i].input);
315
+ int err = result != detect_tests[i].expected;
316
+ fprintf(stderr, " value_has_env_variables('%s'): %s, expected %s, got %s\n",
317
+ detect_tests[i].input, err ? "FAILED" : "OK",
318
+ detect_tests[i].expected ? "true" : "false",
319
+ result ? "true" : "false");
320
+ errors += err;
321
+ }
322
+ }
323
+
324
+ // --- cleanup test env vars ---
325
+ unsetenv("ND_TEST_VAR");
326
+ unsetenv("ND_TEST_DC");
327
+ unsetenv("ND_TEST_RACK");
328
+ unsetenv("ND_TEST_EMPTY");
329
+ unsetenv("ND_TEST_NESTED");
330
+
331
+ fprintf(stderr, "%s: %d errors\n", __FUNCTION__, errors);
332
+ return errors;
333
+}
src/database/rrdhost-labels.h
+1
@@ -7,5 +7,6 @@
7
8
void reload_host_labels(void);
9
void rrdhost_set_is_parent_label(void);
10
+int rrdhost_labels_unittest(void);
11
12
#endif //NETDATA_RRDHOST_LABELS_H