From 5d410f9263924d98206e2c58ac17685a613db158 Mon Sep 17 00:00:00 2001 From: Reed von Redwitz Date: Wed, 15 Jul 2026 12:50:20 +0200 Subject: [PATCH] feat(spawn-jdk): tag JDK with target OperatingSystem and Architecture --- .../java/build/spawn/jdk/Architecture.java | 77 +++++++++++++++++ .../src/main/java/build/spawn/jdk/JDK.java | 69 +++++++++++++-- .../java/build/spawn/jdk/OperatingSystem.java | 85 +++++++++++++++++++ .../build/spawn/jdk/ArchitectureTests.java | 57 +++++++++++++ .../build/spawn/jdk/OperatingSystemTests.java | 64 ++++++++++++++ .../spawn/platform/local/jdk/JDKDetector.java | 33 ++++++- .../jdk/JDKDetectorForeignPlatformTests.java | 75 ++++++++++++++++ 7 files changed, 449 insertions(+), 11 deletions(-) create mode 100644 spawn-jdk/src/main/java/build/spawn/jdk/Architecture.java create mode 100644 spawn-jdk/src/main/java/build/spawn/jdk/OperatingSystem.java create mode 100644 spawn-jdk/src/test/java/build/spawn/jdk/ArchitectureTests.java create mode 100644 spawn-jdk/src/test/java/build/spawn/jdk/OperatingSystemTests.java create mode 100644 spawn-local-jdk/src/test/java/build/spawn/platform/local/jdk/JDKDetectorForeignPlatformTests.java diff --git a/spawn-jdk/src/main/java/build/spawn/jdk/Architecture.java b/spawn-jdk/src/main/java/build/spawn/jdk/Architecture.java new file mode 100644 index 0000000..632f896 --- /dev/null +++ b/spawn-jdk/src/main/java/build/spawn/jdk/Architecture.java @@ -0,0 +1,77 @@ +package build.spawn.jdk; + +/*- + * #%L + * Spawn JDK + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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. + * #L% + */ + +import java.util.Locale; + +/** + * The CPU architecture a {@link JDK} was built for. + * + * @author reed.vonredwitz + * @since Jul-2026 + */ +public enum Architecture { + + /** + * 64-bit x86 (aka {@code amd64}, {@code x86_64}). + */ + X86_64, + + /** + * 64-bit ARM (aka {@code aarch64}, {@code arm64}). + */ + AARCH64, + + /** + * An architecture that could not be recognized. + */ + OTHER; + + /** + * Determines the {@link Architecture} for the specified name, typically sourced from the + * {@code OS_ARCH} entry of a JDK's {@code release} file or the {@code os.arch} system property. + * + * @param name the architecture name + * @return the {@link Architecture}, or {@link #OTHER} if it can't be recognized + */ + public static Architecture of(final String name) { + final var lower = name.toLowerCase(Locale.ROOT); + + if (lower.equals("amd64") || lower.equals("x86_64")) { + return X86_64; + } + if (lower.equals("aarch64") || lower.equals("arm64")) { + return AARCH64; + } + + return OTHER; + } + + /** + * Obtains the {@link Architecture} of the currently executing Virtual Machine, based on the + * {@code os.arch} system property. + * + * @return the current {@link Architecture} + */ + public static Architecture current() { + return of(System.getProperty("os.arch", "")); + } +} diff --git a/spawn-jdk/src/main/java/build/spawn/jdk/JDK.java b/spawn-jdk/src/main/java/build/spawn/jdk/JDK.java index 531bcc3..b7abebc 100644 --- a/spawn-jdk/src/main/java/build/spawn/jdk/JDK.java +++ b/spawn-jdk/src/main/java/build/spawn/jdk/JDK.java @@ -44,15 +44,30 @@ public final class JDK */ private final JDKHome home; + /** + * The {@link OperatingSystem} the {@link JDK} was built for. + */ + private final OperatingSystem operatingSystem; + + /** + * The {@link Architecture} the {@link JDK} was built for. + */ + private final Architecture architecture; + /** * Constructs a {@link JDK}. * - * @param version the {@link JDKVersion} - * @param home the {@link JDKHome} + * @param version the {@link JDKVersion} + * @param home the {@link JDKHome} + * @param operatingSystem the {@link OperatingSystem} the {@link JDK} was built for + * @param architecture the {@link Architecture} the {@link JDK} was built for */ - private JDK(final JDKVersion version, final JDKHome home) { + private JDK(final JDKVersion version, final JDKHome home, final OperatingSystem operatingSystem, + final Architecture architecture) { this.version = Objects.requireNonNull(version, "The JDKVersion must not be null"); this.home = Objects.requireNonNull(home, "The JDKHome must not be null"); + this.operatingSystem = Objects.requireNonNull(operatingSystem, "The OperatingSystem must not be null"); + this.architecture = Objects.requireNonNull(architecture, "The Architecture must not be null"); } /** @@ -73,9 +88,28 @@ public JDKHome home() { return this.home; } + /** + * Obtains the {@link OperatingSystem} the {@link JDK} was built for. + * + * @return the {@link OperatingSystem} + */ + public OperatingSystem operatingSystem() { + return this.operatingSystem; + } + + /** + * Obtains the {@link Architecture} the {@link JDK} was built for. + * + * @return the {@link Architecture} + */ + public Architecture architecture() { + return this.architecture; + } + @Override public String toString() { - return "JDK{version=" + this.version + ", home=" + this.home.path() + "}"; + return "JDK{version=" + this.version + ", home=" + this.home.path() + + ", os=" + this.operatingSystem + ", arch=" + this.architecture + "}"; } @Override @@ -91,12 +125,13 @@ public boolean equals(final Object object) { if (!(object instanceof final JDK other)) { return false; } - return Objects.equals(this.version, other.version) && Objects.equals(this.home, other.home); + return Objects.equals(this.version, other.version) && Objects.equals(this.home, other.home) + && this.operatingSystem == other.operatingSystem && this.architecture == other.architecture; } @Override public int hashCode() { - return Objects.hash(this.version, this.home); + return Objects.hash(this.version, this.home, this.operatingSystem, this.architecture); } /** @@ -109,17 +144,33 @@ public int hashCode() { * @return the current {@link JDK} */ public static JDK current() { - return of(JDKVersion.current(), JDKHome.current()); + return of(JDKVersion.current(), JDKHome.current(), OperatingSystem.current(), Architecture.current()); } /** - * Creates a {@link JDK} based on the specified {@link JDKVersion} and {@link JDKHome}. + * Creates a {@link JDK} based on the specified {@link JDKVersion} and {@link JDKHome}, assuming it was + * built for the {@link OperatingSystem} and {@link Architecture} of the currently executing Virtual Machine. * * @param version the {@link JDKVersion} * @param home the {@link JDKHome} * @return a new {@link JDK} */ public static JDK of(final JDKVersion version, final JDKHome home) { - return new JDK(version, home); + return of(version, home, OperatingSystem.current(), Architecture.current()); + } + + /** + * Creates a {@link JDK} based on the specified {@link JDKVersion}, {@link JDKHome}, {@link OperatingSystem} + * and {@link Architecture}. + * + * @param version the {@link JDKVersion} + * @param home the {@link JDKHome} + * @param operatingSystem the {@link OperatingSystem} the {@link JDK} was built for + * @param architecture the {@link Architecture} the {@link JDK} was built for + * @return a new {@link JDK} + */ + public static JDK of(final JDKVersion version, final JDKHome home, final OperatingSystem operatingSystem, + final Architecture architecture) { + return new JDK(version, home, operatingSystem, architecture); } } diff --git a/spawn-jdk/src/main/java/build/spawn/jdk/OperatingSystem.java b/spawn-jdk/src/main/java/build/spawn/jdk/OperatingSystem.java new file mode 100644 index 0000000..d93599b --- /dev/null +++ b/spawn-jdk/src/main/java/build/spawn/jdk/OperatingSystem.java @@ -0,0 +1,85 @@ +package build.spawn.jdk; + +/*- + * #%L + * Spawn JDK + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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. + * #L% + */ + +import java.util.Locale; + +/** + * The operating system a {@link JDK} was built for. + * + * @author reed.vonredwitz + * @since Jul-2026 + */ +public enum OperatingSystem { + + /** + * Linux. + */ + LINUX, + + /** + * macOS. + */ + MAC, + + /** + * Windows. + */ + WINDOWS, + + /** + * An operating system that could not be recognized. + */ + OTHER; + + /** + * Determines the {@link OperatingSystem} for the specified name, typically sourced from the + * {@code OS_NAME} entry of a JDK's {@code release} file or the {@code os.name} system property. + * + * @param name the operating system name + * @return the {@link OperatingSystem}, or {@link #OTHER} if it can't be recognized + */ + public static OperatingSystem of(final String name) { + final var lower = name.toLowerCase(Locale.ROOT); + + if (lower.contains("mac") || lower.contains("darwin")) { + return MAC; + } + if (lower.contains("windows")) { + return WINDOWS; + } + if (lower.contains("linux")) { + return LINUX; + } + + return OTHER; + } + + /** + * Obtains the {@link OperatingSystem} of the currently executing Virtual Machine, based on the + * {@code os.name} system property. + * + * @return the current {@link OperatingSystem} + */ + public static OperatingSystem current() { + return of(System.getProperty("os.name", "")); + } +} diff --git a/spawn-jdk/src/test/java/build/spawn/jdk/ArchitectureTests.java b/spawn-jdk/src/test/java/build/spawn/jdk/ArchitectureTests.java new file mode 100644 index 0000000..d76aa66 --- /dev/null +++ b/spawn-jdk/src/test/java/build/spawn/jdk/ArchitectureTests.java @@ -0,0 +1,57 @@ +package build.spawn.jdk; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Architecture}. + * + * @author reed.vonredwitz + * @since Jul-2026 + */ +class ArchitectureTests { + + /** + * Ensure the common x86-64 spellings are recognized. + */ + @Test + void shouldRecognizeX86_64() { + assertThat(Architecture.of("amd64")).isEqualTo(Architecture.X86_64); + assertThat(Architecture.of("x86_64")).isEqualTo(Architecture.X86_64); + } + + /** + * Ensure the common AArch64 spellings are recognized. + */ + @Test + void shouldRecognizeAarch64() { + assertThat(Architecture.of("aarch64")).isEqualTo(Architecture.AARCH64); + assertThat(Architecture.of("arm64")).isEqualTo(Architecture.AARCH64); + } + + /** + * Ensure an unrecognized architecture name falls back to {@link Architecture#OTHER}. + */ + @Test + void shouldFallBackToOtherForUnrecognizedName() { + assertThat(Architecture.of("riscv64")).isEqualTo(Architecture.OTHER); + } + + /** + * Ensure matching is case-insensitive. + */ + @Test + void shouldMatchCaseInsensitively() { + assertThat(Architecture.of("AMD64")).isEqualTo(Architecture.X86_64); + } + + /** + * Ensure {@link Architecture#current()} matches the {@code os.arch} system property. + */ + @Test + void currentShouldMatchOsArchSystemProperty() { + assertThat(Architecture.current()) + .isEqualTo(Architecture.of(System.getProperty("os.arch", ""))); + } +} diff --git a/spawn-jdk/src/test/java/build/spawn/jdk/OperatingSystemTests.java b/spawn-jdk/src/test/java/build/spawn/jdk/OperatingSystemTests.java new file mode 100644 index 0000000..f9cf02a --- /dev/null +++ b/spawn-jdk/src/test/java/build/spawn/jdk/OperatingSystemTests.java @@ -0,0 +1,64 @@ +package build.spawn.jdk; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OperatingSystem}. + * + * @author reed.vonredwitz + * @since Jul-2026 + */ +class OperatingSystemTests { + + /** + * Ensure Mac-flavored OS names are recognized. + */ + @Test + void shouldRecognizeMac() { + assertThat(OperatingSystem.of("Mac OS X")).isEqualTo(OperatingSystem.MAC); + assertThat(OperatingSystem.of("Darwin")).isEqualTo(OperatingSystem.MAC); + } + + /** + * Ensure Windows-flavored OS names are recognized. + */ + @Test + void shouldRecognizeWindows() { + assertThat(OperatingSystem.of("Windows 11")).isEqualTo(OperatingSystem.WINDOWS); + } + + /** + * Ensure Linux-flavored OS names are recognized. + */ + @Test + void shouldRecognizeLinux() { + assertThat(OperatingSystem.of("Linux")).isEqualTo(OperatingSystem.LINUX); + } + + /** + * Ensure an unrecognized OS name falls back to {@link OperatingSystem#OTHER}. + */ + @Test + void shouldFallBackToOtherForUnrecognizedName() { + assertThat(OperatingSystem.of("SunOS")).isEqualTo(OperatingSystem.OTHER); + } + + /** + * Ensure matching is case-insensitive. + */ + @Test + void shouldMatchCaseInsensitively() { + assertThat(OperatingSystem.of("LINUX")).isEqualTo(OperatingSystem.LINUX); + } + + /** + * Ensure {@link OperatingSystem#current()} matches the {@code os.name} system property. + */ + @Test + void currentShouldMatchOsNameSystemProperty() { + assertThat(OperatingSystem.current()) + .isEqualTo(OperatingSystem.of(System.getProperty("os.name", ""))); + } +} diff --git a/spawn-local-jdk/src/main/java/build/spawn/platform/local/jdk/JDKDetector.java b/spawn-local-jdk/src/main/java/build/spawn/platform/local/jdk/JDKDetector.java index 1a7ae13..ff2eb7e 100644 --- a/spawn-local-jdk/src/main/java/build/spawn/platform/local/jdk/JDKDetector.java +++ b/spawn-local-jdk/src/main/java/build/spawn/platform/local/jdk/JDKDetector.java @@ -23,7 +23,9 @@ import build.base.foundation.Exceptional; import build.base.logging.Logger; import build.base.option.JDKVersion; +import build.spawn.jdk.Architecture; import build.spawn.jdk.JDK; +import build.spawn.jdk.OperatingSystem; import build.spawn.jdk.option.JDKHome; import java.io.IOException; @@ -64,6 +66,19 @@ default Stream paths() { */ Stream detect(); + /** + * Obtains a {@link Stream} of the available {@link JDK}s built for the specified {@link OperatingSystem} + * and {@link Architecture}, e.g. to locate a foreign {@link JDK} staged for cross-target {@code jlink}ing. + * + * @param operatingSystem the required {@link OperatingSystem} + * @param architecture the required {@link Architecture} + * @return a {@link Stream} of matching {@link JDK}s + */ + default Stream detect(final OperatingSystem operatingSystem, final Architecture architecture) { + return detect() + .filter(jdk -> jdk.operatingSystem() == operatingSystem && jdk.architecture() == architecture); + } + /** * Obtains a {@link Stream} of the {@link JDKDetector}s that are available. * @@ -131,8 +146,10 @@ static Exceptional of(final Path path) { return Exceptional.empty(); } - // a Pattern to match JAVA_VERSION="..." in the release file + // patterns to match JAVA_VERSION="...", OS_NAME="..." and OS_ARCH="..." in the release file final var VERSION = Pattern.compile("JAVA_VERSION=\"(.+?)\""); + final var OS_NAME = Pattern.compile("OS_NAME=\"(.+?)\""); + final var OS_ARCH = Pattern.compile("OS_ARCH=\"(.+?)\""); try { final var releaseContent = Files.readString(releaseFile); @@ -142,7 +159,19 @@ static Exceptional of(final Path path) { final var javaVersion = JDKVersion.of(matcher.group(1)); final var javaHome = JDKHome.of(home.toString()); - return Exceptional.of(JDK.of(javaVersion, javaHome)); + // OS_NAME / OS_ARCH describe the platform the JDK was built for, which may differ + // from the host running this detection (e.g. a foreign JDK staged for cross-jlinking) + final var osNameMatcher = OS_NAME.matcher(releaseContent); + final var operatingSystem = osNameMatcher.find() + ? OperatingSystem.of(osNameMatcher.group(1)) + : OperatingSystem.current(); + + final var osArchMatcher = OS_ARCH.matcher(releaseContent); + final var architecture = osArchMatcher.find() + ? Architecture.of(osArchMatcher.group(1)) + : Architecture.current(); + + return Exceptional.of(JDK.of(javaVersion, javaHome, operatingSystem, architecture)); } else { LOGGER.warn("Could not detect Java version from release file at [{0}]", releaseFile); diff --git a/spawn-local-jdk/src/test/java/build/spawn/platform/local/jdk/JDKDetectorForeignPlatformTests.java b/spawn-local-jdk/src/test/java/build/spawn/platform/local/jdk/JDKDetectorForeignPlatformTests.java new file mode 100644 index 0000000..76d1da5 --- /dev/null +++ b/spawn-local-jdk/src/test/java/build/spawn/platform/local/jdk/JDKDetectorForeignPlatformTests.java @@ -0,0 +1,75 @@ +package build.spawn.platform.local.jdk; + +import build.base.foundation.Exceptional; +import build.spawn.jdk.Architecture; +import build.spawn.jdk.JDK; +import build.spawn.jdk.OperatingSystem; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JDKDetector#of(Path)} tagging a detected {@link JDK} with the {@link OperatingSystem} + * and {@link Architecture} it was built for, as opposed to those of the host running detection. + * + * @author reed.vonredwitz + * @since Jul-2026 + */ +class JDKDetectorForeignPlatformTests { + + /** + * Ensure a {@code release} file describing a platform other than the host is honored, so a foreign + * JDK staged for cross-target {@code jlink}ing is tagged correctly rather than assumed to be host-native. + */ + @Test + void shouldTagJDKWithForeignPlatformFromReleaseFile(@TempDir final Path tempDir) throws IOException { + stageJDKHome(tempDir, """ + JAVA_VERSION="21.0.1" + OS_NAME="Mac OS X" + OS_ARCH="aarch64" + """); + + final Exceptional result = JDKDetector.of(tempDir); + + assertThat(result.isPresent()).isTrue(); + + final var jdk = result.orElseThrow(); + assertThat(jdk.operatingSystem()).isEqualTo(OperatingSystem.MAC); + assertThat(jdk.architecture()).isEqualTo(Architecture.AARCH64); + } + + /** + * Ensure a {@code release} file without {@code OS_NAME}/{@code OS_ARCH} entries falls back to the + * host's current {@link OperatingSystem} and {@link Architecture}, preserving prior behavior. + */ + @Test + void shouldFallBackToHostPlatformWhenReleaseFileOmitsOsInfo(@TempDir final Path tempDir) throws IOException { + stageJDKHome(tempDir, """ + JAVA_VERSION="21.0.1" + """); + + final Exceptional result = JDKDetector.of(tempDir); + + assertThat(result.isPresent()).isTrue(); + + final var jdk = result.orElseThrow(); + assertThat(jdk.operatingSystem()).isEqualTo(OperatingSystem.current()); + assertThat(jdk.architecture()).isEqualTo(Architecture.current()); + } + + /** + * Stages a minimal, fake JDK home: a {@code bin/java} placeholder (so {@link JDKDetector#of(Path)} + * accepts it as a JDK home) and the specified {@code release} file content. + */ + private static void stageJDKHome(final Path home, final String releaseContent) throws IOException { + final var bin = home.resolve("bin"); + Files.createDirectories(bin); + Files.createFile(bin.resolve("java")); + Files.writeString(home.resolve("release"), releaseContent); + } +}