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,78 @@
|
|||||||
|
# Java 应用(`kopsc java <子命令>`)
|
||||||
|
|
||||||
|
> 返回 [README.md](README.md)
|
||||||
|
|
||||||
|
按 JDK 版本自动套用调优参数并前台运行 Java 应用。你不需要逐条抄 JVM 参数,
|
||||||
|
kopsc 会根据宿主机(或容器)的实际资源算出堆大小,并按 JDK 版本选择对应的 GC 日志写法。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kopsc java run app.jar # 一键启动,参数自动生成
|
||||||
|
kopsc java run app.jar -- --spring.profiles.active=prod # 应用参数写在 jar 之后
|
||||||
|
kopsc java run --jdk 17 --print-flags app.jar # 只打印最终 java 命令行
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行应用(java.run)
|
||||||
|
|
||||||
|
`kopsc java run <app.jar> [-- 应用参数...]` 等价于用一组业界常用参数执行
|
||||||
|
`java <flags> -jar <app.jar> <应用参数>`,并在前台运行:日志直接打印到终端,Ctrl+C 可停止,
|
||||||
|
退出码原样返回给调用方。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kopsc java run myapp.jar
|
||||||
|
kopsc java run myapp.jar -- --server.port=9000 --spring.profiles.active=prod
|
||||||
|
kopsc java run --jdk 11 --heap 4g myapp.jar
|
||||||
|
kopsc java run --env staging --gc-log /var/log/app myapp.jar
|
||||||
|
kopsc java run --dry-run myapp.jar # 只看将执行的命令,不启动
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自动识别的内容
|
||||||
|
|
||||||
|
| 项目 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| JDK 版本 | 执行 `java -version` 解析版本,识别不到时回退 `-XshowSettings:properties`;支持 **8 / 11 / 17 / 21**,其它版本会提示用 `--jdk` 指定 |
|
||||||
|
| CPU 核数 | 取可用逻辑核数;容器内优先采用 cgroup 配额(`cpu.max` / `cfs_quota_us`) |
|
||||||
|
| 内存 | Linux 读 `/proc/meminfo`(容器内以 cgroup `memory.max` / `memory.limit_in_bytes` 为准)、macOS 用 `sysctl hw.memsize`、Windows 用 `wmic` |
|
||||||
|
| 堆大小 | 默认取可用内存的 **50%**,`-Xms` 与 `-Xmx` 同值;可用 `--heap` / `--xms` 覆盖 |
|
||||||
|
| 直接内存 | `-XX:MaxDirectMemorySize` 取堆的 1/4,最低 64m |
|
||||||
|
| 配置环境 | `-Dconfig.file=application-<env>.yaml`,来源依次为 `--env`、环境变量 `APP_ENV`、默认 `prod` |
|
||||||
|
|
||||||
|
### 生成的参数
|
||||||
|
|
||||||
|
| 类别 | 参数 |
|
||||||
|
| --- | --- |
|
||||||
|
| 通用 | `-server`、`-Xss256k`、`-XX:+SegmentedCodeCache`、`-XX:+ExplicitGCInvokesConcurrent`、`-Djava.security.egd=file:/dev/./urandom` |
|
||||||
|
| 堆 | `-Xmx<size>`、`-Xms<size>`、`-XX:MaxDirectMemorySize=<size>` |
|
||||||
|
| 压缩指针 | `-XX:+UseCompressedOops`、`-XX:+UseCompressedClassPointers`;**堆超过 30g 时自动省略** |
|
||||||
|
| GC | `-XX:+UseG1GC` |
|
||||||
|
| GC 日志(JDK 9+) | `-Xlog:gc*,safepoint:<dir>/gc.log:time,uptime:filecount=100,filesize=50M` |
|
||||||
|
| GC 日志(JDK 8) | `-verbose:gc`、`-Xloggc:<dir>/gc.log`、`-XX:+PrintGCDetails`、`-XX:+PrintGCDateStamps`、`-XX:+UseGCLogFileRotation`、`-XX:NumberOfGCLogFiles=100`、`-XX:GCLogFileSize=50M` |
|
||||||
|
| 命令回显 | `-XX:+PrintCommandLineFlags` |
|
||||||
|
| 可选 | `-XX:+HeapDumpOnOutOfMemoryError`(`--heap-dump-on-oom`) |
|
||||||
|
|
||||||
|
> **JDK 8 与 9+ 的 GC 日志写法不兼容**:`-Xlog:` 是 JDK 9 引入的,JDK 8 用它会导致 JVM 直接拒绝启动。
|
||||||
|
> kopsc 按检测到的版本自动切换,所以同一份启动命令在 JDK 8 和 JDK 21 上都能跑。
|
||||||
|
|
||||||
|
### 选项
|
||||||
|
|
||||||
|
| 选项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `<app.jar>` | 应用 jar,必填 |
|
||||||
|
| `-- <应用参数>...` | 交给应用自身的参数;写在 jar 之后的所有内容也会原样传给应用 |
|
||||||
|
| `--jdk <8\|11\|17\|21>` | 指定按哪个 JDK 版本的方案生成参数;可写 `1.8`、`jdk17` |
|
||||||
|
| `--java <路径>` | 指定使用的 java 可执行文件,默认取 PATH 中的 `java` |
|
||||||
|
| `--heap <大小>` / `--xmx <大小>` | 指定堆上限,如 `4g`、`512m`;默认取可用内存 50% |
|
||||||
|
| `--xms <大小>` | 指定初始堆,默认与堆上限相同 |
|
||||||
|
| `--env <环境>` | 指定配置环境,生成 `-Dconfig.file=application-<环境>.yaml` |
|
||||||
|
| `--gc-log <目录>` | 开启 GC 日志并指定输出目录;默认不开启 |
|
||||||
|
| `--heap-dump-on-oom` | OOM 时导出堆转储 |
|
||||||
|
| `--print-flags` | 只打印最终 java 命令行(一行),可直接复制到 Dockerfile / systemd |
|
||||||
|
| `-q`, `--quiet` | 不打印启动摘要 |
|
||||||
|
| `--dry-run` | 只展示将执行的命令与资源判定,不启动进程 |
|
||||||
|
|
||||||
|
### 注意
|
||||||
|
|
||||||
|
- 选项必须写在 **jar 之前**;jar 之后的内容全部视为应用参数,所以 `--spring.profiles.active=prod`
|
||||||
|
不会被 kopsc 当成自己的选项。
|
||||||
|
- 应用参数里出现的 `-Xmx`、`-D...` 只是应用自己的参数,kopsc 不会解析它们。
|
||||||
|
- 前台运行依赖终端;长时间运行的场景请配合 `kopsc proc nohup` 或容器编排使用。
|
||||||
|
- `--gc-log` 指定的目录不存在时会自动创建。
|
||||||
@@ -91,6 +91,10 @@ kopsc proc nohup ./server # 后台运行命令,输出写日志(
|
|||||||
kopsc proc wait nginx # 每秒检查一次,等 nginx 退出后再继续
|
kopsc proc wait nginx # 每秒检查一次,等 nginx 退出后再继续
|
||||||
kopsc proc wait java app.jar --timeout 60 # 多个关键字逻辑与;最多等 60 秒
|
kopsc proc wait java app.jar --timeout 60 # 多个关键字逻辑与;最多等 60 秒
|
||||||
|
|
||||||
|
kopsc java run app.jar # 按 JDK 版本自动套调优参数并运行
|
||||||
|
kopsc java run app.jar -- --spring.profiles.active=prod
|
||||||
|
kopsc java run --jdk 17 --print-flags app.jar # 只看最终 java 命令行
|
||||||
|
|
||||||
kopsc list # 全部命令及当前平台可用性
|
kopsc list # 全部命令及当前平台可用性
|
||||||
kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因
|
kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因
|
||||||
kopsc explain docker clearsafe # 同上,空格写法也支持
|
kopsc explain docker clearsafe # 同上,空格写法也支持
|
||||||
@@ -139,6 +143,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、
|
|||||||
| **disk** | 磁盘与文件占用:哪个目录、哪个文件最占空间 | [README.disk.md](README.disk.md) |
|
| **disk** | 磁盘与文件占用:哪个目录、哪个文件最占空间 | [README.disk.md](README.disk.md) |
|
||||||
| **diagnose** | 容器 / Pod 状态根因诊断(自动识别 Docker 与 K8s) | [README.diagnose.md](README.diagnose.md) |
|
| **diagnose** | 容器 / Pod 状态根因诊断(自动识别 Docker 与 K8s) | [README.diagnose.md](README.diagnose.md) |
|
||||||
| **proc** | 本机进程:后台运行命令并写日志、等待进程退出 | [README.proc.md](README.proc.md) |
|
| **proc** | 本机进程:后台运行命令并写日志、等待进程退出 | [README.proc.md](README.proc.md) |
|
||||||
|
| **java** | Java 应用:按 JDK 版本套用调优参数并运行 jar | [README.java.md](README.java.md) |
|
||||||
| **pkg** | 系统软件包:刷新包管理器索引 | [README.pkg.md](README.pkg.md) |
|
| **pkg** | 系统软件包:刷新包管理器索引 | [README.pkg.md](README.pkg.md) |
|
||||||
|
|
||||||
辅助命令(不涉及具体资源,直接用即可):
|
辅助命令(不涉及具体资源,直接用即可):
|
||||||
@@ -164,6 +169,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、
|
|||||||
| 排障 | `kopsc diagnose web` |
|
| 排障 | `kopsc diagnose web` |
|
||||||
| 后台跑服务 | `kopsc proc nohup ./server` |
|
| 后台跑服务 | `kopsc proc nohup ./server` |
|
||||||
| 等进程退出 | `kopsc proc wait nginx`、`kopsc proc wait --pid 1234 --timeout 30` |
|
| 等进程退出 | `kopsc proc wait nginx`、`kopsc proc wait --pid 1234 --timeout 30` |
|
||||||
|
| 跑 Java 应用 | `kopsc java run app.jar`、`kopsc java run --jdk 17 --print-flags app.jar` |
|
||||||
| 刷新软件源 | `kopsc pkg refreshindex` |
|
| 刷新软件源 | `kopsc pkg refreshindex` |
|
||||||
|
|
||||||
## 平台识别
|
## 平台识别
|
||||||
@@ -225,6 +231,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、
|
|||||||
| diagnose | [`README.diagnose.md`](README.diagnose.md) |
|
| diagnose | [`README.diagnose.md`](README.diagnose.md) |
|
||||||
| proc | [`README.proc.md`](README.proc.md) |
|
| proc | [`README.proc.md`](README.proc.md) |
|
||||||
| pkg | [`README.pkg.md`](README.pkg.md) |
|
| pkg | [`README.pkg.md`](README.pkg.md) |
|
||||||
|
| java | [`README.java.md`](README.java.md) |
|
||||||
|
|
||||||
新分组需要新建 `README.<分组>.md`,并在本文件「命令一览」的表格里加一行链接。
|
新分组需要新建 `README.<分组>.md`,并在本文件「命令一览」的表格里加一行链接。
|
||||||
每条命令至少要写清:**作用**、**实际执行什么**、**示例**;有独有选项时补选项表。
|
每条命令至少要写清:**作用**、**实际执行什么**、**示例**;有独有选项时补选项表。
|
||||||
|
|||||||
+26
-4
@@ -221,6 +221,7 @@ var groupDescriptions = map[string]string{
|
|||||||
"net": "网络与端口:端口被谁占用、本机监听了哪些端口",
|
"net": "网络与端口:端口被谁占用、本机监听了哪些端口",
|
||||||
"pkg": "系统软件包:刷新包管理器(dnf/apt/apk/brew/winget)的软件源索引",
|
"pkg": "系统软件包:刷新包管理器(dnf/apt/apk/brew/winget)的软件源索引",
|
||||||
"proc": "本机进程:在后台运行命令并写日志,关掉终端也不退出",
|
"proc": "本机进程:在后台运行命令并写日志,关掉终端也不退出",
|
||||||
|
"java": "Java 应用:按 JDK 版本自动套用调优参数并前台运行 jar",
|
||||||
}
|
}
|
||||||
|
|
||||||
// newGroup builds an intermediate command such as `kopsc disk`.
|
// newGroup builds an intermediate command such as `kopsc disk`.
|
||||||
@@ -256,9 +257,19 @@ func variantDisplay(v registry.Variant) string {
|
|||||||
return strings.Join(v.Argv, " ")
|
return strings.Join(v.Argv, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// leafUsage renders the cobra usage line for a leaf command. Commands that
|
||||||
|
// already spell out their own arguments (such as `java run <app.jar> [-- 应用参数...]`)
|
||||||
|
// keep it as-is; the generic "[-- 透传参数...]" tail would only repeat it.
|
||||||
|
func leafUsage(name, usage string) string {
|
||||||
|
if strings.TrimSpace(usage) != "" {
|
||||||
|
return name + " " + usage
|
||||||
|
}
|
||||||
|
return name + " [--dry-run] [-h] [-- 透传参数...]"
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command {
|
func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command {
|
||||||
leaf := &cobra.Command{
|
leaf := &cobra.Command{
|
||||||
Use: name + " [--dry-run] [-h] [-- 透传参数...]",
|
Use: leafUsage(name, cmd.Usage),
|
||||||
Short: cmd.Summary,
|
Short: cmd.Summary,
|
||||||
Long: cmd.Summary + "\n\n动作: " + cmd.Action,
|
Long: cmd.Summary + "\n\n动作: " + cmd.Action,
|
||||||
DisableFlagParsing: true,
|
DisableFlagParsing: true,
|
||||||
@@ -287,6 +298,15 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command {
|
|||||||
return a.printConfirmation(cmd, plan)
|
return a.printConfirmation(cmd, plan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
// A command with a preview builtin can show exactly what it
|
||||||
|
// would do (for example the final java command line), so
|
||||||
|
// render that instead of only the builtin's name.
|
||||||
|
if err := executor.Preview(context.Background(), plan, a.Stdout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return plan.Run(context.Background(), executor.Options{
|
return plan.Run(context.Background(), executor.Options{
|
||||||
DryRun: dryRun,
|
DryRun: dryRun,
|
||||||
Stdin: a.Stdin,
|
Stdin: a.Stdin,
|
||||||
@@ -379,11 +399,13 @@ func parseLeafArgs(cmd *registry.Command, args []string) (dryRun, yes bool, extr
|
|||||||
|
|
||||||
func (a *App) printLeafHelp(w io.Writer, cmd *registry.Command) {
|
func (a *App) printLeafHelp(w io.Writer, cmd *registry.Command) {
|
||||||
invocation := cmd.Invocation()
|
invocation := cmd.Invocation()
|
||||||
argsHint := ""
|
// A command that documents its own arguments already ends with them, so
|
||||||
|
// the generic passthrough tail is only added when there is no Usage.
|
||||||
if cmd.Usage != "" {
|
if cmd.Usage != "" {
|
||||||
argsHint = " " + cmd.Usage
|
fmt.Fprintf(w, "用法: %s [--dry-run] [-h] [--yes] %s\n\n", invocation, cmd.Usage)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "用法: %s [--dry-run] [-h] [--yes] [-- 透传参数...]\n\n", invocation)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "用法: %s [--dry-run] [-h] [--yes]%s [-- 透传参数...]\n\n", invocation, argsHint)
|
|
||||||
fmt.Fprintf(w, "%s\n\n", cmd.Summary)
|
fmt.Fprintf(w, "%s\n\n", cmd.Summary)
|
||||||
fmt.Fprintf(w, "动作: %s\n\n", cmd.Action)
|
fmt.Fprintf(w, "动作: %s\n\n", cmd.Action)
|
||||||
fmt.Fprintf(w, "当前平台: %s\n", resolve.DescribeInfo(a.Info))
|
fmt.Fprintf(w, "当前平台: %s\n", resolve.DescribeInfo(a.Info))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
// in-process command implementation.
|
// in-process command implementation.
|
||||||
type BuiltinRequest struct {
|
type BuiltinRequest struct {
|
||||||
Args []string
|
Args []string
|
||||||
|
Stdin io.Reader
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
Stderr io.Writer
|
Stderr io.Writer
|
||||||
Info platform.Info
|
Info platform.Info
|
||||||
@@ -42,6 +43,8 @@ var builtins = map[string]Builtin{
|
|||||||
"docker.save": runDockerSave,
|
"docker.save": runDockerSave,
|
||||||
"docker.stats": runDockerStats,
|
"docker.stats": runDockerStats,
|
||||||
"docker.stop": runDockerStop,
|
"docker.stop": runDockerStop,
|
||||||
|
"java.run": runJavaRun,
|
||||||
|
"java.run.preview": runJavaPreview,
|
||||||
"net.pids": runNetPIDs,
|
"net.pids": runNetPIDs,
|
||||||
"net.ports": runNetPorts,
|
"net.ports": runNetPorts,
|
||||||
"proc.nohup": runNohup,
|
"proc.nohup": runNohup,
|
||||||
@@ -72,7 +75,7 @@ func Preview(ctx context.Context, plan Plan, w io.Writer) error {
|
|||||||
return fmt.Errorf("未注册的确认动作 %q", name)
|
return fmt.Errorf("未注册的确认动作 %q", name)
|
||||||
}
|
}
|
||||||
// Build the list in memory first: a target that fails to resolve must not
|
// Build the list in memory first: a target that fails to resolve must not
|
||||||
// leave a dangling "将影响:" heading behind.
|
// leave a dangling heading behind.
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := fn(ctx, BuiltinRequest{
|
if err := fn(ctx, BuiltinRequest{
|
||||||
Args: plan.ExtraArgs,
|
Args: plan.ExtraArgs,
|
||||||
@@ -83,7 +86,11 @@ func Preview(ctx context.Context, plan Plan, w io.Writer) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w, "将影响:")
|
// Only destructive commands act on a list of objects; other previews (such
|
||||||
|
// as the java command line) already carry their own heading.
|
||||||
|
if plan.Result.Command != nil && plan.Result.Command.RequiresYes {
|
||||||
|
fmt.Fprintln(w, "将影响:")
|
||||||
|
}
|
||||||
_, err := w.Write(buf.Bytes())
|
_, err := w.Write(buf.Bytes())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ func (p Plan) Run(ctx context.Context, opts Options) error {
|
|||||||
}
|
}
|
||||||
return fn(ctx, BuiltinRequest{
|
return fn(ctx, BuiltinRequest{
|
||||||
Args: p.ExtraArgs,
|
Args: p.ExtraArgs,
|
||||||
|
Stdin: opts.Stdin,
|
||||||
Stdout: opts.Stdout,
|
Stdout: opts.Stdout,
|
||||||
Stderr: opts.Stderr,
|
Stderr: opts.Stderr,
|
||||||
Info: p.Result.Info,
|
Info: p.Result.Info,
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// javaTuning is everything BuildJavaFlags needs to describe one JVM launch.
|
||||||
|
type javaTuning struct {
|
||||||
|
JDK string // normalised release: "8", "11", "17", "21"
|
||||||
|
CPUs int // usable CPUs, 0 when unknown
|
||||||
|
Memory int64 // usable memory in bytes, 0 when unknown
|
||||||
|
Heap string // -Xmx value; empty means "compute from Memory"
|
||||||
|
Initial string // -Xms value; empty means "same as -Xmx"
|
||||||
|
Env string // used for -Dconfig.file=application-<Env>.yaml
|
||||||
|
GCDir string // directory for GC logs; empty means no GC log
|
||||||
|
HeapDump bool // add -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
}
|
||||||
|
|
||||||
|
// javaHeapRatio is the fraction of available memory used for the heap when the
|
||||||
|
// user does not pick a size: half leaves room for metaspace, thread stacks,
|
||||||
|
// direct buffers and the OS page cache.
|
||||||
|
const javaHeapRatioPercent = 50
|
||||||
|
|
||||||
|
// compressedOopsLimit is the largest heap for which compressed oops help; JVM
|
||||||
|
// warns and effectively disables them beyond this, so kopsc stops emitting the
|
||||||
|
// flags at 30 GiB (the commonly recommended ceiling, below the 32 GiB limit).
|
||||||
|
const compressedOopsLimit = int64(30) << 30
|
||||||
|
|
||||||
|
// BuildJavaFlags renders the ordered java arguments for one launch.
|
||||||
|
//
|
||||||
|
// The order mirrors a hand-written command line so `--print-flags` output can
|
||||||
|
// be pasted into a Dockerfile or systemd unit unchanged: JVM ergonomics first,
|
||||||
|
// then heap, then GC and logging, then application properties.
|
||||||
|
func BuildJavaFlags(t javaTuning) []string {
|
||||||
|
flags := []string{"-server"}
|
||||||
|
|
||||||
|
// Heap: -Xms and -Xmx are equal to keep the heap from resizing at runtime.
|
||||||
|
heapBytes, heapText := resolveHeap(t)
|
||||||
|
initialText := t.Initial
|
||||||
|
if initialText == "" {
|
||||||
|
initialText = heapText
|
||||||
|
}
|
||||||
|
if heapText != "" {
|
||||||
|
flags = append(flags, "-Xmx"+heapText)
|
||||||
|
}
|
||||||
|
if initialText != "" {
|
||||||
|
flags = append(flags, "-Xms"+initialText)
|
||||||
|
}
|
||||||
|
|
||||||
|
flags = append(flags,
|
||||||
|
"-Xss256k",
|
||||||
|
"-XX:MaxDirectMemorySize="+directMemory(heapBytes),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compressed oops only make sense while the heap fits under the limit.
|
||||||
|
if heapBytes <= 0 || heapBytes <= compressedOopsLimit {
|
||||||
|
flags = append(flags, "-XX:+UseCompressedOops", "-XX:+UseCompressedClassPointers")
|
||||||
|
}
|
||||||
|
|
||||||
|
flags = append(flags,
|
||||||
|
"-XX:+SegmentedCodeCache",
|
||||||
|
"-XX:+UseG1GC",
|
||||||
|
"-XX:+ExplicitGCInvokesConcurrent",
|
||||||
|
)
|
||||||
|
|
||||||
|
if jdkMajor(t.JDK) >= 9 {
|
||||||
|
flags = append(flags, "-XX:+PrintCommandLineFlags")
|
||||||
|
if t.GCDir != "" {
|
||||||
|
flags = append(flags, "-Xlog:gc*,safepoint:"+gcLogPath(t.GCDir)+":time,uptime:filecount=100,filesize=50M")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// JDK 8 has no -Xlog:; it uses -Xloggc plus its own rotation options.
|
||||||
|
// Emitting -Xlog: on JDK 8 makes the JVM refuse to start.
|
||||||
|
flags = append(flags, "-verbose:gc", "-XX:+PrintCommandLineFlags", "-XX:+PrintGCDetails", "-XX:+PrintGCDateStamps")
|
||||||
|
if t.GCDir != "" {
|
||||||
|
flags = append(flags,
|
||||||
|
"-Xloggc:"+gcLogPath(t.GCDir),
|
||||||
|
"-XX:+UseGCLogFileRotation",
|
||||||
|
"-XX:NumberOfGCLogFiles=100",
|
||||||
|
"-XX:GCLogFileSize=50M",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flags = append(flags, "-Djava.security.egd=file:/dev/./urandom")
|
||||||
|
|
||||||
|
if t.HeapDump {
|
||||||
|
flags = append(flags, "-XX:+HeapDumpOnOutOfMemoryError")
|
||||||
|
}
|
||||||
|
|
||||||
|
flags = append(flags, "-Dconfig.file="+configFile(t.Env))
|
||||||
|
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
// configFile renders the Spring-style config property: --env wins, then the
|
||||||
|
// APP_ENV environment variable, then "prod".
|
||||||
|
func configFile(env string) string {
|
||||||
|
if env == "" {
|
||||||
|
env = os.Getenv("APP_ENV")
|
||||||
|
}
|
||||||
|
if env == "" {
|
||||||
|
env = "prod"
|
||||||
|
}
|
||||||
|
return "application-" + env + ".yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHeap decides the heap size: an explicit value wins, otherwise half of
|
||||||
|
// the available memory.
|
||||||
|
func resolveHeap(t javaTuning) (int64, string) {
|
||||||
|
if t.Heap != "" {
|
||||||
|
return parseJVMSize(t.Heap), t.Heap
|
||||||
|
}
|
||||||
|
if t.Memory <= 0 {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
// Multiply first: dividing by 100 up front truncates, turning 8 GiB into
|
||||||
|
// 3.5g because the remainder is lost before the ratio is applied.
|
||||||
|
bytes := t.Memory * javaHeapRatioPercent / 100
|
||||||
|
return bytes, formatJVMSize(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// directMemory returns MaxDirectMemorySize as a quarter of the heap (the JVM's
|
||||||
|
// own default), at least 64 MiB so small heaps still work with NIO.
|
||||||
|
func directMemory(heapBytes int64) string {
|
||||||
|
if heapBytes <= 0 {
|
||||||
|
return "256m"
|
||||||
|
}
|
||||||
|
quarter := heapBytes / 4
|
||||||
|
if quarter < 64<<20 {
|
||||||
|
quarter = 64 << 20
|
||||||
|
}
|
||||||
|
return formatJVMSize(quarter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcLogPath joins the GC directory and the standard file name.
|
||||||
|
func gcLogPath(dir string) string {
|
||||||
|
return strings.TrimRight(dir, `/\`) + "/gc.log"
|
||||||
|
}
|
||||||
|
|
||||||
|
// jdkMajor parses the normalised release into a number; "8" when unknown so
|
||||||
|
// the conservative JDK 8 flag set is used.
|
||||||
|
func jdkMajor(jdk string) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(jdk))
|
||||||
|
if err != nil {
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJVMSize converts a user-supplied size ("2g", "512m", "1024k", "1073741824")
|
||||||
|
// into bytes. An unparsable value returns 0.
|
||||||
|
func parseJVMSize(value string) int64 {
|
||||||
|
value = strings.TrimSpace(strings.ToLower(value))
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
multiplier := int64(1)
|
||||||
|
switch value[len(value)-1] {
|
||||||
|
case 'k':
|
||||||
|
multiplier, value = 1<<10, value[:len(value)-1]
|
||||||
|
case 'm':
|
||||||
|
multiplier, value = 1<<20, value[:len(value)-1]
|
||||||
|
case 'g':
|
||||||
|
multiplier, value = 1<<30, value[:len(value)-1]
|
||||||
|
case 't':
|
||||||
|
multiplier, value = 1<<40, value[:len(value)-1]
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n * multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatJVMSize renders bytes the way -Xmx expects: "512m", "2g". Sizes are
|
||||||
|
// rounded down to whole mebibytes (or gibibytes when large enough) so the
|
||||||
|
// printed flag stays short.
|
||||||
|
func formatJVMSize(bytes int64) string {
|
||||||
|
if bytes <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const (
|
||||||
|
mib = int64(1) << 20
|
||||||
|
gib = int64(1) << 30
|
||||||
|
)
|
||||||
|
if bytes >= gib {
|
||||||
|
if bytes%gib == 0 {
|
||||||
|
return fmt.Sprintf("%dg", bytes/gib)
|
||||||
|
}
|
||||||
|
// Round down to 0.5g so "6.4g" becomes "6.5g"-free output (6.4g),
|
||||||
|
// keeping the flag short while never asking for more than available.
|
||||||
|
halfG := int64(1) << 29
|
||||||
|
halves := bytes / halfG
|
||||||
|
if halves%2 == 0 {
|
||||||
|
return fmt.Sprintf("%dg", halves/2)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1fg", float64(halves)/2)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm", bytes/mib)
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const gib = int64(1) << 30
|
||||||
|
|
||||||
|
func listFlags(flags []string) string { return strings.Join(flags, " ") }
|
||||||
|
|
||||||
|
func TestBuildJavaFlagsJDK21(t *testing.T) {
|
||||||
|
flags := BuildJavaFlags(javaTuning{
|
||||||
|
JDK: "21",
|
||||||
|
Memory: 8 * gib,
|
||||||
|
Env: "prod",
|
||||||
|
GCDir: "/boot/logs",
|
||||||
|
})
|
||||||
|
got := listFlags(flags)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"-server",
|
||||||
|
"-Xmx4g", "-Xms4g", // 50% of 8 GiB
|
||||||
|
"-Xss256k",
|
||||||
|
"-XX:MaxDirectMemorySize=1g", // a quarter of the heap
|
||||||
|
"-XX:+UseCompressedOops", "-XX:+UseCompressedClassPointers",
|
||||||
|
"-XX:+SegmentedCodeCache",
|
||||||
|
"-XX:+UseG1GC",
|
||||||
|
"-XX:+ExplicitGCInvokesConcurrent",
|
||||||
|
"-Xlog:gc*,safepoint:/boot/logs/gc.log:time,uptime:filecount=100,filesize=50M",
|
||||||
|
"-Djava.security.egd=file:/dev/./urandom",
|
||||||
|
"-Dconfig.file=application-prod.yaml",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("JDK 21 flags missing %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// JDK 8-only spellings must not appear on modern JVMs.
|
||||||
|
for _, unwanted := range []string{"-Xloggc:", "-XX:+PrintGCDetails", "-verbose:gc"} {
|
||||||
|
if strings.Contains(got, unwanted) {
|
||||||
|
t.Errorf("JDK 21 flags must not contain %q:\n%s", unwanted, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJavaFlagsJDK8UsesXloggc(t *testing.T) {
|
||||||
|
flags := BuildJavaFlags(javaTuning{JDK: "8", Memory: 4 * gib, GCDir: "/var/log/app"})
|
||||||
|
got := listFlags(flags)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"-Xmx2g", "-Xms2g",
|
||||||
|
"-XX:+UseG1GC",
|
||||||
|
"-verbose:gc",
|
||||||
|
"-Xloggc:/var/log/app/gc.log",
|
||||||
|
"-XX:+PrintGCDetails",
|
||||||
|
"-XX:+PrintGCDateStamps",
|
||||||
|
"-XX:+UseGCLogFileRotation",
|
||||||
|
"-XX:NumberOfGCLogFiles=100",
|
||||||
|
"-XX:GCLogFileSize=50M",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("JDK 8 flags missing %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The decisive compatibility rule: -Xlog: makes JDK 8 refuse to start.
|
||||||
|
if strings.Contains(got, "-Xlog:") {
|
||||||
|
t.Fatalf("JDK 8 must not receive -Xlog: (it cannot parse it):\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJavaFlagsJDK11And17UseXLlog(t *testing.T) {
|
||||||
|
for _, jdk := range []string{"11", "17"} {
|
||||||
|
got := listFlags(BuildJavaFlags(javaTuning{JDK: jdk, Memory: 4 * gib, GCDir: "./logs"}))
|
||||||
|
if !strings.Contains(got, "-Xlog:gc*,safepoint:./logs/gc.log:time,uptime:filecount=100,filesize=50M") {
|
||||||
|
t.Errorf("JDK %s should use -Xlog:, got:\n%s", jdk, got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "-Xloggc:") {
|
||||||
|
t.Errorf("JDK %s must not use -Xloggc:\n%s", jdk, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJavaFlagsOrder(t *testing.T) {
|
||||||
|
flags := BuildJavaFlags(javaTuning{JDK: "17", Memory: 2 * gib, GCDir: "/tmp"})
|
||||||
|
// The command line must start with the executable-adjacent flags so it can
|
||||||
|
// be pasted into a Dockerfile unchanged.
|
||||||
|
if flags[0] != "-server" {
|
||||||
|
t.Fatalf("first flag = %q, want -server", flags[0])
|
||||||
|
}
|
||||||
|
xmx, xms := indexOf(flags, "-Xmx1g"), indexOf(flags, "-Xms1g")
|
||||||
|
if xmx < 0 || xms < 0 || xmx > xms {
|
||||||
|
t.Errorf("-Xmx should come before -Xms (%d, %d): %v", xmx, xms, flags)
|
||||||
|
}
|
||||||
|
logIdx := indexOfPrefix(flags, "-Xlog:")
|
||||||
|
propertyIdx := indexOfPrefix(flags, "-Dconfig.file=")
|
||||||
|
if logIdx < 0 || propertyIdx < 0 || logIdx > propertyIdx {
|
||||||
|
t.Errorf("-Dconfig.file should come after the GC flags: %v", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(values []string, want string) int {
|
||||||
|
for i, v := range values {
|
||||||
|
if v == want {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOfPrefix(values []string, prefix string) int {
|
||||||
|
for i, v := range values {
|
||||||
|
if strings.HasPrefix(v, prefix) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJavaFlagsCommonTuning(t *testing.T) {
|
||||||
|
got := listFlags(BuildJavaFlags(javaTuning{JDK: "17", Memory: 4 * gib, GCDir: "/tmp"}))
|
||||||
|
for _, want := range []string{
|
||||||
|
"-server",
|
||||||
|
"-Xss256k",
|
||||||
|
"-XX:MaxDirectMemorySize=512m", // a quarter of the 2g heap
|
||||||
|
"-XX:+SegmentedCodeCache",
|
||||||
|
"-XX:+ExplicitGCInvokesConcurrent",
|
||||||
|
"-Djava.security.egd=file:/dev/./urandom",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("common flag %q missing:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJavaFlagsDirectMemoryMinimum(t *testing.T) {
|
||||||
|
// A tiny heap must still leave 64 MiB for direct buffers (NIO).
|
||||||
|
got := listFlags(BuildJavaFlags(javaTuning{JDK: "17", Memory: 128 << 20}))
|
||||||
|
if !strings.Contains(got, "-XX:MaxDirectMemorySize=64m") {
|
||||||
|
t.Fatalf("direct memory floor missing:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJavaFlagsHeapDefaultsToHalf(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
memory int64
|
||||||
|
wantXmx string
|
||||||
|
wantXms string
|
||||||
|
}{
|
||||||
|
{16 * gib, "-Xmx8g", "-Xms8g"},
|
||||||
|
{1 * gib, "-Xmx512m", "-Xms512m"},
|
||||||
|
{512 << 20, "-Xmx256m", "-Xms256m"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
flags := BuildJavaFlags(javaTuning{JDK: "21", Memory: tc.memory})
|
||||||
|
if got := indexOf(flags, tc.wantXmx); got < 0 {
|
||||||
|
t.Errorf("memory %d: want %s in %v", tc.memory, tc.wantXmx, flags)
|
||||||
|
}
|
||||||
|
if got := indexOf(flags, tc.wantXms); got < 0 {
|
||||||
|
t.Errorf("memory %d: -Xms must equal -Xmx (%s), got %v", tc.memory, tc.wantXms, flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJavaFlagsHeapOverrides(t *testing.T) {
|
||||||
|
// --heap / --xmx wins over the 50% default.
|
||||||
|
flags := BuildJavaFlags(javaTuning{JDK: "17", Memory: 16 * gib, Heap: "4g"})
|
||||||
|
if indexOf(flags, "-Xmx4g") < 0 || indexOf(flags, "-Xms4g") < 0 {
|
||||||
|
t.Fatalf("explicit heap not applied: %v", flags)
|
||||||
|
}
|
||||||
|
if indexOf(flags, "-Xmx8g") >= 0 {
|
||||||
|
t.Fatalf("default heap leaked alongside the override: %v", flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --xms makes the initial heap differ, which is the only way to split them.
|
||||||
|
flags = BuildJavaFlags(javaTuning{JDK: "17", Memory: 16 * gib, Heap: "4g", Initial: "1g"})
|
||||||
|
if indexOf(flags, "-Xmx4g") < 0 || indexOf(flags, "-Xms1g") < 0 {
|
||||||
|
t.Fatalf("initial heap override not applied: %v", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJavaFlagsNoMemoryMeansNoHeapFlags(t *testing.T) {
|
||||||
|
flags := BuildJavaFlags(javaTuning{JDK: "17"})
|
||||||
|
if indexOfPrefix(flags, "-Xmx") >= 0 || indexOfPrefix(flags, "-Xms") >= 0 {
|
||||||
|
t.Fatalf("unknown memory must leave the heap to JVM ergonomics: %v", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigFileResolution(t *testing.T) {
|
||||||
|
// --env wins.
|
||||||
|
if got := configFile("staging"); got != "application-staging.yaml" {
|
||||||
|
t.Errorf("configFile(staging) = %q", got)
|
||||||
|
}
|
||||||
|
// APP_ENV is the fallback.
|
||||||
|
t.Setenv("APP_ENV", "uat")
|
||||||
|
if got := configFile(""); got != "application-uat.yaml" {
|
||||||
|
t.Errorf("configFile(\"\") with APP_ENV=uat = %q", got)
|
||||||
|
}
|
||||||
|
// prod is the default when neither is set.
|
||||||
|
t.Setenv("APP_ENV", "")
|
||||||
|
if got := configFile(""); got != "application-prod.yaml" {
|
||||||
|
t.Errorf("configFile(\"\") default = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildJavaFlagsEnvFlagWinsOverAppEnv(t *testing.T) {
|
||||||
|
t.Setenv("APP_ENV", "uat")
|
||||||
|
flags := BuildJavaFlags(javaTuning{JDK: "17", Memory: 2 * gib, Env: "staging"})
|
||||||
|
if indexOf(flags, "-Dconfig.file=application-staging.yaml") < 0 {
|
||||||
|
t.Fatalf("--env must win over APP_ENV: %v", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompressedOopsDroppedForLargeHeaps(t *testing.T) {
|
||||||
|
// Above 30g the JVM cannot use compressed oops, so kopsc stops emitting
|
||||||
|
// the flags instead of leaving a warning in the log.
|
||||||
|
big := BuildJavaFlags(javaTuning{JDK: "17", Heap: "32g"})
|
||||||
|
for _, flag := range big {
|
||||||
|
if flag == "-XX:+UseCompressedOops" || flag == "-XX:+UseCompressedClassPointers" {
|
||||||
|
t.Fatalf("heap 32g must drop compressed oops flags, got: %v", big)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30g is still inside the usable range.
|
||||||
|
ok := BuildJavaFlags(javaTuning{JDK: "17", Heap: "30g"})
|
||||||
|
if indexOf(ok, "-XX:+UseCompressedOops") < 0 {
|
||||||
|
t.Fatalf("heap 30g should keep compressed oops: %v", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompressedOopsDroppedWhenDerivedHeapIsLarge(t *testing.T) {
|
||||||
|
// 128 GiB of memory yields a 64g heap by default, which is also too large.
|
||||||
|
flags := BuildJavaFlags(javaTuning{JDK: "21", Memory: 128 * gib})
|
||||||
|
if indexOf(flags, "-XX:+UseCompressedOops") >= 0 {
|
||||||
|
t.Fatalf("derived 64g heap must drop compressed oops: %v", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// javaRunOptions are the parsed arguments of `kopsc java run`.
|
||||||
|
type javaRunOptions struct {
|
||||||
|
jar string // the application jar
|
||||||
|
appArgs []string // everything after "--", passed to the application
|
||||||
|
jdk string // --jdk: 8 / 11 / 17 / 21
|
||||||
|
javaBin string // --java: an explicit java executable
|
||||||
|
heap string // --heap / --xmx
|
||||||
|
initial string // --xms
|
||||||
|
env string // --env
|
||||||
|
gcDir string // --gc-log directory
|
||||||
|
heapDump bool // --heap-dump-on-oom
|
||||||
|
printFlags bool // --print-flags
|
||||||
|
quiet bool // -q / --quiet
|
||||||
|
}
|
||||||
|
|
||||||
|
// supportedJDKs are the releases kopsc has a tuning profile for.
|
||||||
|
var supportedJDKs = []string{"8", "11", "17", "21"}
|
||||||
|
|
||||||
|
// runJavaRun launches a Spring-style Java application with kopsc's tuned JVM
|
||||||
|
// flags, in the foreground, so its logs and Ctrl+C behave like a plain
|
||||||
|
// `java -jar`.
|
||||||
|
func runJavaRun(ctx context.Context, req BuiltinRequest) error {
|
||||||
|
opts, err := parseJavaRunArgs(req.Args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
jar, err := resolveJarPath(opts.jar)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
jdk, javaBin, err := resolveJDK(opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := platform.DetectResources()
|
||||||
|
tuning := javaTuning{
|
||||||
|
JDK: jdk,
|
||||||
|
CPUs: res.CPUs,
|
||||||
|
Memory: res.Memory,
|
||||||
|
Heap: opts.heap,
|
||||||
|
Initial: opts.initial,
|
||||||
|
Env: opts.env,
|
||||||
|
GCDir: opts.gcDir,
|
||||||
|
HeapDump: opts.heapDump,
|
||||||
|
}
|
||||||
|
flags := BuildJavaFlags(tuning)
|
||||||
|
argv := append([]string{javaBin}, flags...)
|
||||||
|
argv = append(argv, "-jar", jar)
|
||||||
|
argv = append(argv, opts.appArgs...)
|
||||||
|
|
||||||
|
if opts.printFlags {
|
||||||
|
fmt.Fprintln(req.Stdout, strings.Join(argv, " "))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.quiet {
|
||||||
|
fmt.Fprintf(req.Stdout, "JDK: %s(探测到 %s)\n", jdk, describeJava(javaBin))
|
||||||
|
fmt.Fprintf(req.Stdout, "资源: %d 核, 内存 %s(%s)\n", res.CPUs, formatBytes(res.Memory), res.Detected)
|
||||||
|
fmt.Fprintf(req.Stdout, "堆: %s\n", heapSummary(flags))
|
||||||
|
if opts.gcDir != "" {
|
||||||
|
fmt.Fprintf(req.Stdout, "GC 日志: %s\n", gcLogPath(opts.gcDir))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(req.Stdout, "启动: %s\n\n", strings.Join(argv, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.gcDir != "" {
|
||||||
|
if err := os.MkdirAll(opts.gcDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("创建 GC 日志目录 %q 失败: %w", opts.gcDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return runJavaForeground(ctx, argv, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runJavaForeground runs the JVM attached to the caller's terminal. The exit
|
||||||
|
// code is passed through so supervisors see the application's own status.
|
||||||
|
//
|
||||||
|
// Ctrl+C is forwarded to the JVM instead of killing kopsc outright, so the
|
||||||
|
// application's shutdown hooks run and Spring can close its context cleanly.
|
||||||
|
func runJavaForeground(ctx context.Context, argv []string, req BuiltinRequest) error {
|
||||||
|
cmd := exec.Command(argv[0], argv[1:]...)
|
||||||
|
cmd.Stdin = req.Stdin
|
||||||
|
cmd.Stdout = req.Stdout
|
||||||
|
cmd.Stderr = req.Stderr
|
||||||
|
// Relay Ctrl+C/SIGTERM to the JVM so its shutdown hooks run.
|
||||||
|
stopSignals := forwardSignals(cmd)
|
||||||
|
defer stopSignals()
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("启动 java 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Killing on context cancellation keeps kopsc from outliving its parent
|
||||||
|
// while the application itself is still shutting down gracefully.
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := cmd.Wait()
|
||||||
|
close(done)
|
||||||
|
if err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return &ExitError{Code: exitErr.ExitCode(), Err: err}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("等待 java 退出失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveJarPath checks that the jar exists and returns an absolute path so the
|
||||||
|
// JVM resolves it independently of its working directory.
|
||||||
|
func resolveJarPath(jar string) (string, error) {
|
||||||
|
info, err := os.Stat(jar)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("找不到应用 jar %q: %w;用法: kopsc java run <app.jar> [-- 应用参数...]", jar, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return "", fmt.Errorf("%q 是目录,不是 jar 文件", jar)
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(jar)
|
||||||
|
if err != nil {
|
||||||
|
return jar, nil
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveJDK decides which JDK to tune for and which java executable to run.
|
||||||
|
func resolveJDK(opts javaRunOptions) (version, javaBin string, err error) {
|
||||||
|
if opts.jdk != "" {
|
||||||
|
version = normalizeJDKFlag(opts.jdk)
|
||||||
|
if !containsString(supportedJDKs, version) {
|
||||||
|
return "", "", fmt.Errorf("--jdk 只支持 %s,收到 %q",
|
||||||
|
strings.Join(supportedJDKs, "/"), opts.jdk)
|
||||||
|
}
|
||||||
|
return version, javaExecutable(opts.javaBin), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var info platform.Java
|
||||||
|
if opts.javaBin != "" {
|
||||||
|
info = platform.DetectJavaAt(opts.javaBin)
|
||||||
|
} else {
|
||||||
|
info = platform.DetectJava()
|
||||||
|
}
|
||||||
|
if info.Version == "" {
|
||||||
|
return "", "", fmt.Errorf("无法自动识别 JDK 版本(%s);请用 --jdk 指定:%s",
|
||||||
|
describeJavaDetection(info), strings.Join(supportedJDKs, " / "))
|
||||||
|
}
|
||||||
|
if !containsString(supportedJDKs, info.Version) {
|
||||||
|
return "", "", fmt.Errorf("检测到 JDK %s,kopsc 暂无该版本的调优方案;"+
|
||||||
|
"可用 --jdk 指定按 %s 的参数运行", info.Raw, strings.Join(supportedJDKs, " / "))
|
||||||
|
}
|
||||||
|
return info.Version, firstNonEmptyString(info.Path, javaExecutable(opts.javaBin)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// javaExecutable returns the java binary to run: --java, or "java" on PATH.
|
||||||
|
func javaExecutable(explicit string) string {
|
||||||
|
if explicit != "" {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
return "java"
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeJDKFlag accepts "8", "1.8", "jdk8", "JDK 17" and similar spellings.
|
||||||
|
func normalizeJDKFlag(value string) string {
|
||||||
|
v := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
v = strings.TrimPrefix(v, "jdk")
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if strings.HasPrefix(v, "1.") {
|
||||||
|
v = strings.TrimPrefix(v, "1.")
|
||||||
|
}
|
||||||
|
if i := strings.IndexAny(v, "._"); i > 0 {
|
||||||
|
v = v[:i]
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(v); err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeJava renders the java executable for the startup summary.
|
||||||
|
func describeJava(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return "java(PATH 中)"
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeJavaDetection(info platform.Java) string {
|
||||||
|
if info.Path == "" {
|
||||||
|
return "PATH 中没有 java"
|
||||||
|
}
|
||||||
|
return "java 存在但版本无法解析"
|
||||||
|
}
|
||||||
|
|
||||||
|
// heapSummary extracts the -Xmx value for the startup summary.
|
||||||
|
func heapSummary(flags []string) string {
|
||||||
|
for _, f := range flags {
|
||||||
|
if strings.HasPrefix(f, "-Xmx") {
|
||||||
|
return strings.TrimPrefix(f, "-Xmx") + "(-Xms 相同或由 --xms 指定)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "交由 JVM 自行决定(未识别到内存)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBytes renders a byte count for humans; "未知" when it is 0.
|
||||||
|
func formatBytes(n int64) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
return humanSize(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, want string) bool {
|
||||||
|
for _, v := range values {
|
||||||
|
if v == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyString(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJavaRunArgs parses kopsc's options, which must come before the first
|
||||||
|
// positional argument (the jar). Everything after "--" is handed to the
|
||||||
|
// application untouched.
|
||||||
|
func parseJavaRunArgs(args []string) (javaRunOptions, error) {
|
||||||
|
var opts javaRunOptions
|
||||||
|
i := 0
|
||||||
|
for ; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
|
||||||
|
if arg == "--" {
|
||||||
|
// `-- jar args` : the jar follows the separator.
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(arg, "-") || arg == "-" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
name, value := arg, ""
|
||||||
|
if eq := strings.IndexByte(arg, '='); eq >= 0 && strings.HasPrefix(arg, "--") {
|
||||||
|
name, value = arg[:eq], arg[eq+1:]
|
||||||
|
}
|
||||||
|
takeValue := func() (string, error) {
|
||||||
|
if value != "" {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return "", fmt.Errorf("%s 缺少参数", name)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
return args[i], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "--jdk":
|
||||||
|
v, err := takeValue()
|
||||||
|
if err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
opts.jdk = v
|
||||||
|
case "--java":
|
||||||
|
v, err := takeValue()
|
||||||
|
if err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
opts.javaBin = v
|
||||||
|
case "--heap", "--xmx":
|
||||||
|
v, err := takeValue()
|
||||||
|
if err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
if parseJVMSize(v) <= 0 {
|
||||||
|
return opts, fmt.Errorf("--heap 无法识别大小 %q,可用 2g、512m 或字节数", v)
|
||||||
|
}
|
||||||
|
opts.heap = v
|
||||||
|
case "--xms":
|
||||||
|
v, err := takeValue()
|
||||||
|
if err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
if parseJVMSize(v) <= 0 {
|
||||||
|
return opts, fmt.Errorf("--xms 无法识别大小 %q,可用 2g、512m 或字节数", v)
|
||||||
|
}
|
||||||
|
opts.initial = v
|
||||||
|
case "--env":
|
||||||
|
v, err := takeValue()
|
||||||
|
if err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
opts.env = v
|
||||||
|
case "--gc-log":
|
||||||
|
v, err := takeValue()
|
||||||
|
if err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
opts.gcDir = v
|
||||||
|
case "--no-gc-log":
|
||||||
|
opts.gcDir = "" // explicit, and the default is off
|
||||||
|
case "--heap-dump-on-oom":
|
||||||
|
opts.heapDump = true
|
||||||
|
case "--print-flags":
|
||||||
|
opts.printFlags = true
|
||||||
|
case "-q", "--quiet":
|
||||||
|
opts.quiet = true
|
||||||
|
default:
|
||||||
|
return opts, fmt.Errorf("未知选项 %q;应用自己的参数请写在 jar 之后,或用 -- 分隔", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if i >= len(args) {
|
||||||
|
return opts, errors.New("缺少应用 jar;用法: kopsc java run <app.jar> [-- 应用参数...]")
|
||||||
|
}
|
||||||
|
opts.jar = args[i]
|
||||||
|
rest := args[i+1:]
|
||||||
|
// A separator after the jar is not an application argument.
|
||||||
|
if len(rest) > 0 && rest[0] == "--" {
|
||||||
|
rest = rest[1:]
|
||||||
|
}
|
||||||
|
opts.appArgs = append(opts.appArgs, rest...)
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runJavaPreview renders the command `kopsc java run` would execute without
|
||||||
|
// starting anything. It backs both --dry-run and --print-flags, so the same
|
||||||
|
// resolution code path is used; only the output differs.
|
||||||
|
func runJavaPreview(ctx context.Context, req BuiltinRequest) error {
|
||||||
|
opts, err := parseJavaRunArgs(req.Args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The jar may not exist yet in a dry run; report it rather than failing so
|
||||||
|
// the flags can still be inspected.
|
||||||
|
if _, statErr := os.Stat(opts.jar); statErr != nil {
|
||||||
|
fmt.Fprintf(req.Stderr, "注意: %v\n", statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
jdk, javaBin, err := resolveJDK(opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := platform.DetectResources()
|
||||||
|
flags := BuildJavaFlags(javaTuning{
|
||||||
|
JDK: jdk,
|
||||||
|
CPUs: res.CPUs,
|
||||||
|
Memory: res.Memory,
|
||||||
|
Heap: opts.heap,
|
||||||
|
Initial: opts.initial,
|
||||||
|
Env: opts.env,
|
||||||
|
GCDir: opts.gcDir,
|
||||||
|
HeapDump: opts.heapDump,
|
||||||
|
})
|
||||||
|
argv := append([]string{javaBin}, flags...)
|
||||||
|
argv = append(argv, "-jar", opts.jar)
|
||||||
|
argv = append(argv, opts.appArgs...)
|
||||||
|
|
||||||
|
fmt.Fprintln(req.Stdout, "将要启动的 java 命令:")
|
||||||
|
fmt.Fprintln(req.Stdout, strings.Join(argv, " "))
|
||||||
|
fmt.Fprintf(req.Stdout, "\n资源: %d 核, 内存 %s(%s)\n", res.CPUs, formatBytes(res.Memory), res.Detected)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseJavaRunArgsBasic(t *testing.T) {
|
||||||
|
opts, err := parseJavaRunArgs([]string{"app.jar"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if opts.jar != "app.jar" || len(opts.appArgs) != 0 {
|
||||||
|
t.Fatalf("opts = %+v", opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJavaRunArgsApplicationArgsAfterDoubleDash(t *testing.T) {
|
||||||
|
opts, err := parseJavaRunArgs([]string{
|
||||||
|
"--jdk", "17",
|
||||||
|
"app.jar",
|
||||||
|
"--",
|
||||||
|
"--spring.profiles.active=prod", "--server.port=8080", "-X", "flag",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if opts.jar != "app.jar" {
|
||||||
|
t.Errorf("jar = %q", opts.jar)
|
||||||
|
}
|
||||||
|
want := "--spring.profiles.active=prod --server.port=8080 -X flag"
|
||||||
|
if got := strings.Join(opts.appArgs, " "); got != want {
|
||||||
|
t.Errorf("appArgs = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJavaRunArgsKeepsJarArgsWithoutDoubleDash(t *testing.T) {
|
||||||
|
// Arguments after the jar belong to the application, so a leading dash
|
||||||
|
// there must not be mistaken for a kopsc option.
|
||||||
|
opts, err := parseJavaRunArgs([]string{"app.jar", "--spring.profiles.active=dev"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Join(opts.appArgs, " ") != "--spring.profiles.active=dev" {
|
||||||
|
t.Fatalf("appArgs = %v", opts.appArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJavaRunArgsAllOptions(t *testing.T) {
|
||||||
|
opts, err := parseJavaRunArgs([]string{
|
||||||
|
"--jdk=11", "--heap", "2g", "--xms", "512m", "--env", "staging",
|
||||||
|
"--gc-log", "/var/log/app", "--heap-dump-on-oom", "--print-flags", "-q",
|
||||||
|
"app.jar",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if opts.jdk != "11" || opts.heap != "2g" || opts.initial != "512m" ||
|
||||||
|
opts.env != "staging" || opts.gcDir != "/var/log/app" ||
|
||||||
|
!opts.heapDump || !opts.printFlags || !opts.quiet {
|
||||||
|
t.Fatalf("opts = %+v", opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJavaRunArgsRejectsBadInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{nil, "缺少应用 jar"},
|
||||||
|
{[]string{"--jdk"}, "--jdk 缺少参数"},
|
||||||
|
{[]string{"--heap", "huge", "app.jar"}, "--heap 无法识别大小"},
|
||||||
|
{[]string{"--xms", "0", "app.jar"}, "--xms 无法识别大小"},
|
||||||
|
{[]string{"--unknown", "app.jar"}, "未知选项"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
_, err := parseJavaRunArgs(tc.args)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeJDKFlag(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"8": "8",
|
||||||
|
"1.8": "8",
|
||||||
|
"jdk8": "8",
|
||||||
|
"JDK17": "17",
|
||||||
|
"17": "17",
|
||||||
|
"21.0": "21",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := normalizeJDKFlag(in); got != want {
|
||||||
|
t.Errorf("normalizeJDKFlag(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJavaRunOptionsBeforeJar checks that every kopsc option is consumed
|
||||||
|
// before the jar and never leaks into the application's arguments.
|
||||||
|
func TestJavaRunOptionsBeforeJar(t *testing.T) {
|
||||||
|
opts, err := parseJavaRunArgs([]string{
|
||||||
|
"--jdk", "17",
|
||||||
|
"--heap", "2g",
|
||||||
|
"--xms", "1g",
|
||||||
|
"--env", "prod",
|
||||||
|
"--gc-log", "./logs",
|
||||||
|
"--heap-dump-on-oom",
|
||||||
|
"app.jar",
|
||||||
|
"--",
|
||||||
|
"--server.port=9000",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Join(opts.appArgs, " ") != "--server.port=9000" {
|
||||||
|
t.Fatalf("kopsc options leaked into app args: %v", opts.appArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := BuildJavaFlags(javaTuning{
|
||||||
|
JDK: opts.jdk, Heap: opts.heap, Initial: opts.initial,
|
||||||
|
Env: opts.env, GCDir: opts.gcDir, HeapDump: opts.heapDump,
|
||||||
|
Memory: 8 << 30,
|
||||||
|
})
|
||||||
|
for _, want := range []string{
|
||||||
|
"-Xmx2g", "-Xms1g",
|
||||||
|
"-Xlog:gc*,safepoint:./logs/gc.log:time,uptime:filecount=100,filesize=50M",
|
||||||
|
"-XX:+HeapDumpOnOutOfMemoryError",
|
||||||
|
"-Dconfig.file=application-prod.yaml",
|
||||||
|
} {
|
||||||
|
if indexOfPrefix(flags, want) < 0 {
|
||||||
|
t.Errorf("flag %q missing for the parsed options: %v", want, flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJavaRunNoGCLogByDefault(t *testing.T) {
|
||||||
|
opts, err := parseJavaRunArgs([]string{"app.jar"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if opts.gcDir != "" {
|
||||||
|
t.Fatalf("GC logging should be off by default, got %q", opts.gcDir)
|
||||||
|
}
|
||||||
|
flags := BuildJavaFlags(javaTuning{JDK: "17", Memory: 2 << 30, GCDir: opts.gcDir})
|
||||||
|
if indexOfPrefix(flags, "-Xlog:") >= 0 {
|
||||||
|
t.Fatalf("no GC log flag expected when --gc-log is absent: %v", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJavaRunRejectsUnsupportedJDK(t *testing.T) {
|
||||||
|
// JDK 9/10 are not supported releases; kopsc must say so instead of
|
||||||
|
// silently applying the wrong profile.
|
||||||
|
if v := normalizeJDKFlag("9"); v != "9" {
|
||||||
|
t.Fatalf("normalizeJDKFlag(9) = %q", v)
|
||||||
|
}
|
||||||
|
for _, supported := range supportedJDKs {
|
||||||
|
if !containsString(supportedJDKs, supported) {
|
||||||
|
t.Fatalf("%q should be supported", supported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if containsString(supportedJDKs, "9") {
|
||||||
|
t.Fatal("JDK 9 must not be advertised as supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeJavaScript writes an executable shell script standing in for java.
|
||||||
|
func fakeJavaScript(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "java")
|
||||||
|
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJavaRunForegroundWiresStdioAndExitCode(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("fake java is a shell script")
|
||||||
|
}
|
||||||
|
// The script echoes stdin to stdout, writes to stderr, and exits 7.
|
||||||
|
java := fakeJavaScript(t, `cat; echo "to-stderr" >&2; echo "args: $*" >&2; exit 7`)
|
||||||
|
|
||||||
|
jar := filepath.Join(t.TempDir(), "app.jar")
|
||||||
|
if err := os.WriteFile(jar, []byte("PK"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out, errOut bytes.Buffer
|
||||||
|
err := runJavaRun(context.Background(), BuiltinRequest{
|
||||||
|
Args: []string{"--jdk", "17", "--java", java, "-q", jar, "--", "--x=1"},
|
||||||
|
Stdin: strings.NewReader("hello-stdin\n"),
|
||||||
|
Stdout: &out,
|
||||||
|
Stderr: &errOut,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the exit code to be propagated")
|
||||||
|
}
|
||||||
|
if code := ExitCode(err); code != 7 {
|
||||||
|
t.Fatalf("exit code = %d, want 7", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "hello-stdin") {
|
||||||
|
t.Errorf("stdin was not forwarded to the JVM: stdout=%q", out.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut.String(), "to-stderr") {
|
||||||
|
t.Errorf("stderr was not forwarded: %q", errOut.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut.String(), "--x=1") {
|
||||||
|
t.Errorf("application arguments missing: %q", errOut.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJavaRunForegroundReturnsNilOnSuccess(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("fake java is a shell script")
|
||||||
|
}
|
||||||
|
java := fakeJavaScript(t, "exit 0")
|
||||||
|
jar := filepath.Join(t.TempDir(), "app.jar")
|
||||||
|
if err := os.WriteFile(jar, []byte("PK"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runJavaRun(context.Background(), BuiltinRequest{
|
||||||
|
Args: []string{"--jdk", "21", "--java", java, "-q", jar},
|
||||||
|
Stdout: &bytes.Buffer{},
|
||||||
|
Stderr: &bytes.Buffer{},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("successful run returned %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuiltinRequestCarriesStdin pins the wiring that lets a builtin read the
|
||||||
|
// caller's stdin: Plan.Run must hand Options.Stdin to the builtin request.
|
||||||
|
func TestBuiltinRequestCarriesStdin(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("fake java is a shell script")
|
||||||
|
}
|
||||||
|
java := fakeJavaScript(t, "cat")
|
||||||
|
jar := filepath.Join(t.TempDir(), "app.jar")
|
||||||
|
if err := os.WriteFile(jar, []byte("PK"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := Plan{
|
||||||
|
Result: resolveResultWithVariant(registryVariantBuiltinFor("java.run")),
|
||||||
|
ExtraArgs: []string{"--jdk", "17", "--java", java, "-q", jar},
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := plan.Run(context.Background(), Options{
|
||||||
|
Stdin: strings.NewReader("from-plan-run\n"),
|
||||||
|
Stdout: &out,
|
||||||
|
Stderr: &out,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "from-plan-run") {
|
||||||
|
t.Fatalf("Options.Stdin never reached the builtin: %q", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrintFlagsEmitsOnlyTheCommandLine keeps the output pasteable into a
|
||||||
|
// Dockerfile or systemd unit: exactly one line, starting with java.
|
||||||
|
func TestPrintFlagsEmitsOnlyTheCommandLine(t *testing.T) {
|
||||||
|
jar := filepath.Join(t.TempDir(), "app.jar")
|
||||||
|
if err := os.WriteFile(jar, []byte("PK"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := runJavaRun(context.Background(), BuiltinRequest{
|
||||||
|
Args: []string{"--jdk", "17", "--print-flags", jar, "--", "--server.port=9000"},
|
||||||
|
Stdout: &out,
|
||||||
|
Stderr: &bytes.Buffer{},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("print-flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.TrimRight(out.String(), "\n")
|
||||||
|
if strings.Contains(text, "\n") {
|
||||||
|
t.Fatalf("--print-flags must emit a single line, got:\n%s", out.String())
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(text, "java ") {
|
||||||
|
t.Errorf("command should start with java: %q", text)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"-server", "-XX:+UseG1GC", "-jar " + jar, "--server.port=9000"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("--print-flags output missing %q: %s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing was started: no startup summary lines.
|
||||||
|
if strings.Contains(text, "JDK:") || strings.Contains(text, "启动:") {
|
||||||
|
t.Errorf("--print-flags must not print the startup summary: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewBuiltinEmitsCommandWithHeading(t *testing.T) {
|
||||||
|
jar := filepath.Join(t.TempDir(), "app.jar")
|
||||||
|
if err := os.WriteFile(jar, []byte("PK"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := runJavaPreview(context.Background(), BuiltinRequest{
|
||||||
|
Args: []string{"--jdk", "21", jar},
|
||||||
|
Stdout: &out,
|
||||||
|
Stderr: &bytes.Buffer{},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("preview: %v", err)
|
||||||
|
}
|
||||||
|
text := out.String()
|
||||||
|
for _, want := range []string{"将要启动的 java 命令:", "java -server", "-jar " + jar, "资源:"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("preview missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// forwardSignals relays interrupts to the JVM so its shutdown hooks run
|
||||||
|
// instead of the process being torn down abruptly.
|
||||||
|
//
|
||||||
|
// The JVM shares kopsc's process group, so a terminal Ctrl+C already reaches
|
||||||
|
// it; this relay also covers the case where a supervisor signals kopsc alone.
|
||||||
|
// The returned stop function must be called once the child has been waited for.
|
||||||
|
func forwardSignals(cmd *exec.Cmd) (stop func()) {
|
||||||
|
signals := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case sig := <-signals:
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = cmd.Process.Signal(sig)
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
signal.Stop(signals)
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
// forwardSignals is a no-op on Windows: there is no SIGINT to relay, and
|
||||||
|
// console Ctrl+C is delivered to every attached process already.
|
||||||
|
func forwardSignals(cmd *exec.Cmd) (stop func()) {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -452,6 +452,35 @@ func Builtin() *Registry {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "java.run",
|
||||||
|
Group: "java",
|
||||||
|
Summary: "按 JDK 版本套用调优参数并前台运行 Java 应用",
|
||||||
|
Action: "以 kopsc 内置的业界常用调优参数启动 Spring 风格应用:自动识别宿主机的 CPU 与可用内存" +
|
||||||
|
"(容器内以 cgroup 限额为准),堆大小默认取可用内存的 50%;按检测到的 JDK 版本套用对应参数," +
|
||||||
|
"JDK 8 使用 -Xloggc 组,JDK 9+ 使用 -Xlog: 组;应用参数写在 jar 之后,或用 -- 分隔。" +
|
||||||
|
"前台运行,Ctrl+C 可停止,退出码透传",
|
||||||
|
Usage: "<app.jar> [-- 应用参数...]",
|
||||||
|
MinArgs: 1,
|
||||||
|
ConfirmBuiltin: "java.run.preview",
|
||||||
|
Variants: []Variant{
|
||||||
|
{
|
||||||
|
Match: Match{OS: "linux"},
|
||||||
|
Builtin: "java.run",
|
||||||
|
Note: "Linux 内置实现(/proc 与 cgroup 探测资源)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Match: Match{OS: "darwin"},
|
||||||
|
Builtin: "java.run",
|
||||||
|
Note: "macOS 内置实现(sysctl 探测内存)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Match: Match{OS: "windows"},
|
||||||
|
Builtin: "java.run",
|
||||||
|
Note: "Windows 内置实现(wmic 探测内存)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "pkg.refreshindex",
|
ID: "pkg.refreshindex",
|
||||||
Group: "pkg",
|
Group: "pkg",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestBuiltinIDsStable(t *testing.T) {
|
|||||||
"docker.prunebuilder", "docker.prunevolumes", "docker.clearall",
|
"docker.prunebuilder", "docker.prunevolumes", "docker.clearall",
|
||||||
"docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs",
|
"docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs",
|
||||||
"diagnose",
|
"diagnose",
|
||||||
"disk.usage", "net.pids", "net.ports", "proc.nohup", "proc.wait", "pkg.refreshindex",
|
"disk.usage", "net.pids", "net.ports", "proc.nohup", "proc.wait", "java.run", "pkg.refreshindex",
|
||||||
}
|
}
|
||||||
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
|
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)
|
t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)
|
||||||
@@ -373,3 +373,34 @@ func TestProcWaitIsBuiltinEverywhere(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJavaRunIsBuiltinEverywhere(t *testing.T) {
|
||||||
|
reg := Builtin()
|
||||||
|
cmd, ok := reg.Lookup("java.run")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("java.run not found")
|
||||||
|
}
|
||||||
|
if cmd.RequiresYes {
|
||||||
|
t.Error("java.run only starts an application; it must not require --yes")
|
||||||
|
}
|
||||||
|
if cmd.MinArgs != 1 || !strings.Contains(cmd.Usage, ".jar") {
|
||||||
|
t.Errorf("java.run must require the jar argument, got MinArgs=%d Usage=%q", cmd.MinArgs, cmd.Usage)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"JDK", "内存"} {
|
||||||
|
if !strings.Contains(cmd.Action, want) {
|
||||||
|
t.Errorf("java.run Action should mention %q:\n%s", want, cmd.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, v := range cmd.Variants {
|
||||||
|
if v.Builtin != "java.run" || len(v.Argv) != 0 {
|
||||||
|
t.Errorf("variant for %s should be the java.run builtin, got %+v", v.Match.OS, v)
|
||||||
|
}
|
||||||
|
seen[v.Match.OS] = true
|
||||||
|
}
|
||||||
|
for _, osName := range []string{"linux", "darwin", "windows"} {
|
||||||
|
if !seen[osName] {
|
||||||
|
t.Errorf("java.run lacks a %s variant", osName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user