Repository: jenkinsci/configuration-as-code-plugin
Pull request #2665: Allow empty valued variables without ignoring enviroment vars substitution
Author: login: PereBueno
Created: 2025-04-09T14:19:47Z | Merged: 2025-04-17T11:43:24Z

<!-- Please describe your pull request here. -->
fixes: https://github.com/jenkinsci/configuration-as-code-plugin/issues/2658

Changes export behaviour for global environment variables. Now empty env vars will be exported as empty values instead of key without value as they were until now.
```
env:
  - key: "FOO"
    value: ""

```
No change when configuring, behaviour is the same as before this PR.

Additinally, added a test covering varlues with environment variable resolution.

### Your checklist for this pull request

🚨 Please review the [guidelines for contributing](../blob/master/docs/CONTRIBUTING.md) to this repository.

- [x] Make sure you are requesting to **pull a topic/feature/bugfix branch** (right side) and not your master branch!
- [x] Ensure that the pull request title represents the desired changelog entry
- [x] Please describe what you did
- [x] Link to relevant issues in GitHub or in [Jenkins JIRA](https://issues.jenkins-ci.org)
- [x] Link to relevant pull requests, esp. upstream and downstream changes
- [x] Did you provide a test-case? That demonstrates feature works or fixes the issue.

<!--
Put an `x` into the [ ] to show you have filled the information
-->



Diff:
diff --git a/integrations/src/test/java/io/jenkins/plugins/casc/GlobalNodePropertiesTest.java b/integrations/src/test/java/io/jenkins/plugins/casc/GlobalNodePropertiesTest.java
index ba91c2edea..c7ddab6fa4 100644
--- a/integrations/src/test/java/io/jenkins/plugins/casc/GlobalNodePropertiesTest.java
+++ b/integrations/src/test/java/io/jenkins/plugins/casc/GlobalNodePropertiesTest.java
@@ -7,15 +7,19 @@
 import static org.hamcrest.core.Is.is;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
+import hudson.node_monitors.DiskSpaceMonitorNodeProperty;
 import hudson.slaves.EnvironmentVariablesNodeProperty;
 import hudson.slaves.NodeProperty;
 import hudson.slaves.NodePropertyDescriptor;
+import hudson.tools.ToolLocationNodeProperty;
 import hudson.util.DescribableList;
 import io.jenkins.plugins.casc.misc.ConfiguredWithCode;
 import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule;
 import io.jenkins.plugins.casc.misc.junit.jupiter.WithJenkinsConfiguredWithCode;
 import io.jenkins.plugins.casc.model.CNode;
+import java.util.Iterator;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.Set;
 import jenkins.model.Jenkins;
 import org.junit.jupiter.api.Test;
@@ -30,14 +34,33 @@ void configure(JenkinsConfiguredWithCodeRule j) {
 
         DescribableList<NodeProperty<?>, NodePropertyDescriptor> nodeProperties = jenkins.getGlobalNodeProperties();
 
-        Set<Map.Entry<String, String>> entries = ((EnvironmentVariablesNodeProperty) nodeProperties.get(0))
+        assertEquals(3, nodeProperties.size());
+
+        Set<Map.Entry<String, String>> envVars = ((EnvironmentVariablesNodeProperty)
+                        nodeProperties.get(EnvironmentVariablesNodeProperty.class))
                 .getEnvVars()
                 .entrySet();
-        assertEquals(1, entries.size());
+        assertEquals(2, envVars.size());
 
-        Map.Entry<String, String> envVar = entries.iterator().next();
+        Iterator<Entry<String, String>> iterator = envVars.iterator();
+        Map.Entry<String, String> envVar = iterator.next();
         assertEquals("FOO", envVar.getKey());
         assertEquals("BAR", envVar.getValue());
+
+        envVar = iterator.next();
+        assertEquals("FOO2", envVar.getKey());
+        assertEquals("", envVar.getValue());
+
+        DiskSpaceMonitorNodeProperty diskSpace = nodeProperties.get(DiskSpaceMonitorNodeProperty.class);
+        assertEquals("1GiB", diskSpace.getFreeDiskSpaceThreshold());
+        assertEquals("2GiB", diskSpace.getFreeDiskSpaceWarningThreshold());
+        assertEquals("1GiB", diskSpace.getFreeTempSpaceThreshold());
+        assertEquals("2GiB", diskSpace.getFreeTempSpaceWarningThreshold());
+
+        ToolLocationNodeProperty toolLocations = nodeProperties.get(ToolLocationNodeProperty.class);
+        assertEquals(1, toolLocations.getLocations().size());
+        assertEquals("Default", toolLocations.getLocations().get(0).getName());
+        assertEquals("/home/user/bin/git", toolLocations.getLocations().get(0).getHome());
     }
 
     @Test
diff --git a/integrations/src/test/java/io/jenkins/plugins/casc/GlobalNodePropertiesWithEnvVarsTest.java b/integrations/src/test/java/io/jenkins/plugins/casc/GlobalNodePropertiesWithEnvVarsTest.java
new file mode 100644
index 0000000000..8cd2d150a9
--- /dev/null
+++ b/integrations/src/test/java/io/jenkins/plugins/casc/GlobalNodePropertiesWithEnvVarsTest.java
@@ -0,0 +1,64 @@
+package io.jenkins.plugins.casc;
+
+import static io.jenkins.plugins.casc.misc.Util.getJenkinsRoot;
+import static io.jenkins.plugins.casc.misc.Util.toStringFromYamlFile;
+import static io.jenkins.plugins.casc.misc.Util.toYamlString;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+
+import hudson.slaves.EnvironmentVariablesNodeProperty;
+import hudson.slaves.NodeProperty;
+import hudson.slaves.NodePropertyDescriptor;
+import hudson.tools.ToolLocationNodeProperty;
+import hudson.util.DescribableList;
+import io.jenkins.plugins.casc.misc.ConfiguredWithCode;
+import io.jenkins.plugins.casc.misc.Env;
+import io.jenkins.plugins.casc.misc.EnvVarsRule;
+import io.jenkins.plugins.casc.misc.Envs;
+import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule;
+import io.jenkins.plugins.casc.model.CNode;
+import java.util.Map;
+import org.hamcrest.core.Is;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.RuleChain;
+
+public class GlobalNodePropertiesWithEnvVarsTest {
+
+    private JenkinsConfiguredWithCodeRule j = new JenkinsConfiguredWithCodeRule();
+
+    @Rule
+    public RuleChain chain = RuleChain.outerRule(new EnvVarsRule()).around(j);
+
+    @Test
+    @ConfiguredWithCode("GlobalNodePropertiesWithEnvVarsTest.yml")
+    @Envs({@Env(name = "VALUE_1", value = "BAR"), @Env(name = "TEST_GIT_HOME", value = "git-home")})
+    public void configureWithEnvVarsTest() {
+        DescribableList<NodeProperty<?>, NodePropertyDescriptor> nodeProperties = j.jenkins.getGlobalNodeProperties();
+        Map<String, String> envVars = ((EnvironmentVariablesNodeProperty)
+                        nodeProperties.get(EnvironmentVariablesNodeProperty.class))
+                .getEnvVars();
+
+        assertThat(envVars.size(), is(2));
+        assertThat(envVars.get("FOO"), is("BAR"));
+        assertThat(envVars.get("FOO2"), is(""));
+
+        ToolLocationNodeProperty toolLocations = nodeProperties.get(ToolLocationNodeProperty.class);
+        assertThat(toolLocations.getLocations(), hasSize(1));
+        assertThat(toolLocations.getLocations().get(0).getHome(), is("git-home"));
+    }
+
+    @Test
+    @ConfiguredWithCode("GlobalNodePropertiesWithEnvVarsTest.yml")
+    @Envs({@Env(name = "VALUE_1", value = "BAR"), @Env(name = "TEST_GIT_HOME", value = "git-home")})
+    public void export() throws Exception {
+        ConfiguratorRegistry registry = ConfiguratorRegistry.get();
+        ConfigurationContext context = new ConfigurationContext(registry);
+        CNode yourAttribute = getJenkinsRoot(context).get("globalNodeProperties");
+
+        String exported = toYamlString(yourAttribute);
+        String expected = toStringFromYamlFile(this, "GlobalNodePropertiesWithEnvVarsTestExpected.yml");
+        assertThat(exported, Is.is(expected));
+    }
+}
diff --git a/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml b/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
index c9eeb3aaf9..4bc62aa9a5 100644
--- a/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
+++ b/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
@@ -1,6 +1,17 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+    - toolLocation:
+        locations:
+          - home: "/home/user/bin/git"
+            key: "hudson.plugins.git.GitTool$DescriptorImpl@Default"
\ No newline at end of file
diff --git a/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTestExpected.yml b/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTestExpected.yml
index e02c4d039a..59693d6d09 100644
--- a/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTestExpected.yml
+++ b/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTestExpected.yml
@@ -1,4 +1,15 @@
+- diskSpaceMonitor:
+    freeDiskSpaceThreshold: "1GiB"
+    freeDiskSpaceWarningThreshold: "2GiB"
+    freeTempSpaceThreshold: "1GiB"
+    freeTempSpaceWarningThreshold: "2GiB"
 - envVars:
     env:
     - key: "FOO"
       value: "BAR"
+    - key: "FOO2"
+      value: ""
+- toolLocation:
+    locations:
+    - home: "/home/user/bin/git"
+      key: "hudson.plugins.git.GitTool$DescriptorImpl@Default"
diff --git a/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesWithEnvVarsTest.yml b/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesWithEnvVarsTest.yml
new file mode 100644
index 0000000000..9e965cca14
--- /dev/null
+++ b/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesWithEnvVarsTest.yml
@@ -0,0 +1,12 @@
+jenkins:
+  globalNodeProperties:
+    - envVars:
+        env:
+          - key: FOO
+            value: ${VALUE_1}
+          - key: FOO2
+            value: ${VALUE_2}
+    - toolLocation:
+        locations:
+          - home: "${TEST_GIT_HOME}"
+            key: "hudson.plugins.git.GitTool$DescriptorImpl@Default"
\ No newline at end of file
diff --git a/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesWithEnvVarsTestExpected.yml b/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesWithEnvVarsTestExpected.yml
new file mode 100644
index 0000000000..56d395429d
--- /dev/null
+++ b/integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesWithEnvVarsTestExpected.yml
@@ -0,0 +1,10 @@
+- envVars:
+    env:
+    - key: "FOO"
+      value: "BAR"
+    - key: "FOO2"
+      value: ""
+- toolLocation:
+    locations:
+    - home: "git-home"
+      key: "hudson.plugins.git.GitTool$DescriptorImpl@Default"
diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java
index 3e383cabbc..27f2cd0039 100644
--- a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java
+++ b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java
@@ -657,7 +657,7 @@ public Node toYaml(CNode config) throws ConfiguratorException {
             default:
                 final Scalar scalar = config.asScalar();
                 final String value = scalar.getValue();
-                if (value == null || value.length() == 0) {
+                if (StringUtils.isBlank(value) && !scalar.isPrintableWhenEmpty()) {
                     return null;
                 }
 
diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/core/GlobalNodePropertiesConfigurator.java b/plugin/src/main/java/io/jenkins/plugins/casc/core/GlobalNodePropertiesConfigurator.java
new file mode 100644
index 0000000000..c4fec5104b
--- /dev/null
+++ b/plugin/src/main/java/io/jenkins/plugins/casc/core/GlobalNodePropertiesConfigurator.java
@@ -0,0 +1,57 @@
+package io.jenkins.plugins.casc.core;
+
+import edu.umd.cs.findbugs.annotations.CheckForNull;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.Extension;
+import hudson.slaves.EnvironmentVariablesNodeProperty;
+import io.jenkins.plugins.casc.Attribute;
+import io.jenkins.plugins.casc.ConfigurationContext;
+import io.jenkins.plugins.casc.ConfiguratorException;
+import io.jenkins.plugins.casc.impl.configurators.DataBoundConfigurator;
+import io.jenkins.plugins.casc.model.CNode;
+import io.jenkins.plugins.casc.model.Mapping;
+
+@Extension
+public class GlobalNodePropertiesConfigurator extends DataBoundConfigurator<EnvironmentVariablesNodeProperty> {
+
+    public GlobalNodePropertiesConfigurator() {
+        this(EnvironmentVariablesNodeProperty.class);
+    }
+
+    public GlobalNodePropertiesConfigurator(Class<?> clazz) {
+        super(EnvironmentVariablesNodeProperty.class);
+    }
+
+    @NonNull
+    @Override
+    public String getName() {
+        return "globalNodeProperties";
+    }
+
+    @NonNull
+    @Override
+    public EnvironmentVariablesNodeProperty configure(CNode c, ConfigurationContext context)
+            throws ConfiguratorException {
+        return super.configure(c, context);
+    }
+
+    @Override
+    @CheckForNull
+    public CNode describe(EnvironmentVariablesNodeProperty instance, ConfigurationContext context) throws Exception {
+        Mapping mapping = new Mapping();
+        for (Attribute attribute : getAttributes()) {
+            CNode value = attribute.describe(instance, context);
+            if (value != null) {
+                // Making sure empty variables are part of the export
+                value.asSequence().forEach(entry -> {
+                    if (entry.asMapping().get("key") != null
+                            && entry.asMapping().get("value") != null) {
+                        entry.asMapping().get("value").asScalar().setPrintableWhenEmpty(true);
+                    }
+                });
+                mapping.put(attribute.getName(), value);
+            }
+        }
+        return mapping;
+    }
+}
diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/model/CNode.java b/plugin/src/main/java/io/jenkins/plugins/casc/model/CNode.java
index 35f64dc867..9d7f38d064 100644
--- a/plugin/src/main/java/io/jenkins/plugins/casc/model/CNode.java
+++ b/plugin/src/main/java/io/jenkins/plugins/casc/model/CNode.java
@@ -35,6 +35,14 @@ default boolean isSensitiveData() {
         return false;
     }
 
+    /**
+     * Indicates if the field should be included when describing even if empty
+     * @return false by default
+     */
+    default boolean isPrintableWhenEmpty() {
+        return false;
+    }
+
     /**
      * Indicate the source (file, line number) this specific configuration node comes from.
      * This is used to offer relevant diagnostic messages
diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/model/Scalar.java b/plugin/src/main/java/io/jenkins/plugins/casc/model/Scalar.java
index 14787f6321..8ba6641674 100644
--- a/plugin/src/main/java/io/jenkins/plugins/casc/model/Scalar.java
+++ b/plugin/src/main/java/io/jenkins/plugins/casc/model/Scalar.java
@@ -16,6 +16,7 @@ public final class Scalar implements CNode, CharSequence {
     private Source source;
     private boolean sensitive;
     private boolean encrypted;
+    private boolean printableWhenEmpty = false;
 
     public enum Format {
         STRING,
@@ -163,6 +164,15 @@ public Source getSource() {
         return source;
     }
 
+    @Override
+    public boolean isPrintableWhenEmpty() {
+        return printableWhenEmpty;
+    }
+
+    public void setPrintableWhenEmpty(boolean print) {
+        this.printableWhenEmpty = print;
+    }
+
     @Override
     public CNode clone() {
         return new Scalar(this);
@@ -175,5 +185,6 @@ private Scalar(Scalar it) {
         this.source = it.source;
         this.sensitive = it.sensitive;
         this.encrypted = it.encrypted;
+        this.printableWhenEmpty = it.printableWhenEmpty;
     }
 }


Reviews:
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T14:25:09Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T14:26:15Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T14:26:26Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T14:26:39Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T14:28:33Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T14:29:38Z
user: login: PereBueno
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T15:12:49Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T11:12:39Z
user: login: PereBueno
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T15:55:22Z
user: login: timja
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T18:43:52Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T19:05:34Z
user: login: timja
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T19:37:10Z
user: login: PereBueno
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T10:34:52Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T11:57:10Z
user: login: timja
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-12T14:25:01Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T13:19:27Z
user: login: timja
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T13:25:04Z
user: login: PereBueno
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T15:07:51Z
user: login: timja
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T15:50:29Z
user: login: PereBueno
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T08:28:30Z
user: login: timja
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-15T09:31:24Z
user: login: jmdesprez
state: APPROVED
author_association: NONE
submitted_at: 2025-04-17T11:37:04Z

Review comments:
diff_hunk: @@ -0,0 +1,61 @@
+package io.jenkins.plugins.casc.core;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.Extension;
+import hudson.slaves.EnvironmentVariablesNodeProperty;
+import hudson.slaves.EnvironmentVariablesNodeProperty.Entry;
+import io.jenkins.plugins.casc.ConfigurationContext;
+import io.jenkins.plugins.casc.ConfiguratorException;
+import io.jenkins.plugins.casc.impl.configurators.DataBoundConfigurator;
+import io.jenkins.plugins.casc.model.CNode;
+import io.jenkins.plugins.casc.model.Mapping;
+import java.util.ArrayList;
+import java.util.List;
+
+@Extension
+public class GlobalNodePropertiesConfigurator extends DataBoundConfigurator<EnvironmentVariablesNodeProperty> {
+
+    public GlobalNodePropertiesConfigurator() {
+        this(EnvironmentVariablesNodeProperty.class);
+    }
+
+    public GlobalNodePropertiesConfigurator(Class<?> clazz) {
+        super(EnvironmentVariablesNodeProperty.class);
+    }
+
+    @NonNull
+    @Override
+    public String getName() {
+        return "globalNodeProperties";
+    }
+
+    @NonNull
+    @Override
+    public EnvironmentVariablesNodeProperty configure(CNode c, ConfigurationContext context)
+            throws ConfiguratorException {
+        Mapping mapping = c.asMapping();
+        List<Entry> variables = getVarsAsList(mapping, context);
+        return new EnvironmentVariablesNodeProperty(variables);
+    }
+
+    private List<Entry> getVarsAsList(Mapping m, ConfigurationContext context) {
+        List<Entry> result = new ArrayList<>();
+        if (m.get("env") != null) {
+            result = m.get("env").asSequence().stream()
+                    .map(pair -> {
+                        if (pair.asMapping().get("key") == null) {
+                            return null;
+                        }
+                        final String key =
+                                pair.asMapping().get("key").asScalar().getValue();
+                        final String value = pair.asMapping().get("value") == null
+                                ? ""
path: plugin/src/main/java/io/jenkins/plugins/casc/core/GlobalNodePropertiesConfigurator.java
user: login: jglick
body: Why would we be allowing null here?
created_at: 2025-04-09T14:25:09Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: jglick
body: This (`FOO3`) just looks like a user error to me ⇒ the bundle should be rejected.
created_at: 2025-04-09T14:26:15Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,15 @@
+- diskSpaceMonitor:
+    freeDiskSpaceThreshold: "1GiB"
+    freeDiskSpaceWarningThreshold: "2GiB"
+    freeTempSpaceThreshold: "1GiB"
+    freeTempSpaceWarningThreshold: "2GiB"
 - envVars:
     env:
     - key: "FOO"
       value: "BAR"
+    - key: "FOO2"
+    - key: "FOO3"
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTestExpected.yml
user: login: jglick
body: ditto
created_at: 2025-04-09T14:26:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,9 @@
+- envVars:
+    env:
+    - key: "FOO"
+      value: "BAR"
+    - key: "FOO2"
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesWithEnvVarsTestExpected.yml
user: login: jglick
body: ditto (`FOO2`)
created_at: 2025-04-09T14:26:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,65 @@
+package io.jenkins.plugins.casc.core;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.Extension;
+import hudson.slaves.EnvironmentVariablesNodeProperty;
+import hudson.slaves.EnvironmentVariablesNodeProperty.Entry;
+import io.jenkins.plugins.casc.ConfigurationContext;
+import io.jenkins.plugins.casc.ConfiguratorException;
+import io.jenkins.plugins.casc.impl.configurators.DataBoundConfigurator;
+import io.jenkins.plugins.casc.model.CNode;
+import io.jenkins.plugins.casc.model.Mapping;
+import java.util.ArrayList;
+import java.util.List;
+
+@Extension
+public class GlobalNodePropertiesConfigurator extends DataBoundConfigurator<EnvironmentVariablesNodeProperty> {
+
+    public GlobalNodePropertiesConfigurator() {
+        this(EnvironmentVariablesNodeProperty.class);
+    }
+
+    public GlobalNodePropertiesConfigurator(Class<?> clazz) {
+        super(EnvironmentVariablesNodeProperty.class);
+    }
+
+    @NonNull
+    @Override
+    public String getName() {
+        return "globalNodeProperties";
+    }
+
+    @NonNull
+    @Override
+    public EnvironmentVariablesNodeProperty configure(CNode c, ConfigurationContext context)
+            throws ConfiguratorException {
+        Mapping mapping = c.asMapping();
+        List<Entry> variables = getVarsAsList(mapping, context);
+        return new EnvironmentVariablesNodeProperty(variables);
+    }
+
+    private List<Entry> getVarsAsList(Mapping m, ConfigurationContext context) {
+        List<Entry> result = new ArrayList<>();
+        if (m.get("env") != null) {
+            result = m.get("env").asSequence().stream()
+                    .map(pair -> {
+                        if (pair.asMapping().get("key") == null) {
+                            return null;
+                        }
path: plugin/src/main/java/io/jenkins/plugins/casc/core/GlobalNodePropertiesConfigurator.java
user: login: jglick
body: Similarly, why would we allow this? Both `key` and `value` are mandatory.
created_at: 2025-04-09T14:28:32Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,15 @@
+- diskSpaceMonitor:
+    freeDiskSpaceThreshold: "1GiB"
+    freeDiskSpaceWarningThreshold: "2GiB"
+    freeTempSpaceThreshold: "1GiB"
+    freeTempSpaceWarningThreshold: "2GiB"
 - envVars:
     env:
     - key: "FOO"
       value: "BAR"
+    - key: "FOO2"
+    - key: "FOO3"
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTestExpected.yml
user: login: jglick
body: If the variables are in fact defined, with empty values, I would expect
```suggestion
    - key: "FOO2"
      value: ""
    - key: "FOO3"
      value: ""
```
created_at: 2025-04-09T14:29:35Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: PereBueno
body: UI allows to save an empty env var (regardless why would someone want that), so theoretically configuration as code should allow creating that configuration too.
created_at: 2025-04-09T15:12:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: jglick
body: I am talking about `FOO3` (null) not `FOO2` (empty).
created_at: 2025-04-10T11:12:38Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: PereBueno
body: That's the point in allowing importing the exported yaml. 
Empty vars will still be exported with no value, so it's either allowing keys without a value when importing or changing the export (which was discussed [here](https://github.com/jenkinsci/configuration-as-code-plugin/pull/2657#discussion_r2032720353)). 
I think we all agree that would be the optimal solution, but when testing that it impacted exports from several plugins, so that's not what we're trying to cover with this change
created_at: 2025-04-10T15:55:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: timja
body: the point is that 

```yaml
- key: FOO
   (no value)
```

is invalid and should not be allowed (unless it works right now and this is just testing something pre-existing)

a null env var value is invalid.
an empty string env var uncommon, but I guess

so ok:

```yaml
- key: FOO
  value: ""
```

not ok:

```yaml
- key: FOO
```
created_at: 2025-04-10T18:43:52Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: jglick
body: Right. And if 

> Empty vars will still be exported with no value

then that is what needs to be fixed—if not everywhere, which I think would be correct but which seems controversial, at least for this one setting.
created_at: 2025-04-10T19:05:33Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: timja
body: IIUC the issue is that by default we can't change empty strings to be shown, they are hidden because numerous places don't convert it to null, thats why there's a configurator to allow empty strings to be shown here.

I don't know why we would allow null here though.
created_at: 2025-04-10T19:37:10Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: PereBueno
body: What about using a escape hatch deactivated by default? 
We wouldn't change default behaviour but we could use it for either allowing importing null env vars or adding empty values to the export (the idea was simply allow importing what we export)
created_at: 2025-04-11T10:34:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: jglick
body: Importing null is certainly wrong. We want to export empty values for _this_ setting; you should not need to search the web for some system property to fix a bug.
created_at: 2025-04-11T11:57:10Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: timja
body: Agreed. I've tried removing the check that stops exporting empty, in my test instance it wasn't that many, would it be better to try e.g. running bom tests to see how bad the impact is instead? 
created_at: 2025-04-12T14:25:01Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: jglick
body: Can we not just limit the behavioral change to this setting, leaving other misbehaving plugins for another day?
created_at: 2025-04-14T13:19:27Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: timja
body: Looks do-able from a quick look, something similar to this on [`CNode`](https://github.com/jenkinsci/configuration-as-code-plugin/blob/7c3138e7575e425610317c76f502534cfe3804be/plugin/src/main/java/io/jenkins/plugins/casc/model/CNode.java#L34-L36) and then allow setting the value when you construct it.

Not sure how easy it would be to integrate when using databoundconfigurator though.
created_at: 2025-04-14T13:25:03Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: PereBueno
body: I'm trying that, with no luck for the moment
created_at: 2025-04-14T15:07:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: timja
body: (my link was missing added now)
created_at: 2025-04-14T15:50:29Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,18 @@
 jenkins:
   globalNodeProperties:
+    - diskSpaceMonitor:
+        freeDiskSpaceThreshold: "1GiB"
+        freeDiskSpaceWarningThreshold: "2GiB"
+        freeTempSpaceThreshold: "1GiB"
+        freeTempSpaceWarningThreshold: "2GiB"
     - envVars:
         env:
           - key: FOO
             value: BAR
+          - key: FOO2
+            value: ""
+          - key: FOO3
path: integrations/src/test/resources/io/jenkins/plugins/casc/GlobalNodePropertiesTest.yml
user: login: PereBueno
body: Updated https://github.com/jenkinsci/configuration-as-code-plugin/pull/2665/commits/be00ec15cac82b27d116af4f2350878c17f0ae97:
* Removed the changes when configuring, so importing null vars is not supported
* Added a false by default flag to CNode to allow printing when empty and setting it to trtue only for non null env vars

Will update PR description accordingly
created_at: 2025-04-15T08:28:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: timja
created_at: 2025-04-15T09:35:20Z
body: Related test failing on Java 21
author_association: MEMBER
user: login: PereBueno
created_at: 2025-04-15T10:50:02Z
body: > Related test failing on Java 21

Forgot to remove test condition from previous implementation :sweat_smile: 

Fixed now
author_association: CONTRIBUTOR