feat: 新增 java run,按 JDK 版本自动套用调优参数运行 Java 应用
- 自动识别 JDK 8/11/17/21 与宿主机 CPU、可用内存;容器内以 cgroup 限额为准;堆默认取可用内存 50%,-Xms 与 -Xmx 同值 - 按版本分支生成参数:JDK 8 用 -Xloggc 一组,JDK 9+ 用 -Xlog:; 堆超过 30g 时自动省略压缩指针参数 - 支持 --jdk/--java/--heap/--xms/--env/--gc-log/--heap-dump-on-oom/ --print-flags/--dry-run;jar 之后的参数原样交给应用 - 前台运行:stdio 直连、退出码透传、SIGINT/SIGTERM 转发给 JVM - BuiltinRequest 增加 Stdin,使内置命令可以读取调用方输入 - 修正帮助中的用法行重复(自定义 Usage 不再追加通用透传尾巴) - 文档:新增 README.java.md,主 README 补充导航与示例
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Resources describes the CPU and memory a process may actually use. Inside a
|
||||
// container the cgroup limits win over the host's totals, so a JVM run in a
|
||||
// 2-core / 4 GiB container is not tuned for the 64-core host underneath it.
|
||||
type Resources struct {
|
||||
CPUs int // usable logical CPUs, at least 1
|
||||
Memory int64 // usable memory in bytes, 0 when unknown
|
||||
Detected string // how Memory was determined, for --print-flags output
|
||||
}
|
||||
|
||||
// hardwareProbe gathers Resources. Both file reads and command execution are
|
||||
// injectable so the detection can be tested on any host.
|
||||
type hardwareProbe struct {
|
||||
GOOS string
|
||||
ReadFile func(string) ([]byte, error)
|
||||
Run func(name string, args ...string) ([]byte, error)
|
||||
NumCPU int
|
||||
}
|
||||
|
||||
func systemHardwareProbe() hardwareProbe {
|
||||
return hardwareProbe{
|
||||
GOOS: runtime.GOOS,
|
||||
ReadFile: os.ReadFile,
|
||||
Run: func(name string, args ...string) ([]byte, error) {
|
||||
return exec.Command(name, args...).Output()
|
||||
},
|
||||
NumCPU: runtime.NumCPU(),
|
||||
}
|
||||
}
|
||||
|
||||
// DetectResources reports the CPUs and memory available to this process.
|
||||
func DetectResources() Resources {
|
||||
return systemHardwareProbe().detect()
|
||||
}
|
||||
|
||||
func (p hardwareProbe) detect() Resources {
|
||||
res := Resources{CPUs: p.NumCPU}
|
||||
if res.CPUs < 1 {
|
||||
res.CPUs = 1
|
||||
}
|
||||
|
||||
switch p.GOOS {
|
||||
case "linux":
|
||||
host := p.linuxMemory()
|
||||
res.Memory, res.Detected = p.linuxCgroupMemory(host)
|
||||
if cpus, ok := p.linuxCgroupCPU(); ok {
|
||||
res.CPUs = cpus
|
||||
}
|
||||
case "darwin":
|
||||
res.Memory = p.darwinMemory()
|
||||
res.Detected = "hw.memsize"
|
||||
case "windows":
|
||||
res.Memory = p.windowsMemory()
|
||||
res.Detected = "GlobalMemoryStatusEx"
|
||||
}
|
||||
if res.Memory < 0 {
|
||||
res.Memory = 0
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// linuxMemory reads MemTotal from /proc/meminfo.
|
||||
func (p hardwareProbe) linuxMemory() int64 {
|
||||
data, err := p.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parseMemTotal(string(data))
|
||||
}
|
||||
|
||||
// parseMemTotal returns the MemTotal line in bytes (the file reports KiB).
|
||||
func parseMemTotal(data string) int64 {
|
||||
scanner := bufio.NewScanner(strings.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 || fields[0] != "MemTotal:" {
|
||||
continue
|
||||
}
|
||||
kib, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return kib * 1024
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// linuxCgroupMemory narrows the host total to the container's limit. An
|
||||
// "unlimited" limit (very large number, or the "max" keyword) is ignored.
|
||||
func (p hardwareProbe) linuxCgroupMemory(host int64) (int64, string) {
|
||||
// cgroup v2
|
||||
if limit, ok := p.readCgroupLimit("/sys/fs/cgroup/memory.max"); ok {
|
||||
return minNonZero(host, limit), "cgroup v2 memory.max"
|
||||
}
|
||||
// cgroup v1
|
||||
if limit, ok := p.readCgroupLimit("/sys/fs/cgroup/memory/memory.limit_in_bytes"); ok {
|
||||
return minNonZero(host, limit), "cgroup v1 memory.limit_in_bytes"
|
||||
}
|
||||
return host, "/proc/meminfo"
|
||||
}
|
||||
|
||||
// readCgroupLimit reads a cgroup memory limit, returning false when the file is
|
||||
// missing or the limit means "unlimited".
|
||||
func (p hardwareProbe) readCgroupLimit(path string) (int64, bool) {
|
||||
data, err := p.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parseCgroupLimit(strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
// unlimitedCgroup is the sentinel cgroup v1 writes for "no limit" (2^63-4096,
|
||||
// earlier kernels: a page-aligned near-max value).
|
||||
const unlimitedCgroup = int64(1) << 62
|
||||
|
||||
// parseCgroupLimit interprets a cgroup memory limit value.
|
||||
func parseCgroupLimit(value string) (int64, bool) {
|
||||
if value == "" || value == "max" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || n <= 0 || n >= unlimitedCgroup {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// linuxCgroupCPU applies the container's CPU quota, so a container limited to
|
||||
// 2 CPUs is not tuned for the host's 64.
|
||||
func (p hardwareProbe) linuxCgroupCPU() (int, bool) {
|
||||
// cgroup v2: "<quota> <period>" or "max <period>"
|
||||
if data, err := p.ReadFile("/sys/fs/cgroup/cpu.max"); err == nil {
|
||||
fields := strings.Fields(strings.TrimSpace(string(data)))
|
||||
if len(fields) >= 1 && fields[0] != "max" {
|
||||
quota, qerr := strconv.ParseInt(fields[0], 10, 64)
|
||||
period := int64(100000)
|
||||
if len(fields) >= 2 {
|
||||
if v, perr := strconv.ParseInt(fields[1], 10, 64); perr == nil && v > 0 {
|
||||
period = v
|
||||
}
|
||||
}
|
||||
if qerr == nil && quota > 0 && period > 0 {
|
||||
return ceilDiv(quota, period), true
|
||||
}
|
||||
}
|
||||
}
|
||||
// cgroup v1: quota in cpu.cfs_quota_us, period in cpu.cfs_period_us
|
||||
quotaData, err := p.ReadFile("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
quota, err := strconv.ParseInt(strings.TrimSpace(string(quotaData)), 10, 64)
|
||||
if err != nil || quota <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
period := int64(100000)
|
||||
if periodData, perr := p.ReadFile("/sys/fs/cgroup/cpu/cpu.cfs_period_us"); perr == nil {
|
||||
if v, perr := strconv.ParseInt(strings.TrimSpace(string(periodData)), 10, 64); perr == nil && v > 0 {
|
||||
period = v
|
||||
}
|
||||
}
|
||||
return ceilDiv(quota, period), true
|
||||
}
|
||||
|
||||
// darwinMemory asks sysctl for hw.memsize.
|
||||
func (p hardwareProbe) darwinMemory() int64 {
|
||||
out, err := p.Run("sysctl", "-n", "hw.memsize")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// windowsMemory reads TotalPhys from GlobalMemoryStatusEx.
|
||||
func (p hardwareProbe) windowsMemory() int64 {
|
||||
out, err := p.Run("wmic", "ComputerSystem", "get", "TotalPhysicalMemory", "/value")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parseWMICMemory(string(out))
|
||||
}
|
||||
|
||||
// parseWMICMemory extracts TotalPhysicalMemory from wmic key=value output.
|
||||
func parseWMICMemory(out string) int64 {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !ok || strings.TrimSpace(key) != "TotalPhysicalMemory" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// minNonZero returns the smaller positive value.
|
||||
func minNonZero(a, b int64) int64 {
|
||||
if a <= 0 {
|
||||
return b
|
||||
}
|
||||
if b <= 0 {
|
||||
return a
|
||||
}
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ceilDiv is integer division rounding up, used for CPU quotas that are not a
|
||||
// whole number of cores (quota 150000 / period 100000 = 2 CPUs).
|
||||
func ceilDiv(a, b int64) int {
|
||||
n := (a + b - 1) / b
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeHardwareProbe builds a probe backed by an in-memory file table.
|
||||
func fakeHardwareProbe(goos string, cpus int, files map[string]string) hardwareProbe {
|
||||
return hardwareProbe{
|
||||
GOOS: goos,
|
||||
NumCPU: cpus,
|
||||
ReadFile: func(path string) ([]byte, error) {
|
||||
content, ok := files[path]
|
||||
if !ok {
|
||||
return nil, errors.New("no such file")
|
||||
}
|
||||
return []byte(content), nil
|
||||
},
|
||||
Run: func(name string, args ...string) ([]byte, error) {
|
||||
if content, ok := files[name+" "+joinArgs(args)]; ok {
|
||||
return []byte(content), nil
|
||||
}
|
||||
return nil, errors.New("command not found")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func joinArgs(args []string) string {
|
||||
out := ""
|
||||
for i, a := range args {
|
||||
if i > 0 {
|
||||
out += " "
|
||||
}
|
||||
out += a
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDetectResourcesLinuxHost(t *testing.T) {
|
||||
p := fakeHardwareProbe("linux", 8, map[string]string{
|
||||
"/proc/meminfo": "MemTotal: 16384000 kB\nMemFree: 100 kB\n",
|
||||
})
|
||||
res := p.detect()
|
||||
if res.CPUs != 8 {
|
||||
t.Errorf("CPUs = %d, want 8", res.CPUs)
|
||||
}
|
||||
if want := int64(16384000) * 1024; res.Memory != want {
|
||||
t.Errorf("Memory = %d, want %d", res.Memory, want)
|
||||
}
|
||||
if res.Detected != "/proc/meminfo" {
|
||||
t.Errorf("Detected = %q", res.Detected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectResourcesLinuxCgroupV2Wins(t *testing.T) {
|
||||
p := fakeHardwareProbe("linux", 64, map[string]string{
|
||||
"/proc/meminfo": "MemTotal: 65536000 kB\n",
|
||||
"/sys/fs/cgroup/memory.max": "4294967296\n", // 4 GiB
|
||||
"/sys/fs/cgroup/cpu.max": "200000 100000\n", // 2 CPUs
|
||||
})
|
||||
res := p.detect()
|
||||
if res.Memory != 4<<30 {
|
||||
t.Errorf("Memory = %d, want 4GiB (cgroup limit must win over the host)", res.Memory)
|
||||
}
|
||||
if res.Detected != "cgroup v2 memory.max" {
|
||||
t.Errorf("Detected = %q", res.Detected)
|
||||
}
|
||||
if res.CPUs != 2 {
|
||||
t.Errorf("CPUs = %d, want 2 (cgroup quota)", res.CPUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectResourcesLinuxCgroupUnlimited(t *testing.T) {
|
||||
p := fakeHardwareProbe("linux", 4, map[string]string{
|
||||
"/proc/meminfo": "MemTotal: 8192000 kB\n",
|
||||
"/sys/fs/cgroup/memory.max": "max\n",
|
||||
"/sys/fs/cgroup/cpu.max": "max 100000\n",
|
||||
})
|
||||
res := p.detect()
|
||||
if want := int64(8192000) * 1024; res.Memory != want {
|
||||
t.Errorf("Memory = %d, want the host total %d", res.Memory, want)
|
||||
}
|
||||
if res.CPUs != 4 {
|
||||
t.Errorf("CPUs = %d, want the host count 4", res.CPUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectResourcesLinuxCgroupV1(t *testing.T) {
|
||||
p := fakeHardwareProbe("linux", 32, map[string]string{
|
||||
"/proc/meminfo": "MemTotal: 33554432 kB\n",
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "2147483648\n", // 2 GiB
|
||||
"/sys/fs/cgroup/cpu/cpu.cfs_quota_us": "150000\n",
|
||||
"/sys/fs/cgroup/cpu/cpu.cfs_period_us": "100000\n",
|
||||
})
|
||||
res := p.detect()
|
||||
if res.Memory != 2<<30 {
|
||||
t.Errorf("Memory = %d, want 2GiB", res.Memory)
|
||||
}
|
||||
if res.CPUs != 2 {
|
||||
t.Errorf("CPUs = %d, want 2 (1.5 CPU quota rounds up)", res.CPUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectResourcesDarwin(t *testing.T) {
|
||||
p := fakeHardwareProbe("darwin", 10, map[string]string{
|
||||
"sysctl -n hw.memsize": "17179869184\n", // 16 GiB
|
||||
})
|
||||
res := p.detect()
|
||||
if res.Memory != 16<<30 {
|
||||
t.Errorf("Memory = %d, want 16GiB", res.Memory)
|
||||
}
|
||||
if res.Detected != "hw.memsize" || res.CPUs != 10 {
|
||||
t.Errorf("res = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectResourcesWindows(t *testing.T) {
|
||||
p := fakeHardwareProbe("windows",
|
||||
12,
|
||||
map[string]string{
|
||||
"wmic ComputerSystem get TotalPhysicalMemory /value": "\nTotalPhysicalMemory=8589934592\n\n",
|
||||
})
|
||||
res := p.detect()
|
||||
if res.Memory != 8<<30 {
|
||||
t.Errorf("Memory = %d, want 8GiB", res.Memory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectResourcesUnknownMemory(t *testing.T) {
|
||||
p := fakeHardwareProbe("linux", 2, map[string]string{})
|
||||
res := p.detect()
|
||||
if res.Memory != 0 {
|
||||
t.Errorf("Memory = %d, want 0 when nothing can be read", res.Memory)
|
||||
}
|
||||
if res.CPUs != 2 {
|
||||
t.Errorf("CPUs = %d, want 2", res.CPUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMemTotal(t *testing.T) {
|
||||
if got := parseMemTotal("MemTotal: 16384000 kB\n"); got != int64(16384000)*1024 {
|
||||
t.Errorf("parseMemTotal = %d", got)
|
||||
}
|
||||
if got := parseMemTotal("garbage\n"); got != 0 {
|
||||
t.Errorf("parseMemTotal(garbage) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCgroupLimit(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{"max", 0, false},
|
||||
{"", 0, false},
|
||||
{"9223372036854771712", 0, false}, // cgroup v1 "unlimited"
|
||||
{"4294967296", 4294967296, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := parseCgroupLimit(tc.in)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Errorf("parseCgroupLimit(%q) = (%d, %v), want (%d, %v)", tc.in, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCeilDiv(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b int64
|
||||
want int
|
||||
}{
|
||||
{200000, 100000, 2},
|
||||
{150000, 100000, 2},
|
||||
{50000, 100000, 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ceilDiv(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("ceilDiv(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxCgroupCPUQuotaBeatsHostCores(t *testing.T) {
|
||||
// A 64-core host with a container limited to 4 CPUs must report 4.
|
||||
p := fakeHardwareProbe("linux", 64, map[string]string{
|
||||
"/proc/meminfo": "MemTotal: 65536000 kB\n",
|
||||
"/sys/fs/cgroup/cpu.max": "400000 100000\n",
|
||||
})
|
||||
if res := p.detect(); res.CPUs != 4 {
|
||||
t.Fatalf("CPUs = %d, want 4 (cgroup quota)", res.CPUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxCgroupCPUFractionalQuotaRoundsUp(t *testing.T) {
|
||||
// 50% of one CPU still needs one core's worth of threads.
|
||||
p := fakeHardwareProbe("linux", 8, map[string]string{
|
||||
"/proc/meminfo": "MemTotal: 8192000 kB\n",
|
||||
"/sys/fs/cgroup/cpu.max": "50000 100000\n",
|
||||
})
|
||||
if res := p.detect(); res.CPUs != 1 {
|
||||
t.Fatalf("CPUs = %d, want 1", res.CPUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxCgroupCPUUnlimitedKeepsHostCores(t *testing.T) {
|
||||
p := fakeHardwareProbe("linux", 12, map[string]string{
|
||||
"/proc/meminfo": "MemTotal: 8192000 kB\n",
|
||||
"/sys/fs/cgroup/cpu.max": "max 100000\n",
|
||||
})
|
||||
if res := p.detect(); res.CPUs != 12 {
|
||||
t.Fatalf("CPUs = %d, want the host count 12", res.CPUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxCgroupV1QuotaBeatsHostCores(t *testing.T) {
|
||||
p := fakeHardwareProbe("linux", 32, map[string]string{
|
||||
"/proc/meminfo": "MemTotal: 8192000 kB\n",
|
||||
"/sys/fs/cgroup/cpu/cpu.cfs_quota_us": "300000\n",
|
||||
"/sys/fs/cgroup/cpu/cpu.cfs_period_us": "100000\n",
|
||||
})
|
||||
if res := p.detect(); res.CPUs != 3 {
|
||||
t.Fatalf("CPUs = %d, want 3", res.CPUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxCgroupV1NegativeQuotaMeansUnlimited(t *testing.T) {
|
||||
p := fakeHardwareProbe("linux", 6, map[string]string{
|
||||
"/proc/meminfo": "MemTotal: 8192000 kB\n",
|
||||
"/sys/fs/cgroup/cpu/cpu.cfs_quota_us": "-1\n",
|
||||
})
|
||||
if res := p.detect(); res.CPUs != 6 {
|
||||
t.Fatalf("CPUs = %d, want the host count 6 (quota -1 means unlimited)", res.CPUs)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeJavaProbe returns canned output for specific java invocations.
|
||||
func fakeJavaProbe(versionStderr, propsStderr string) javaProbe {
|
||||
return javaProbe{
|
||||
LookPath: func(name string) (string, error) {
|
||||
if name != "java" {
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
return "/usr/bin/java", nil
|
||||
},
|
||||
Run: func(name string, args ...string) ([]byte, []byte, error) {
|
||||
for _, a := range args {
|
||||
if a == "-XshowSettings:properties" {
|
||||
return nil, []byte(propsStderr), nil
|
||||
}
|
||||
}
|
||||
return nil, []byte(versionStderr), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectJavaVersions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
banner string
|
||||
want string
|
||||
rawWant string
|
||||
}{
|
||||
{"jdk8", `java version "1.8.0_392"` + "\n", "8", "1.8.0_392"},
|
||||
{"jdk11", `openjdk version "11.0.21" 2023-10-17`, "11", "11.0.21"},
|
||||
{"jdk17", `openjdk version "17.0.9" 2023-10-17`, "17", "17.0.9"},
|
||||
{"jdk21", `openjdk version "21.0.1" 2023-10-17 LTS`, "21", "21.0.1"},
|
||||
{"vendor banner without 'version'", `openjdk 21.0.1 2023-10-17 LTS`, "21", "21.0.1"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
info := fakeJavaProbe(tc.banner, "").detectAt("/usr/bin/java")
|
||||
if info.Version != tc.want {
|
||||
t.Errorf("Version = %q, want %q", info.Version, tc.want)
|
||||
}
|
||||
if info.Raw != tc.rawWant {
|
||||
t.Errorf("Raw = %q, want %q", info.Raw, tc.rawWant)
|
||||
}
|
||||
if info.Path != "/usr/bin/java" {
|
||||
t.Errorf("Path = %q", info.Path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectJavaFallsBackToProperties(t *testing.T) {
|
||||
// An unparsable banner: kopsc must use the properties dump instead.
|
||||
props := " java.specification.version = 17\n java.version = 17.0.9\n"
|
||||
info := fakeJavaProbe("weird banner without a version", props).detectAt("/opt/jdk/bin/java")
|
||||
if info.Version != "17" {
|
||||
t.Fatalf("Version = %q, want 17 from java.specification.version", info.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectJavaMissing(t *testing.T) {
|
||||
probe := javaProbe{
|
||||
LookPath: func(string) (string, error) { return "", errors.New("not found") },
|
||||
}
|
||||
if info := probe.detect(); info.Path != "" || info.Version != "" {
|
||||
t.Fatalf("info = %+v, want empty when java is not installed", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeJavaVersion(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"1.8.0_392": "8",
|
||||
"1.8.0": "8",
|
||||
"8": "8",
|
||||
"11.0.21": "11",
|
||||
"17": "17",
|
||||
"21.0.1": "21",
|
||||
"24.0.2": "24",
|
||||
"": "",
|
||||
"garbage": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeJavaVersion(in); got != want {
|
||||
t.Errorf("normalizeJavaVersion(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJavaRelease(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`java version "1.8.0_392"`: "1.8.0_392",
|
||||
`openjdk version "17.0.9"`: "17.0.9",
|
||||
`openjdk 21.0.1 2023-10-17 LTS`: "21.0.1",
|
||||
`no version here`: "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := parseJavaRelease(in); got != want {
|
||||
t.Errorf("parseJavaRelease(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbesAreInjectable documents the contract that keeps these detectors
|
||||
// testable: nothing in detect()/detectAt() touches the real host directly.
|
||||
func TestProbesAreInjectable(t *testing.T) {
|
||||
// A hardware probe with no dependencies wired must not panic and must
|
||||
// report what the injected values say.
|
||||
hw := hardwareProbe{
|
||||
GOOS: "linux",
|
||||
NumCPU: 3,
|
||||
ReadFile: func(string) ([]byte, error) {
|
||||
return nil, errors.New("denied")
|
||||
},
|
||||
}
|
||||
if res := hw.detect(); res.CPUs != 3 || res.Memory != 0 {
|
||||
t.Fatalf("injected probe = %+v, want CPUs 3 and unknown memory", res)
|
||||
}
|
||||
|
||||
// A java probe whose LookPath and Run are both faked needs no JVM.
|
||||
jp := javaProbe{
|
||||
LookPath: func(string) (string, error) { return "/fake/java", nil },
|
||||
Run: func(string, ...string) ([]byte, []byte, error) {
|
||||
return nil, []byte(`openjdk version "21.0.1"`), nil
|
||||
},
|
||||
}
|
||||
if info := jp.detect(); info.Version != "21" || info.Path != "/fake/java" {
|
||||
t.Fatalf("injected java probe = %+v", info)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Java describes the JVM kopsc will launch.
|
||||
type Java struct {
|
||||
Path string // resolved executable, e.g. "/usr/bin/java"; empty when not found
|
||||
Version string // normalised release: "8", "11", "17", "21" or "" when unknown
|
||||
Raw string // the full version string, e.g. "17.0.9" (for messages)
|
||||
}
|
||||
|
||||
// javaProbe gathers Java. Executable lookup and command execution are
|
||||
// injectable so detection can be tested without a JVM installed.
|
||||
type javaProbe struct {
|
||||
LookPath func(string) (string, error)
|
||||
Run func(name string, args ...string) (stdout, stderr []byte, err error)
|
||||
}
|
||||
|
||||
func systemJavaProbe() javaProbe {
|
||||
return javaProbe{
|
||||
LookPath: exec.LookPath,
|
||||
Run: func(name string, args ...string) ([]byte, []byte, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
var stdout, stderr strings.Builder
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
return []byte(stdout.String()), []byte(stderr.String()), err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DetectJava finds the java executable and its release version.
|
||||
func DetectJava() Java {
|
||||
return systemJavaProbe().detect()
|
||||
}
|
||||
|
||||
// DetectJavaAt inspects a specific java executable (used by --java).
|
||||
func DetectJavaAt(path string) Java {
|
||||
probe := systemJavaProbe()
|
||||
return probe.detectAt(path)
|
||||
}
|
||||
|
||||
func (p javaProbe) detect() Java {
|
||||
path, err := p.LookPath("java")
|
||||
if err != nil {
|
||||
return Java{}
|
||||
}
|
||||
return p.detectAt(path)
|
||||
}
|
||||
|
||||
func (p javaProbe) detectAt(path string) Java {
|
||||
info := Java{Path: path}
|
||||
|
||||
// `java -version` writes everything to stderr.
|
||||
stdout, stderr, _ := p.Run(path, "-version")
|
||||
raw := firstNonEmpty(string(stderr), string(stdout))
|
||||
info.Raw = parseJavaRelease(raw)
|
||||
info.Version = normalizeJavaVersion(info.Raw)
|
||||
|
||||
// Fall back to the properties dump, which is stable across vendors and
|
||||
// also available on JVMs that print an unexpected -version banner.
|
||||
if info.Version == "" {
|
||||
out, errOut, _ := p.Run(path, "-XshowSettings:properties", "-version")
|
||||
raw = firstNonEmpty(string(errOut), string(out))
|
||||
if v := parseJavaProperty(raw, "java.specification.version"); v != "" {
|
||||
info.Raw = v
|
||||
info.Version = normalizeJavaVersion(v)
|
||||
} else if v := parseJavaProperty(raw, "java.version"); v != "" {
|
||||
info.Raw = v
|
||||
info.Version = normalizeJavaVersion(v)
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// javaVersionPattern matches the quoted version in `java -version` output:
|
||||
//
|
||||
// openjdk version "17.0.9" 2023-10-17
|
||||
// java version "1.8.0_392"
|
||||
// openjdk 21.0.1 2023-10-17 LTS (some vendors omit "version")
|
||||
var javaVersionPattern = regexp.MustCompile(`(?:version\s+)?"?(\d+(?:\.\d+)*(?:[._]\d+)?)"?`)
|
||||
|
||||
// parseJavaRelease extracts the release string from `java -version` output.
|
||||
func parseJavaRelease(banner string) string {
|
||||
m := javaVersionPattern.FindStringSubmatch(banner)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// parseJavaProperty reads one java.*system property from
|
||||
// `java -XshowSettings:properties -version` output:
|
||||
//
|
||||
// java.specification.version = 17
|
||||
func parseJavaProperty(output, key string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
name, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !ok || strings.TrimSpace(name) != key {
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeJavaVersion maps a release string to the major feature version:
|
||||
// "1.8.0_392" -> "8", "17.0.9" -> "17", "21" -> "21". Versions kopsc has no
|
||||
// tuning profile for are still returned, so callers can reject them with a
|
||||
// helpful message.
|
||||
func normalizeJavaVersion(release string) string {
|
||||
release = strings.TrimSpace(release)
|
||||
if release == "" {
|
||||
return ""
|
||||
}
|
||||
// 1.8.0_392 / 1.8.0
|
||||
if strings.HasPrefix(release, "1.") {
|
||||
rest := strings.TrimPrefix(release, "1.")
|
||||
major := rest
|
||||
if i := strings.IndexAny(rest, "._"); i > 0 {
|
||||
major = rest[:i]
|
||||
}
|
||||
if n, err := strconv.Atoi(major); err == nil {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
major := release
|
||||
if i := strings.IndexAny(release, ".-_"); i > 0 {
|
||||
major = release[:i]
|
||||
}
|
||||
if n, err := strconv.Atoi(major); err == nil {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user