@cryptotaxi247 / netdata-1 / commits / 9a4c6a80e

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