This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 6beb2c7559 issue #7583 : validate system resources on lifecycle 
environment enable (#7651)
6beb2c7559 is described below

commit 6beb2c7559dbce6b66fba36ca035dfa9661ca5f1
Author: Matt Casters <[email protected]>
AuthorDate: Tue Jul 28 09:25:31 2026 +0200

    issue #7583 : validate system resources on lifecycle environment enable 
(#7651)
    
    Add optional JVM max memory, free disk space, and CPU core requirements
    on lifecycle environments. Settings live in the resources attribute group
    and are edited on a new System resources tab. Checks run on environment
    enable (GUI, hop-run, hop-server) with off/warn/enforce policy.
---
 .../projects-environment-system-resources.png      | Bin 0 -> 108865 bytes
 .../ROOT/pages/projects/projects-environments.adoc |  26 ++
 .../projects/resources/DiskSpaceRequirement.java   |  40 +++
 .../hop/projects/resources/ResourceAttributes.java | 179 ++++++++++++
 .../resources/SystemResourceCheckResult.java       |  47 ++++
 .../projects/resources/SystemResourceChecker.java  | 174 ++++++++++++
 .../resources/SystemResourceRequirement.java       |  49 ++++
 .../projects/resources/SystemResourceSnapshot.java |  73 +++++
 ...fecycleEnvironmentDialogTabsExtensionPoint.java | 300 +++++++++++++++++++++
 ...ojectEnvironmentAfterEnabledExtensionPoint.java | 102 +++++++
 .../resources/messages/messages_en_US.properties   |  32 +++
 .../projects/resources/ResourceAttributesTest.java | 139 ++++++++++
 .../resources/SystemResourceCheckerTest.java       | 191 +++++++++++++
 .../resources/SystemResourceProbeTest.java         |  74 +++++
 ...tEnvironmentAfterEnabledExtensionPointTest.java | 101 +++++++
 15 files changed, 1527 insertions(+)

diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/projects-environment-system-resources.png
 
b/docs/hop-user-manual/modules/ROOT/assets/images/projects-environment-system-resources.png
new file mode 100644
index 0000000000..e3b47e816e
Binary files /dev/null and 
b/docs/hop-user-manual/modules/ROOT/assets/images/projects-environment-system-resources.png
 differ
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
index 1da0e5ea46..304e1d872e 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
@@ -93,6 +93,32 @@ When you browse for a file or directory in Hop Gui with a 
project active, paths
 
 After creating an environment the user interface will switch to it.
 
+== System resource requirements
+
+Lifecycle environments can declare *system resource requirements* for the host 
where Hop runs. Open the environment properties dialog and select the *System 
resources* tab.
+
+image::projects-environment-system-resources.png[Lifecycle environment System 
resources tab,75%,align="left"]
+
+When the environment is enabled (Hop Gui, `hop-run -e`, `hop-server -e`, and 
other tools that call project enablement), Hop can check:
+
+* *Min JVM max memory (MiB)* — the JVM max heap (`Runtime.maxMemory()`, i.e. 
configured `-Xmx` capacity), not the currently allocated heap.
+* *Min CPU cores* — `Runtime.availableProcessors()`.
+* *Local folders (min free space)* — one or more local paths and a minimum 
free space in MiB (`File.getUsableSpace()`). Both the path and the min-free 
value support Hop variables and expanded number forms (via 
`Const.toLongExpanded`). Paths must resolve to local folders.
+
+Leave a field empty to skip that check.
+
+.On enable policy
+[cols="20%,80%",options="header"]
+|===
+|Value|Behavior
+|off|Do not check (default for Development / CI / CB and when purpose is 
unset).
+|warn|Log a failure report; in Hop Gui show a warning dialog. Environment 
enable continues.
+|enforce|Fail enabling the environment with a fatal error. Default for 
Production.
+|===
+
+If *On enable* is left unset, Hop derives a default from the environment 
*Purpose*: Production → enforce; Testing / Acceptance → warn; otherwise off.
+
+These checks run on the JVM that enables the environment. They do not validate 
remote Beam workers or other remote runtimes.
 
 == Switch between projects and environments
 
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/DiskSpaceRequirement.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/DiskSpaceRequirement.java
new file mode 100644
index 0000000000..ef935fc14c
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/DiskSpaceRequirement.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.resources;
+
+import lombok.Value;
+
+/**
+ * Minimum free disk space required for a (possibly variable-containing) local 
folder path.
+ *
+ * <p>{@link #minFreeBytes} holds the configured expression in 
<strong>mebibytes</strong> (as
+ * entered in the UI). It may contain Hop variables. Resolve and convert with 
{@code
+ * Const.toLongExpanded(variables.resolve(...))} before comparing to {@link
+ * java.io.File#getUsableSpace()}.
+ */
+@Value
+public class DiskSpaceRequirement {
+  /** Folder path; may contain Hop variables until resolved. */
+  String path;
+
+  /**
+   * Minimum free space in MiB as a string expression (literals, expanded 
numbers, or Hop
+   * variables). Not pre-converted to bytes.
+   */
+  String minFreeBytes;
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/ResourceAttributes.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/ResourceAttributes.java
new file mode 100644
index 0000000000..2d3878ccc0
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/ResourceAttributes.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.resources;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.IAttributes;
+
+/**
+ * Attribute group/key constants for system resource requirements stored via 
{@link IAttributes} on
+ * lifecycle environments. Group name is {@link #GROUP}.
+ */
+public final class ResourceAttributes {
+
+  /** IAttributes group for system resource settings. */
+  public static final String GROUP = "resources";
+
+  /**
+   * When environment is enabled: {@link #ON_ENABLE_OFF}, {@link 
#ON_ENABLE_WARN}, {@link
+   * #ON_ENABLE_ENFORCE}.
+   */
+  public static final String KEY_ON_ENABLE = "onEnable";
+
+  /** Minimum JVM max heap ({@link Runtime#maxMemory()}, ≈ {@code -Xmx}) in 
mebibytes. */
+  public static final String KEY_MIN_MAX_MEMORY_MB = "minMaxMemoryMb";
+
+  /** Minimum {@link Runtime#availableProcessors()}. */
+  public static final String KEY_MIN_PROCESSORS = "minProcessors";
+
+  /**
+   * Disk free-space rules: newline-separated {@code path|minFreeMb} records. 
Paths and min-free
+   * expressions may contain Hop variables; resolve with {@code 
variables.resolve} and {@link
+   * org.apache.hop.core.Const#toLongExpanded(String, long)} before checking.
+   */
+  public static final String KEY_DISK_CHECKS = "diskChecks";
+
+  public static final String ON_ENABLE_OFF = "off";
+  public static final String ON_ENABLE_WARN = "warn";
+  public static final String ON_ENABLE_ENFORCE = "enforce";
+
+  /** Bytes in one mebibyte (1024 * 1024). */
+  public static final long BYTES_PER_MIB = 1024L * 1024L;
+
+  private ResourceAttributes() {}
+
+  /**
+   * Resolve on-enable policy from attributes, falling back to purpose-based 
defaults when unset.
+   *
+   * <ul>
+   *   <li>Production → enforce
+   *   <li>Testing / Acceptance → warn
+   *   <li>Development / CI / CB / other → off
+   * </ul>
+   */
+  public static String resolveOnEnable(IAttributes attributes, String purpose) 
{
+    String explicit = attributes != null ? attributes.getAttribute(GROUP, 
KEY_ON_ENABLE) : null;
+    if (StringUtils.isNotBlank(explicit)) {
+      return explicit.trim().toLowerCase();
+    }
+    return defaultOnEnableForPurpose(purpose);
+  }
+
+  public static String defaultOnEnableForPurpose(String purpose) {
+    if (StringUtils.isBlank(purpose)) {
+      return ON_ENABLE_OFF;
+    }
+    String p = purpose.trim().toLowerCase();
+    if (p.contains("production") || p.contains("prod")) {
+      return ON_ENABLE_ENFORCE;
+    }
+    if (p.contains("test") || p.contains("accept")) {
+      return ON_ENABLE_WARN;
+    }
+    return ON_ENABLE_OFF;
+  }
+
+  /**
+   * Build a requirement object from attributes. Blank keys mean that 
dimension is not checked.
+   * Returns a requirement with no thresholds when attributes are empty.
+   */
+  public static SystemResourceRequirement toRequirement(IAttributes 
attributes) {
+    Long minMaxMemoryMb = parsePositiveLong(attribute(attributes, 
KEY_MIN_MAX_MEMORY_MB));
+    Integer minProcessors = parsePositiveInt(attribute(attributes, 
KEY_MIN_PROCESSORS));
+    List<DiskSpaceRequirement> diskChecks = 
parseDiskChecks(attribute(attributes, KEY_DISK_CHECKS));
+    return new SystemResourceRequirement(minMaxMemoryMb, minProcessors, 
diskChecks);
+  }
+
+  public static String formatDiskChecks(List<DiskSpaceRequirement> diskChecks) 
{
+    if (diskChecks == null || diskChecks.isEmpty()) {
+      return "";
+    }
+    StringBuilder sb = new StringBuilder();
+    for (DiskSpaceRequirement req : diskChecks) {
+      if (req == null
+          || StringUtils.isBlank(req.getPath())
+          || StringUtils.isBlank(req.getMinFreeBytes())) {
+        continue;
+      }
+      if (!sb.isEmpty()) {
+        sb.append('\n');
+      }
+      
sb.append(req.getPath().trim()).append('|').append(req.getMinFreeBytes().trim());
+    }
+    return sb.toString();
+  }
+
+  /**
+   * Parse {@link #KEY_DISK_CHECKS} value: newline-separated {@code 
path|minFreeMb} lines. Blank
+   * lines ignored. Lines without {@code |} are skipped. The min-free side is 
kept as a string so
+   * Hop variables and expanded number forms are preserved until check time.
+   */
+  public static List<DiskSpaceRequirement> parseDiskChecks(String raw) {
+    List<DiskSpaceRequirement> result = new ArrayList<>();
+    if (StringUtils.isBlank(raw)) {
+      return result;
+    }
+    for (String line : raw.split("\\R")) {
+      if (StringUtils.isBlank(line)) {
+        continue;
+      }
+      String trimmed = line.trim();
+      int sep = trimmed.lastIndexOf('|');
+      if (sep <= 0 || sep >= trimmed.length() - 1) {
+        continue;
+      }
+      String path = trimmed.substring(0, sep).trim();
+      String mbText = trimmed.substring(sep + 1).trim();
+      if (StringUtils.isBlank(path) || StringUtils.isBlank(mbText)) {
+        continue;
+      }
+      result.add(new DiskSpaceRequirement(path, mbText));
+    }
+    return result;
+  }
+
+  private static String attribute(IAttributes attributes, String key) {
+    return attributes != null ? attributes.getAttribute(GROUP, key) : null;
+  }
+
+  public static Long parsePositiveLong(String text) {
+    if (StringUtils.isBlank(text)) {
+      return null;
+    }
+    try {
+      long value = Long.parseLong(text.trim());
+      return value > 0 ? value : null;
+    } catch (NumberFormatException e) {
+      return null;
+    }
+  }
+
+  public static Integer parsePositiveInt(String text) {
+    if (StringUtils.isBlank(text)) {
+      return null;
+    }
+    try {
+      int value = Integer.parseInt(text.trim());
+      return value > 0 ? value : null;
+    } catch (NumberFormatException e) {
+      return null;
+    }
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceCheckResult.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceCheckResult.java
new file mode 100644
index 0000000000..6ba3827cd0
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceCheckResult.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.resources;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+
+/** Outcome of comparing a {@link SystemResourceRequirement} to a {@link 
SystemResourceSnapshot}. */
+@Getter
+public class SystemResourceCheckResult {
+
+  private final List<String> violations = new ArrayList<>();
+
+  public boolean hasViolations() {
+    return !violations.isEmpty();
+  }
+
+  public void addViolation(String message) {
+    if (message != null && !message.isBlank()) {
+      violations.add(message);
+    }
+  }
+
+  public String formatReport() {
+    StringBuilder sb = new StringBuilder();
+    for (String v : violations) {
+      sb.append("  ").append(v).append('\n');
+    }
+    return sb.toString();
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceChecker.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceChecker.java
new file mode 100644
index 0000000000..5887b8b718
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceChecker.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.resources;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.variables.IVariables;
+
+/**
+ * Compares configured system resource requirements against a measured 
snapshot. Disk free-space
+ * thresholds are MiB expressions resolved via {@link 
IVariables#resolve(String)} and {@link
+ * Const#toLongExpanded(String, long)}.
+ */
+public final class SystemResourceChecker {
+
+  private SystemResourceChecker() {}
+
+  /**
+   * Check all configured thresholds without variable resolution (literals 
only). Collects every
+   * violation rather than stopping at the first.
+   */
+  public static SystemResourceCheckResult check(
+      SystemResourceRequirement requirement, SystemResourceSnapshot actual) {
+    return check(requirement, actual, null);
+  }
+
+  /**
+   * Check all configured thresholds. Disk min-free expressions are resolved 
with {@code variables}
+   * when non-null: {@code Const.toLongExpanded(variables.resolve(minFree), 
-1)}.
+   */
+  public static SystemResourceCheckResult check(
+      SystemResourceRequirement requirement, SystemResourceSnapshot actual, 
IVariables variables) {
+    SystemResourceCheckResult result = new SystemResourceCheckResult();
+    if (requirement == null || actual == null || 
!requirement.hasAnyRequirement()) {
+      return result;
+    }
+
+    if (requirement.getMinMaxMemoryMb() != null) {
+      long requiredBytes = requirement.getMinMaxMemoryMb() * 
ResourceAttributes.BYTES_PER_MIB;
+      long actualBytes = actual.getMaxMemoryBytes();
+      if (actualBytes < requiredBytes) {
+        result.addViolation(
+            "JVM max memory (maxMemory/-Xmx) is "
+                + formatMb(actualBytes)
+                + " MiB but environment requires at least "
+                + requirement.getMinMaxMemoryMb()
+                + " MiB");
+      }
+    }
+
+    if (requirement.getMinProcessors() != null) {
+      int actualCores = actual.getAvailableProcessors();
+      if (actualCores < requirement.getMinProcessors()) {
+        result.addViolation(
+            "Available processors is "
+                + actualCores
+                + " but environment requires at least "
+                + requirement.getMinProcessors());
+      }
+    }
+
+    for (DiskSpaceRequirement disk : requirement.getDiskChecks()) {
+      if (disk == null || StringUtils.isBlank(disk.getPath())) {
+        continue;
+      }
+      String path = disk.getPath();
+      Long usable = actual.getUsableSpaceByPath().get(path);
+      if (usable == null) {
+        result.addViolation("Disk path '" + path + "' was not measured 
(missing from snapshot)");
+        continue;
+      }
+
+      long minMb = resolveMinFreeMb(disk.getMinFreeBytes(), variables);
+      if (minMb <= 0) {
+        result.addViolation(
+            "Disk path '"
+                + path
+                + "' has invalid min free space expression: '"
+                + Const.NVL(disk.getMinFreeBytes(), "")
+                + "'");
+        continue;
+      }
+      long minBytes = minMb * ResourceAttributes.BYTES_PER_MIB;
+
+      File file = new File(path);
+      if (!file.exists()) {
+        result.addViolation(
+            "Disk path '" + path + "' does not exist (required free space: " + 
minMb + " MiB)");
+        continue;
+      }
+      if (usable < minBytes) {
+        result.addViolation(
+            "Disk path '"
+                + path
+                + "' has "
+                + formatMb(usable)
+                + " MiB free but environment requires at least "
+                + minMb
+                + " MiB");
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Resolve disk paths with variables, capture a live snapshot, and check. 
Min free expressions are
+   * resolved during the check via {@link Const#toLongExpanded(String, long)}.
+   */
+  public static SystemResourceCheckResult checkLive(
+      SystemResourceRequirement requirement, IVariables variables) {
+    if (requirement == null || !requirement.hasAnyRequirement()) {
+      return new SystemResourceCheckResult();
+    }
+    List<String> resolvedPaths = new ArrayList<>();
+    List<DiskSpaceRequirement> resolvedDisks = new ArrayList<>();
+    for (DiskSpaceRequirement disk : requirement.getDiskChecks()) {
+      if (disk == null || StringUtils.isBlank(disk.getPath())) {
+        continue;
+      }
+      String path = disk.getPath().trim();
+      if (variables != null) {
+        path = variables.resolve(path);
+      }
+      if (StringUtils.isBlank(path)) {
+        continue;
+      }
+      resolvedPaths.add(path);
+      // Keep min-free expression as configured; resolve + toLongExpanded 
happens in check()
+      resolvedDisks.add(new DiskSpaceRequirement(path, 
disk.getMinFreeBytes()));
+    }
+    SystemResourceRequirement resolved =
+        new SystemResourceRequirement(
+            requirement.getMinMaxMemoryMb(), requirement.getMinProcessors(), 
resolvedDisks);
+    SystemResourceSnapshot snapshot = 
SystemResourceSnapshot.capture(resolvedPaths);
+    return check(resolved, snapshot, variables);
+  }
+
+  /**
+   * Resolve a min-free MiB expression: {@code 
Const.toLongExpanded(variables.resolve(expr), -1)}.
+   *
+   * @return positive MiB value, or {@code <= 0} when missing/invalid
+   */
+  static long resolveMinFreeMb(String minFreeExpression, IVariables variables) 
{
+    if (StringUtils.isBlank(minFreeExpression)) {
+      return -1L;
+    }
+    String resolved =
+        variables != null ? variables.resolve(minFreeExpression.trim()) : 
minFreeExpression.trim();
+    return Const.toLongExpanded(resolved, -1L);
+  }
+
+  private static long formatMb(long bytes) {
+    return bytes / ResourceAttributes.BYTES_PER_MIB;
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceRequirement.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceRequirement.java
new file mode 100644
index 0000000000..1241f71abd
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceRequirement.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.resources;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import lombok.Getter;
+
+/** Configured system resource thresholds for a lifecycle environment. Null 
thresholds = skip. */
+@Getter
+public class SystemResourceRequirement {
+
+  /** Minimum JVM max heap in mebibytes, or null if not checked. */
+  private final Long minMaxMemoryMb;
+
+  /** Minimum CPU cores, or null if not checked. */
+  private final Integer minProcessors;
+
+  private final List<DiskSpaceRequirement> diskChecks;
+
+  public SystemResourceRequirement(
+      Long minMaxMemoryMb, Integer minProcessors, List<DiskSpaceRequirement> 
diskChecks) {
+    this.minMaxMemoryMb = minMaxMemoryMb;
+    this.minProcessors = minProcessors;
+    this.diskChecks =
+        diskChecks == null ? List.of() : Collections.unmodifiableList(new 
ArrayList<>(diskChecks));
+  }
+
+  /** True when at least one threshold is configured. */
+  public boolean hasAnyRequirement() {
+    return minMaxMemoryMb != null || minProcessors != null || 
!diskChecks.isEmpty();
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceSnapshot.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceSnapshot.java
new file mode 100644
index 0000000000..fd87a46554
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/resources/SystemResourceSnapshot.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.resources;
+
+import java.io.File;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.Getter;
+
+/**
+ * Measured host resources at a point in time. Constructed for tests with 
fixed values, or via
+ * {@link #capture(List)} against the live JVM and local filesystem.
+ */
+@Getter
+public class SystemResourceSnapshot {
+
+  private final long maxMemoryBytes;
+  private final long totalMemoryBytes;
+  private final int availableProcessors;
+
+  /** Resolved local path → usable free bytes ({@link File#getUsableSpace()}). 
*/
+  private final Map<String, Long> usableSpaceByPath;
+
+  public SystemResourceSnapshot(
+      long maxMemoryBytes,
+      long totalMemoryBytes,
+      int availableProcessors,
+      Map<String, Long> usableSpaceByPath) {
+    this.maxMemoryBytes = maxMemoryBytes;
+    this.totalMemoryBytes = totalMemoryBytes;
+    this.availableProcessors = availableProcessors;
+    this.usableSpaceByPath =
+        usableSpaceByPath == null
+            ? Map.of()
+            : Collections.unmodifiableMap(new HashMap<>(usableSpaceByPath));
+  }
+
+  /**
+   * Capture live JVM/OS values. For each path, records {@link 
File#getUsableSpace()} (0 when the
+   * path does not exist).
+   */
+  public static SystemResourceSnapshot capture(List<String> resolvedPaths) {
+    Runtime runtime = Runtime.getRuntime();
+    Map<String, Long> usable = new HashMap<>();
+    if (resolvedPaths != null) {
+      for (String path : resolvedPaths) {
+        if (path == null || path.isBlank()) {
+          continue;
+        }
+        usable.put(path, new File(path).getUsableSpace());
+      }
+    }
+    return new SystemResourceSnapshot(
+        runtime.maxMemory(), runtime.totalMemory(), 
runtime.availableProcessors(), usable);
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ResourceLifecycleEnvironmentDialogTabsExtensionPoint.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ResourceLifecycleEnvironmentDialogTabsExtensionPoint.java
new file mode 100644
index 0000000000..c1f9a5ded6
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ResourceLifecycleEnvironmentDialogTabsExtensionPoint.java
@@ -0,0 +1,300 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.xp;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.AttributesContext;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPoint;
+import org.apache.hop.core.extension.IExtensionPoint;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.projects.resources.DiskSpaceRequirement;
+import org.apache.hop.projects.resources.ResourceAttributes;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.AttributesDialogExtension;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
+
+/**
+ * Contributes a System resources tab on the lifecycle environment dialog. 
Settings are stored under
+ * {@link ResourceAttributes#GROUP} on the shared {@link AttributesContext}.
+ */
+@ExtensionPoint(
+    id = "ResourceLifecycleEnvironmentDialogTabs",
+    description = "Add system resource requirements tab to the lifecycle 
environment dialog",
+    extensionPointId = "HopGuiLifecycleEnvironmentDialogTabs")
+public class ResourceLifecycleEnvironmentDialogTabsExtensionPoint
+    implements IExtensionPoint<AttributesDialogExtension> {
+
+  private static final Class<?> PKG = ResourceAttributes.class;
+
+  private Combo wOnEnable;
+  private Text wMinMaxMemoryMb;
+  private Text wMinProcessors;
+  private TableView wDiskChecks;
+  private IVariables variables;
+
+  @Override
+  public void callExtensionPoint(
+      ILogChannel log, IVariables variables, AttributesDialogExtension 
extension)
+      throws HopException {
+    if (extension == null || extension.getTabFolder() == null) {
+      return;
+    }
+    this.variables = variables;
+
+    PropsUi props = PropsUi.getInstance();
+    CTabFolder folder = extension.getTabFolder();
+    int margin = PropsUi.getMargin();
+    int middle = props.getMiddlePct();
+
+    CTabItem tab = new CTabItem(folder, SWT.NONE);
+    tab.setText(BaseMessages.getString(PKG, "ResourceLifecycleEnv.Tab.Title"));
+    tab.setImage(GuiResource.getInstance().getImageServer());
+    Composite comp = new Composite(folder, SWT.NONE);
+    PropsUi.setLook(comp);
+    FormLayout layout = new FormLayout();
+    layout.marginWidth = PropsUi.getFormMargin();
+    layout.marginHeight = PropsUi.getFormMargin();
+    comp.setLayout(layout);
+    tab.setControl(comp);
+
+    Label wlHelp = new Label(comp, SWT.LEFT | SWT.WRAP);
+    PropsUi.setLook(wlHelp);
+    wlHelp.setText(BaseMessages.getString(PKG, "ResourceLifecycleEnv.Help"));
+    FormData fdlHelp = new FormData();
+    fdlHelp.left = new FormAttachment(0, 0);
+    fdlHelp.top = new FormAttachment(0, 0);
+    fdlHelp.right = new FormAttachment(100, 0);
+    wlHelp.setLayoutData(fdlHelp);
+
+    Label wlOnEnable = new Label(comp, SWT.RIGHT);
+    PropsUi.setLook(wlOnEnable);
+    wlOnEnable.setText(BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.OnEnable.Label"));
+    FormData fdlOn = new FormData();
+    fdlOn.left = new FormAttachment(0, 0);
+    fdlOn.top = new FormAttachment(wlHelp, margin * 2);
+    fdlOn.right = new FormAttachment(middle, -margin);
+    wlOnEnable.setLayoutData(fdlOn);
+
+    wOnEnable = new Combo(comp, SWT.READ_ONLY | SWT.BORDER);
+    PropsUi.setLook(wOnEnable);
+    wOnEnable.setItems(
+        ResourceAttributes.ON_ENABLE_OFF,
+        ResourceAttributes.ON_ENABLE_WARN,
+        ResourceAttributes.ON_ENABLE_ENFORCE);
+    FormData fdOn = new FormData();
+    fdOn.left = new FormAttachment(middle, 0);
+    fdOn.top = new FormAttachment(wlOnEnable, 0, SWT.CENTER);
+    fdOn.right = new FormAttachment(100, 0);
+    wOnEnable.setLayoutData(fdOn);
+    wOnEnable.setToolTipText(BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.OnEnable.Tooltip"));
+
+    Label wlMem = new Label(comp, SWT.RIGHT);
+    PropsUi.setLook(wlMem);
+    wlMem.setText(BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.MinMaxMemoryMb.Label"));
+    FormData fdlMem = new FormData();
+    fdlMem.left = new FormAttachment(0, 0);
+    fdlMem.top = new FormAttachment(wOnEnable, margin * 2);
+    fdlMem.right = new FormAttachment(middle, -margin);
+    wlMem.setLayoutData(fdlMem);
+
+    wMinMaxMemoryMb = new Text(comp, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+    PropsUi.setLook(wMinMaxMemoryMb);
+    wMinMaxMemoryMb.setToolTipText(
+        BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.MinMaxMemoryMb.Tooltip"));
+    FormData fdMem = new FormData();
+    fdMem.left = new FormAttachment(middle, 0);
+    fdMem.top = new FormAttachment(wlMem, 0, SWT.CENTER);
+    fdMem.right = new FormAttachment(100, 0);
+    wMinMaxMemoryMb.setLayoutData(fdMem);
+
+    Label wlCpu = new Label(comp, SWT.RIGHT);
+    PropsUi.setLook(wlCpu);
+    wlCpu.setText(BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.MinProcessors.Label"));
+    FormData fdlCpu = new FormData();
+    fdlCpu.left = new FormAttachment(0, 0);
+    fdlCpu.top = new FormAttachment(wMinMaxMemoryMb, margin * 2);
+    fdlCpu.right = new FormAttachment(middle, -margin);
+    wlCpu.setLayoutData(fdlCpu);
+
+    wMinProcessors = new Text(comp, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+    PropsUi.setLook(wMinProcessors);
+    wMinProcessors.setToolTipText(
+        BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.MinProcessors.Tooltip"));
+    FormData fdCpu = new FormData();
+    fdCpu.left = new FormAttachment(middle, 0);
+    fdCpu.top = new FormAttachment(wlCpu, 0, SWT.CENTER);
+    fdCpu.right = new FormAttachment(100, 0);
+    wMinProcessors.setLayoutData(fdCpu);
+
+    Label wlDisk = new Label(comp, SWT.LEFT);
+    PropsUi.setLook(wlDisk);
+    wlDisk.setText(BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.Disk.Label"));
+    FormData fdlDisk = new FormData();
+    fdlDisk.left = new FormAttachment(0, 0);
+    fdlDisk.top = new FormAttachment(wMinProcessors, margin * 2);
+    fdlDisk.right = new FormAttachment(100, 0);
+    wlDisk.setLayoutData(fdlDisk);
+
+    Button wBrowse = new Button(comp, SWT.PUSH);
+    wBrowse.setText(BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.Disk.Button.Browse"));
+    FormData fdBrowse = new FormData();
+    fdBrowse.right = new FormAttachment(100, 0);
+    fdBrowse.top = new FormAttachment(wlDisk, margin);
+    wBrowse.setLayoutData(fdBrowse);
+    wBrowse.addListener(
+        SWT.Selection,
+        e -> {
+          String directory =
+              BaseDialog.presentDirectoryDialog(
+                  extension.getShell(), (String) null, (String) null, 
variables);
+          if (StringUtils.isNotBlank(directory)
+              && wDiskChecks != null
+              && !wDiskChecks.isDisposed()) {
+            TableItem item = new TableItem(wDiskChecks.table, SWT.NONE);
+            item.setText(1, directory);
+            item.setText(2, "1024");
+            wDiskChecks.removeEmptyRows();
+            wDiskChecks.setRowNums();
+            wDiskChecks.optWidth(true);
+          }
+        });
+
+    ColumnInfo[] columns =
+        new ColumnInfo[] {
+          new ColumnInfo(
+              BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.Disk.Column.Path"),
+              ColumnInfo.COLUMN_TYPE_TEXT,
+              false,
+              false),
+          new ColumnInfo(
+              BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.Disk.Column.MinFreeMb"),
+              ColumnInfo.COLUMN_TYPE_TEXT,
+              false,
+              false),
+        };
+    columns[0].setUsingVariables(true);
+    columns[1].setUsingVariables(true);
+
+    wDiskChecks =
+        new TableView(
+            variables, comp, SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI, 
columns, 1, null, props);
+    PropsUi.setLook(wDiskChecks);
+    FormData fdDisk = new FormData();
+    fdDisk.left = new FormAttachment(0, 0);
+    fdDisk.top = new FormAttachment(wlDisk, margin);
+    fdDisk.right = new FormAttachment(wBrowse, -margin);
+    fdDisk.bottom = new FormAttachment(100, 0);
+    wDiskChecks.setLayoutData(fdDisk);
+
+    extension.addLoadCallback(this::loadFromContext);
+    extension.addSaveCallback(this::saveToContext);
+  }
+
+  private void loadFromContext(AttributesContext context) {
+    if (wOnEnable == null || wOnEnable.isDisposed()) {
+      return;
+    }
+    wOnEnable.setText(ResourceAttributes.resolveOnEnable(context, 
context.getPurpose()));
+    wMinMaxMemoryMb.setText(
+        Const.NVL(
+            context.getAttribute(
+                ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_MAX_MEMORY_MB),
+            ""));
+    wMinProcessors.setText(
+        Const.NVL(
+            context.getAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_PROCESSORS),
+            ""));
+
+    wDiskChecks.table.removeAll();
+    List<DiskSpaceRequirement> disks =
+        ResourceAttributes.parseDiskChecks(
+            context.getAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_DISK_CHECKS));
+    if (disks.isEmpty()) {
+      new TableItem(wDiskChecks.table, SWT.NONE);
+    } else {
+      for (DiskSpaceRequirement disk : disks) {
+        TableItem item = new TableItem(wDiskChecks.table, SWT.NONE);
+        item.setText(1, Const.NVL(disk.getPath(), ""));
+        item.setText(2, Const.NVL(disk.getMinFreeBytes(), ""));
+      }
+    }
+    wDiskChecks.removeEmptyRows();
+    wDiskChecks.setRowNums();
+    wDiskChecks.optWidth(true);
+  }
+
+  private void saveToContext(AttributesContext context) {
+    if (wOnEnable == null || wOnEnable.isDisposed()) {
+      return;
+    }
+    context.setAttribute(
+        ResourceAttributes.GROUP,
+        ResourceAttributes.KEY_ON_ENABLE,
+        Const.NVL(wOnEnable.getText(), ResourceAttributes.ON_ENABLE_OFF));
+
+    setOrClear(context, ResourceAttributes.KEY_MIN_MAX_MEMORY_MB, 
wMinMaxMemoryMb.getText());
+    setOrClear(context, ResourceAttributes.KEY_MIN_PROCESSORS, 
wMinProcessors.getText());
+
+    List<DiskSpaceRequirement> disks = new ArrayList<>();
+    for (TableItem item : wDiskChecks.getNonEmptyItems()) {
+      String path = item.getText(1);
+      String mb = item.getText(2);
+      if (StringUtils.isBlank(path) || StringUtils.isBlank(mb)) {
+        continue;
+      }
+      // Keep expression as entered (literals, expanded numbers, or 
variables); resolve at check
+      // time
+      disks.add(new DiskSpaceRequirement(path.trim(), mb.trim()));
+    }
+    String encoded = ResourceAttributes.formatDiskChecks(disks);
+    setOrClear(context, ResourceAttributes.KEY_DISK_CHECKS, encoded);
+  }
+
+  private static void setOrClear(AttributesContext context, String key, String 
value) {
+    if (StringUtils.isBlank(value)) {
+      Map<String, String> group = 
context.getAttributes(ResourceAttributes.GROUP);
+      if (group != null) {
+        group.remove(key);
+      }
+    } else {
+      context.setAttribute(ResourceAttributes.GROUP, key, value.trim());
+    }
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ResourceProjectEnvironmentAfterEnabledExtensionPoint.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ResourceProjectEnvironmentAfterEnabledExtensionPoint.java
new file mode 100644
index 0000000000..1d661f3127
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ResourceProjectEnvironmentAfterEnabledExtensionPoint.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.xp;
+
+import org.apache.hop.core.AttributesContext;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPoint;
+import org.apache.hop.core.extension.IExtensionPoint;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.projects.resources.ResourceAttributes;
+import org.apache.hop.projects.resources.SystemResourceCheckResult;
+import org.apache.hop.projects.resources.SystemResourceChecker;
+import org.apache.hop.projects.resources.SystemResourceRequirement;
+import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.eclipse.swt.SWT;
+
+/**
+ * When a project lifecycle environment is enabled, optionally validate JVM 
memory, free disk space
+ * on local folders, and CPU cores against settings stored under {@link 
ResourceAttributes#GROUP}.
+ */
+@ExtensionPoint(
+    id = "ResourceProjectEnvironmentAfterEnabled",
+    description = "Validate system resource requirements when a lifecycle 
environment is enabled",
+    extensionPointId = "HopProjectEnvironmentAfterEnabled")
+public class ResourceProjectEnvironmentAfterEnabledExtensionPoint
+    implements IExtensionPoint<AttributesContext> {
+
+  private static final Class<?> PKG = ResourceAttributes.class;
+
+  @Override
+  public void callExtensionPoint(ILogChannel log, IVariables variables, 
AttributesContext context)
+      throws HopException {
+    if (context == null) {
+      return;
+    }
+
+    String onEnable = ResourceAttributes.resolveOnEnable(context, 
context.getPurpose());
+    if (ResourceAttributes.ON_ENABLE_OFF.equals(onEnable)) {
+      log.logDetailed(
+          "System resource check skipped (onEnable=off) for environment '"
+              + Const.NVL(context.getEnvironmentName(), "")
+              + "'");
+      return;
+    }
+
+    SystemResourceRequirement requirement = 
ResourceAttributes.toRequirement(context);
+    if (!requirement.hasAnyRequirement()) {
+      log.logDetailed(
+          "System resource check skipped (no thresholds) for environment '"
+              + Const.NVL(context.getEnvironmentName(), "")
+              + "'");
+      return;
+    }
+
+    SystemResourceCheckResult result = 
SystemResourceChecker.checkLive(requirement, variables);
+    String envName = Const.NVL(context.getEnvironmentName(), "");
+    if (!result.hasViolations()) {
+      log.logBasic(BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.Check.Passed", envName));
+      return;
+    }
+
+    String report =
+        BaseMessages.getString(
+            PKG, "ResourceLifecycleEnv.Check.Failed", envName, 
result.formatReport());
+
+    if (ResourceAttributes.ON_ENABLE_ENFORCE.equals(onEnable)) {
+      throw new HopException("FATAL: " + report);
+    }
+
+    // warn
+    log.logError(report);
+    if ("GUI".equalsIgnoreCase(Const.getHopPlatformRuntime())) {
+      try {
+        MessageBox box = new MessageBox(HopGui.getInstance().getShell(), 
SWT.OK | SWT.ICON_WARNING);
+        box.setText(BaseMessages.getString(PKG, 
"ResourceLifecycleEnv.Check.Title"));
+        box.setMessage(report);
+        box.open();
+      } catch (Exception e) {
+        // headless or shell unavailable
+      }
+    }
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/resources/messages/messages_en_US.properties
 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/resources/messages/messages_en_US.properties
new file mode 100644
index 0000000000..a495ad0fbc
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/resources/messages/messages_en_US.properties
@@ -0,0 +1,32 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+ResourceLifecycleEnv.Tab.Title=System resources
+ResourceLifecycleEnv.Help=Optional requirements checked when this lifecycle 
environment is enabled (GUI, hop-run, hop-server, \ldots). Leave fields empty 
to skip a check. Memory uses the JVM max heap (Runtime.maxMemory / -Xmx). Disk 
paths must be local folders.
+ResourceLifecycleEnv.OnEnable.Label=On enable
+ResourceLifecycleEnv.OnEnable.Tooltip=off\: no check; warn\: log/dialog on 
failure; enforce\: fail enabling the environment. Default depends on purpose 
(Production\=enforce, Testing/Acceptance\=warn, else off).
+ResourceLifecycleEnv.MinMaxMemoryMb.Label=Min JVM max memory (MiB)
+ResourceLifecycleEnv.MinMaxMemoryMb.Tooltip=Minimum Runtime.maxMemory() in 
mebibytes (configured -Xmx capacity), not currently allocated heap.
+ResourceLifecycleEnv.MinProcessors.Label=Min CPU cores
+ResourceLifecycleEnv.MinProcessors.Tooltip=Minimum 
Runtime.availableProcessors() reported by the JVM.
+ResourceLifecycleEnv.Disk.Label=Local folders (min free space)
+ResourceLifecycleEnv.Disk.Column.Path=Folder path
+ResourceLifecycleEnv.Disk.Column.MinFreeMb=Min free (MiB)
+ResourceLifecycleEnv.Disk.Button.Browse=Browse\u2026
+ResourceLifecycleEnv.Check.Title=System resource requirements
+ResourceLifecycleEnv.Check.Failed=System resource check failed for environment 
''{0}'':\n{1}
+ResourceLifecycleEnv.Check.Passed=System resource requirements satisfied for 
environment ''{0}''.
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/resources/ResourceAttributesTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/resources/ResourceAttributesTest.java
new file mode 100644
index 0000000000..2aeadb8ce8
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/resources/ResourceAttributesTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.resources;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.core.AttributesContext;
+import org.junit.jupiter.api.Test;
+
+class ResourceAttributesTest {
+
+  @Test
+  void purposeDefaults() {
+    assertEquals(
+        ResourceAttributes.ON_ENABLE_ENFORCE,
+        ResourceAttributes.defaultOnEnableForPurpose("Production"));
+    assertEquals(
+        ResourceAttributes.ON_ENABLE_ENFORCE, 
ResourceAttributes.defaultOnEnableForPurpose("prod"));
+    assertEquals(
+        ResourceAttributes.ON_ENABLE_WARN, 
ResourceAttributes.defaultOnEnableForPurpose("Testing"));
+    assertEquals(
+        ResourceAttributes.ON_ENABLE_WARN,
+        ResourceAttributes.defaultOnEnableForPurpose("Acceptance"));
+    assertEquals(
+        ResourceAttributes.ON_ENABLE_OFF,
+        ResourceAttributes.defaultOnEnableForPurpose("Development"));
+    assertEquals(
+        ResourceAttributes.ON_ENABLE_OFF, 
ResourceAttributes.defaultOnEnableForPurpose(null));
+  }
+
+  @Test
+  void explicitOnEnableOverridesPurpose() {
+    AttributesContext ctx = new AttributesContext();
+    ctx.setAttribute(
+        ResourceAttributes.GROUP,
+        ResourceAttributes.KEY_ON_ENABLE,
+        ResourceAttributes.ON_ENABLE_OFF);
+    assertEquals(
+        ResourceAttributes.ON_ENABLE_OFF, 
ResourceAttributes.resolveOnEnable(ctx, "Production"));
+  }
+
+  @Test
+  void toRequirementEmptyWhenUnset() {
+    AttributesContext ctx = new AttributesContext();
+    SystemResourceRequirement req = ResourceAttributes.toRequirement(ctx);
+    assertFalse(req.hasAnyRequirement());
+    assertNull(req.getMinMaxMemoryMb());
+    assertNull(req.getMinProcessors());
+    assertTrue(req.getDiskChecks().isEmpty());
+  }
+
+  @Test
+  void toRequirementParsesFields() {
+    AttributesContext ctx = new AttributesContext();
+    ctx.setAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_MAX_MEMORY_MB, "4096");
+    ctx.setAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_PROCESSORS, "4");
+    ctx.setAttribute(
+        ResourceAttributes.GROUP,
+        ResourceAttributes.KEY_DISK_CHECKS,
+        "${PROJECT_HOME}/data|10240\n/tmp|${MIN_DISK_MB}\n");
+
+    SystemResourceRequirement req = ResourceAttributes.toRequirement(ctx);
+    assertTrue(req.hasAnyRequirement());
+    assertEquals(4096L, req.getMinMaxMemoryMb());
+    assertEquals(4, req.getMinProcessors());
+    assertEquals(2, req.getDiskChecks().size());
+    assertEquals("${PROJECT_HOME}/data", req.getDiskChecks().get(0).getPath());
+    assertEquals("10240", req.getDiskChecks().get(0).getMinFreeBytes());
+    assertEquals("/tmp", req.getDiskChecks().get(1).getPath());
+    assertEquals("${MIN_DISK_MB}", 
req.getDiskChecks().get(1).getMinFreeBytes());
+  }
+
+  @Test
+  void parseDiskChecksIgnoresInvalidLinesButKeepsVariableExpressions() {
+    List<DiskSpaceRequirement> list =
+        ResourceAttributes.parseDiskChecks(
+            """
+            /valid|100
+            no-pipe
+            |only-min
+            path-only|
+            /with-var|${MIN_FREE}
+            /expanded|1.5m
+
+            /ok|1
+            """);
+    assertEquals(4, list.size());
+    assertEquals("/valid", list.get(0).getPath());
+    assertEquals("100", list.get(0).getMinFreeBytes());
+    assertEquals("/with-var", list.get(1).getPath());
+    assertEquals("${MIN_FREE}", list.get(1).getMinFreeBytes());
+    assertEquals("/expanded", list.get(2).getPath());
+    assertEquals("1.5m", list.get(2).getMinFreeBytes());
+    assertEquals("/ok", list.get(3).getPath());
+  }
+
+  @Test
+  void formatDiskChecksRoundTrip() {
+    List<DiskSpaceRequirement> original =
+        List.of(
+            new DiskSpaceRequirement("/data", "10"),
+            new DiskSpaceRequirement("/tmp", "${MIN_DISK}"));
+    String encoded = ResourceAttributes.formatDiskChecks(original);
+    List<DiskSpaceRequirement> parsed = 
ResourceAttributes.parseDiskChecks(encoded);
+    assertEquals(2, parsed.size());
+    assertEquals("/data", parsed.get(0).getPath());
+    assertEquals("10", parsed.get(0).getMinFreeBytes());
+    assertEquals("/tmp", parsed.get(1).getPath());
+    assertEquals("${MIN_DISK}", parsed.get(1).getMinFreeBytes());
+  }
+
+  @Test
+  void nonPositiveThresholdsIgnored() {
+    AttributesContext ctx = new AttributesContext();
+    ctx.setAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_MAX_MEMORY_MB, "0");
+    ctx.setAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_PROCESSORS, "-1");
+    SystemResourceRequirement req = ResourceAttributes.toRequirement(ctx);
+    assertFalse(req.hasAnyRequirement());
+  }
+}
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/resources/SystemResourceCheckerTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/resources/SystemResourceCheckerTest.java
new file mode 100644
index 0000000000..c4c8520a18
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/resources/SystemResourceCheckerTest.java
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.resources;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class SystemResourceCheckerTest {
+
+  private static final long MIB = ResourceAttributes.BYTES_PER_MIB;
+
+  @Test
+  void emptyRequirementAlwaysPasses() {
+    SystemResourceRequirement req = new SystemResourceRequirement(null, null, 
List.of());
+    SystemResourceSnapshot snap = new SystemResourceSnapshot(0, 0, 0, 
Map.of());
+    SystemResourceCheckResult result = SystemResourceChecker.check(req, snap);
+    assertFalse(result.hasViolations());
+  }
+
+  @Test
+  void memoryPassesWhenMaxHeapMeetsMinimum() {
+    SystemResourceRequirement req = new SystemResourceRequirement(1024L, null, 
List.of());
+    SystemResourceSnapshot snap = new SystemResourceSnapshot(2048 * MIB, 512 * 
MIB, 8, Map.of());
+    assertFalse(SystemResourceChecker.check(req, snap).hasViolations());
+  }
+
+  @Test
+  void memoryFailsWhenMaxHeapBelowMinimum() {
+    SystemResourceRequirement req = new SystemResourceRequirement(4096L, null, 
List.of());
+    SystemResourceSnapshot snap = new SystemResourceSnapshot(1024 * MIB, 256 * 
MIB, 8, Map.of());
+    SystemResourceCheckResult result = SystemResourceChecker.check(req, snap);
+    assertTrue(result.hasViolations());
+    assertEquals(1, result.getViolations().size());
+    assertTrue(result.getViolations().get(0).contains("max memory"));
+    assertTrue(result.getViolations().get(0).contains("4096"));
+  }
+
+  @Test
+  void processorsPassAndFail() {
+    SystemResourceRequirement req = new SystemResourceRequirement(null, 4, 
List.of());
+    SystemResourceSnapshot enough = new SystemResourceSnapshot(MIB, MIB, 8, 
Map.of());
+    SystemResourceSnapshot shortfall = new SystemResourceSnapshot(MIB, MIB, 2, 
Map.of());
+    assertFalse(SystemResourceChecker.check(req, enough).hasViolations());
+    SystemResourceCheckResult fail = SystemResourceChecker.check(req, 
shortfall);
+    assertTrue(fail.hasViolations());
+    assertTrue(fail.getViolations().get(0).contains("processors"));
+  }
+
+  @Test
+  void diskFailsWhenUsableSpaceBelowMinimum(@TempDir Path temp) {
+    String path = temp.toString();
+    // min free expression is MiB; require a huge amount
+    SystemResourceRequirement req =
+        new SystemResourceRequirement(
+            null, null, List.of(new DiskSpaceRequirement(path, "10000000")));
+    SystemResourceSnapshot snap = new SystemResourceSnapshot(MIB, MIB, 4, 
Map.of(path, 100L * MIB));
+    SystemResourceCheckResult result = SystemResourceChecker.check(req, snap);
+    assertTrue(result.hasViolations());
+    assertTrue(result.getViolations().get(0).contains(path));
+    assertTrue(result.getViolations().get(0).contains("free"));
+  }
+
+  @Test
+  void diskPassesWhenUsableSpaceMeetsMinimum(@TempDir Path temp) {
+    String path = temp.toString();
+    SystemResourceRequirement req =
+        new SystemResourceRequirement(null, null, List.of(new 
DiskSpaceRequirement(path, "10")));
+    SystemResourceSnapshot snap = new SystemResourceSnapshot(MIB, MIB, 4, 
Map.of(path, 100 * MIB));
+    assertFalse(SystemResourceChecker.check(req, snap).hasViolations());
+  }
+
+  @Test
+  void diskFailsWhenPathMissingFromFilesystem() {
+    String path = "/path/that/does/not/exist-for-resource-check";
+    SystemResourceRequirement req =
+        new SystemResourceRequirement(null, null, List.of(new 
DiskSpaceRequirement(path, "100")));
+    SystemResourceSnapshot snap = new SystemResourceSnapshot(MIB, MIB, 4, 
Map.of(path, 0L));
+    SystemResourceCheckResult result = SystemResourceChecker.check(req, snap);
+    assertTrue(result.hasViolations());
+    assertTrue(result.getViolations().get(0).contains("does not exist"));
+  }
+
+  @Test
+  void diskResolvesMinFreeVariableWithToLongExpanded(@TempDir Path temp) {
+    String path = temp.toString();
+    Variables variables = new Variables();
+    variables.setVariable("MIN_DISK_MB", "10");
+
+    SystemResourceRequirement req =
+        new SystemResourceRequirement(
+            null, null, List.of(new DiskSpaceRequirement(path, 
"${MIN_DISK_MB}")));
+    SystemResourceSnapshot enough =
+        new SystemResourceSnapshot(MIB, MIB, 4, Map.of(path, 100 * MIB));
+    assertFalse(SystemResourceChecker.check(req, enough, 
variables).hasViolations());
+
+    // Require more free space than available via variable
+    variables.setVariable("MIN_DISK_MB", "10000000");
+    SystemResourceCheckResult fail = SystemResourceChecker.check(req, enough, 
variables);
+    assertTrue(fail.hasViolations());
+    assertTrue(fail.getViolations().get(0).contains("10000000"));
+  }
+
+  @Test
+  void diskAcceptsExpandedNumberForms(@TempDir Path temp) {
+    String path = temp.toString();
+    // "1.5m" via toLongExpanded → 1_500_000 MiB — fail against small free 
space
+    SystemResourceRequirement req =
+        new SystemResourceRequirement(null, null, List.of(new 
DiskSpaceRequirement(path, "1.5m")));
+    SystemResourceSnapshot snap = new SystemResourceSnapshot(MIB, MIB, 4, 
Map.of(path, 100 * MIB));
+    SystemResourceCheckResult result = SystemResourceChecker.check(req, snap);
+    assertTrue(result.hasViolations());
+  }
+
+  @Test
+  void diskReportsInvalidMinFreeExpression(@TempDir Path temp) {
+    String path = temp.toString();
+    SystemResourceRequirement req =
+        new SystemResourceRequirement(
+            null, null, List.of(new DiskSpaceRequirement(path, 
"not-a-number")));
+    SystemResourceSnapshot snap = new SystemResourceSnapshot(MIB, MIB, 4, 
Map.of(path, 100 * MIB));
+    SystemResourceCheckResult result = SystemResourceChecker.check(req, snap);
+    assertTrue(result.hasViolations());
+    assertTrue(result.getViolations().get(0).contains("invalid min free"));
+  }
+
+  @Test
+  void resolveMinFreeMbUsesVariablesAndToLongExpanded() {
+    Variables variables = new Variables();
+    variables.setVariable("N", "2m");
+    assertEquals(2_000_000L, SystemResourceChecker.resolveMinFreeMb("${N}", 
variables));
+    assertEquals(1024L, SystemResourceChecker.resolveMinFreeMb("1024", null));
+    assertEquals(-1L, SystemResourceChecker.resolveMinFreeMb("bogus", null));
+  }
+
+  @Test
+  void collectsAllViolations() {
+    SystemResourceRequirement req =
+        new SystemResourceRequirement(
+            8192L, 16, List.of(new 
DiskSpaceRequirement("/missing-disk-path-xyz", "100")));
+    SystemResourceSnapshot snap =
+        new SystemResourceSnapshot(512 * MIB, 128 * MIB, 2, 
Map.of("/missing-disk-path-xyz", 0L));
+    SystemResourceCheckResult result = SystemResourceChecker.check(req, snap);
+    assertEquals(3, result.getViolations().size());
+    String report = result.formatReport();
+    assertTrue(report.contains("max memory"));
+    assertTrue(report.contains("processors"));
+    assertTrue(report.contains("missing-disk-path-xyz"));
+  }
+
+  @Test
+  void captureReflectsLiveRuntimeAndTempDir(@TempDir Path temp) {
+    SystemResourceSnapshot snap = 
SystemResourceSnapshot.capture(List.of(temp.toString()));
+    assertTrue(snap.getMaxMemoryBytes() > 0);
+    assertTrue(snap.getTotalMemoryBytes() > 0);
+    assertTrue(snap.getMaxMemoryBytes() >= snap.getTotalMemoryBytes());
+    assertTrue(snap.getAvailableProcessors() >= 1);
+    assertTrue(snap.getUsableSpaceByPath().get(temp.toString()) > 0);
+  }
+
+  @Test
+  void checkLivePassesAgainstLenientThresholds(@TempDir Path temp) {
+    SystemResourceRequirement req =
+        new SystemResourceRequirement(
+            1L, 1, List.of(new DiskSpaceRequirement(temp.toString(), "1")));
+    SystemResourceCheckResult result = SystemResourceChecker.checkLive(req, 
null);
+    assertFalse(result.hasViolations(), () -> result.formatReport());
+  }
+}
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/resources/SystemResourceProbeTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/resources/SystemResourceProbeTest.java
new file mode 100644
index 0000000000..7797fd607e
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/resources/SystemResourceProbeTest.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.resources;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Phase 0 probes for issue #7583: document the JVM/OS APIs used by system 
resource validation.
+ *
+ * <p>Product checks use {@link Runtime#maxMemory()} (≈ {@code -Xmx}), {@link
+ * File#getUsableSpace()}, and {@link Runtime#availableProcessors()}. {@link 
Runtime#totalMemory()}
+ * is probed for sanity only (current heap allocation, not capacity).
+ */
+class SystemResourceProbeTest {
+
+  @Test
+  void jvmReportsPositiveTotalMemory() {
+    long total = Runtime.getRuntime().totalMemory();
+    assertTrue(total > 0, "totalMemory() should report allocated heap > 0, got 
" + total);
+  }
+
+  @Test
+  void jvmMaxMemoryIsAtLeastTotalMemory() {
+    Runtime runtime = Runtime.getRuntime();
+    long max = runtime.maxMemory();
+    long total = runtime.totalMemory();
+    assertTrue(max > 0, "maxMemory() should be > 0, got " + max);
+    assertTrue(
+        max >= total, "maxMemory() (" + max + ") should be >= totalMemory() (" 
+ total + ")");
+  }
+
+  @Test
+  void availableProcessorsIsAtLeastOne() {
+    int processors = Runtime.getRuntime().availableProcessors();
+    assertTrue(processors >= 1, "availableProcessors() should be >= 1, got " + 
processors);
+  }
+
+  @Test
+  void usableSpaceOnTempDirIsNonNegative(@TempDir Path temp) {
+    long usable = temp.toFile().getUsableSpace();
+    assertTrue(usable >= 0, "getUsableSpace() on temp dir should be >= 0, got 
" + usable);
+    // On a real local filesystem we expect some free space
+    assertTrue(usable > 0, "getUsableSpace() on temp dir should be > 0, got " 
+ usable);
+  }
+
+  @Test
+  void usableSpaceOnMissingPathIsNonNegative() {
+    File missing = new File("/path/that/does/not/exist-" + UUID.randomUUID());
+    // JDK returns 0L for non-existent paths rather than throwing
+    long usable = missing.getUsableSpace();
+    assertTrue(usable >= 0, "getUsableSpace() on missing path should be >= 0, 
got " + usable);
+  }
+}
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/xp/ResourceProjectEnvironmentAfterEnabledExtensionPointTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/xp/ResourceProjectEnvironmentAfterEnabledExtensionPointTest.java
new file mode 100644
index 0000000000..34446e2fcc
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/xp/ResourceProjectEnvironmentAfterEnabledExtensionPointTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.xp;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import org.apache.hop.core.AttributesContext;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.projects.resources.ResourceAttributes;
+import org.junit.jupiter.api.Test;
+
+class ResourceProjectEnvironmentAfterEnabledExtensionPointTest {
+
+  private final ResourceProjectEnvironmentAfterEnabledExtensionPoint xp =
+      new ResourceProjectEnvironmentAfterEnabledExtensionPoint();
+  private final ILogChannel log = mock(ILogChannel.class);
+
+  @Test
+  void offSkipsCheckEvenWithImpossibleThresholds() {
+    AttributesContext ctx = new AttributesContext();
+    ctx.setEnvironmentName("dev");
+    ctx.setAttribute(
+        ResourceAttributes.GROUP,
+        ResourceAttributes.KEY_ON_ENABLE,
+        ResourceAttributes.ON_ENABLE_OFF);
+    ctx.setAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_PROCESSORS, "999999");
+    assertDoesNotThrow(() -> xp.callExtensionPoint(log, new Variables(), ctx));
+  }
+
+  @Test
+  void noThresholdsSkipsEvenWhenEnforce() {
+    AttributesContext ctx = new AttributesContext();
+    ctx.setEnvironmentName("prod");
+    ctx.setAttribute(
+        ResourceAttributes.GROUP,
+        ResourceAttributes.KEY_ON_ENABLE,
+        ResourceAttributes.ON_ENABLE_ENFORCE);
+    assertDoesNotThrow(() -> xp.callExtensionPoint(log, new Variables(), ctx));
+  }
+
+  @Test
+  void enforceThrowsWhenProcessorsInsufficient() {
+    AttributesContext ctx = new AttributesContext();
+    ctx.setEnvironmentName("prod");
+    ctx.setAttribute(
+        ResourceAttributes.GROUP,
+        ResourceAttributes.KEY_ON_ENABLE,
+        ResourceAttributes.ON_ENABLE_ENFORCE);
+    ctx.setAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_PROCESSORS, "999999");
+
+    HopException ex =
+        assertThrows(HopException.class, () -> xp.callExtensionPoint(log, new 
Variables(), ctx));
+    assertTrue(ex.getMessage().contains("FATAL"));
+    assertTrue(ex.getMessage().contains("processors") || 
ex.getMessage().contains("999999"));
+  }
+
+  @Test
+  void warnDoesNotThrowWhenProcessorsInsufficient() {
+    AttributesContext ctx = new AttributesContext();
+    ctx.setEnvironmentName("test");
+    ctx.setAttribute(
+        ResourceAttributes.GROUP,
+        ResourceAttributes.KEY_ON_ENABLE,
+        ResourceAttributes.ON_ENABLE_WARN);
+    ctx.setAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_PROCESSORS, "999999");
+    assertDoesNotThrow(() -> xp.callExtensionPoint(log, new Variables(), ctx));
+  }
+
+  @Test
+  void enforcePassesWithLenientThresholds() {
+    AttributesContext ctx = new AttributesContext();
+    ctx.setEnvironmentName("prod");
+    ctx.setAttribute(
+        ResourceAttributes.GROUP,
+        ResourceAttributes.KEY_ON_ENABLE,
+        ResourceAttributes.ON_ENABLE_ENFORCE);
+    ctx.setAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_PROCESSORS, "1");
+    ctx.setAttribute(ResourceAttributes.GROUP, 
ResourceAttributes.KEY_MIN_MAX_MEMORY_MB, "1");
+    assertDoesNotThrow(() -> xp.callExtensionPoint(log, new Variables(), ctx));
+  }
+}

Reply via email to