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));
+ }
+}