diff --git a/README.java.md b/README.java.md new file mode 100644 index 0000000..335bb0b --- /dev/null +++ b/README.java.md @@ -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 [-- 应用参数...]` 等价于用一组业界常用参数执行 +`java -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-.yaml`,来源依次为 `--env`、环境变量 `APP_ENV`、默认 `prod` | + +### 生成的参数 + +| 类别 | 参数 | +| --- | --- | +| 通用 | `-server`、`-Xss256k`、`-XX:+SegmentedCodeCache`、`-XX:+ExplicitGCInvokesConcurrent`、`-Djava.security.egd=file:/dev/./urandom` | +| 堆 | `-Xmx`、`-Xms`、`-XX:MaxDirectMemorySize=` | +| 压缩指针 | `-XX:+UseCompressedOops`、`-XX:+UseCompressedClassPointers`;**堆超过 30g 时自动省略** | +| GC | `-XX:+UseG1GC` | +| GC 日志(JDK 9+) | `-Xlog:gc*,safepoint:/gc.log:time,uptime:filecount=100,filesize=50M` | +| GC 日志(JDK 8) | `-verbose:gc`、`-Xloggc:/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 上都能跑。 + +### 选项 + +| 选项 | 说明 | +| --- | --- | +| `` | 应用 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` 指定的目录不存在时会自动创建。 diff --git a/README.md b/README.md index fe16109..dc3ae86 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ kopsc proc nohup ./server # 后台运行命令,输出写日志( kopsc proc wait nginx # 每秒检查一次,等 nginx 退出后再继续 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 explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因 kopsc explain docker clearsafe # 同上,空格写法也支持 @@ -139,6 +143,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | **disk** | 磁盘与文件占用:哪个目录、哪个文件最占空间 | [README.disk.md](README.disk.md) | | **diagnose** | 容器 / Pod 状态根因诊断(自动识别 Docker 与 K8s) | [README.diagnose.md](README.diagnose.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) | 辅助命令(不涉及具体资源,直接用即可): @@ -164,6 +169,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | 排障 | `kopsc diagnose web` | | 后台跑服务 | `kopsc proc nohup ./server` | | 等进程退出 | `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` | ## 平台识别 @@ -225,6 +231,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | diagnose | [`README.diagnose.md`](README.diagnose.md) | | proc | [`README.proc.md`](README.proc.md) | | pkg | [`README.pkg.md`](README.pkg.md) | +| java | [`README.java.md`](README.java.md) | 新分组需要新建 `README.<分组>.md`,并在本文件「命令一览」的表格里加一行链接。 每条命令至少要写清:**作用**、**实际执行什么**、**示例**;有独有选项时补选项表。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0868078..97bc899 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -221,6 +221,7 @@ var groupDescriptions = map[string]string{ "net": "网络与端口:端口被谁占用、本机监听了哪些端口", "pkg": "系统软件包:刷新包管理器(dnf/apt/apk/brew/winget)的软件源索引", "proc": "本机进程:在后台运行命令并写日志,关掉终端也不退出", + "java": "Java 应用:按 JDK 版本自动套用调优参数并前台运行 jar", } // newGroup builds an intermediate command such as `kopsc disk`. @@ -256,9 +257,19 @@ func variantDisplay(v registry.Variant) string { 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 [-- 应用参数...]`) +// 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 { leaf := &cobra.Command{ - Use: name + " [--dry-run] [-h] [-- 透传参数...]", + Use: leafUsage(name, cmd.Usage), Short: cmd.Summary, Long: cmd.Summary + "\n\n动作: " + cmd.Action, DisableFlagParsing: true, @@ -287,6 +298,15 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command { 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{ DryRun: dryRun, 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) { 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 != "" { - 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.Action) fmt.Fprintf(w, "当前平台: %s\n", resolve.DescribeInfo(a.Info)) diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index 8b3c408..f64ebb7 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -14,6 +14,7 @@ import ( // in-process command implementation. type BuiltinRequest struct { Args []string + Stdin io.Reader Stdout io.Writer Stderr io.Writer Info platform.Info @@ -42,6 +43,8 @@ var builtins = map[string]Builtin{ "docker.save": runDockerSave, "docker.stats": runDockerStats, "docker.stop": runDockerStop, + "java.run": runJavaRun, + "java.run.preview": runJavaPreview, "net.pids": runNetPIDs, "net.ports": runNetPorts, "proc.nohup": runNohup, @@ -72,7 +75,7 @@ func Preview(ctx context.Context, plan Plan, w io.Writer) error { return fmt.Errorf("未注册的确认动作 %q", name) } // 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 if err := fn(ctx, BuiltinRequest{ Args: plan.ExtraArgs, @@ -83,7 +86,11 @@ func Preview(ctx context.Context, plan Plan, w io.Writer) error { }); err != nil { 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()) return err } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index a49ce44..eb34b8e 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -114,6 +114,7 @@ func (p Plan) Run(ctx context.Context, opts Options) error { } return fn(ctx, BuiltinRequest{ Args: p.ExtraArgs, + Stdin: opts.Stdin, Stdout: opts.Stdout, Stderr: opts.Stderr, Info: p.Result.Info, diff --git a/internal/executor/javaflags.go b/internal/executor/javaflags.go new file mode 100644 index 0000000..f89cca6 --- /dev/null +++ b/internal/executor/javaflags.go @@ -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-.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) +} diff --git a/internal/executor/javaflags_test.go b/internal/executor/javaflags_test.go new file mode 100644 index 0000000..51ebd03 --- /dev/null +++ b/internal/executor/javaflags_test.go @@ -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) + } +} diff --git a/internal/executor/javarun.go b/internal/executor/javarun.go new file mode 100644 index 0000000..9fc91b3 --- /dev/null +++ b/internal/executor/javarun.go @@ -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 [-- 应用参数...]", 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 [-- 应用参数...]") + } + 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 +} diff --git a/internal/executor/javarun_test.go b/internal/executor/javarun_test.go new file mode 100644 index 0000000..cceee30 --- /dev/null +++ b/internal/executor/javarun_test.go @@ -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) + } + } +} diff --git a/internal/executor/javasignal_unix.go b/internal/executor/javasignal_unix.go new file mode 100644 index 0000000..cbc8271 --- /dev/null +++ b/internal/executor/javasignal_unix.go @@ -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) + } +} diff --git a/internal/executor/javasignal_windows.go b/internal/executor/javasignal_windows.go new file mode 100644 index 0000000..7debc16 --- /dev/null +++ b/internal/executor/javasignal_windows.go @@ -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() {} +} diff --git a/internal/platform/hardware.go b/internal/platform/hardware.go new file mode 100644 index 0000000..d96f096 --- /dev/null +++ b/internal/platform/hardware.go @@ -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: " " or "max " + 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) +} diff --git a/internal/platform/hardware_test.go b/internal/platform/hardware_test.go new file mode 100644 index 0000000..1e721ff --- /dev/null +++ b/internal/platform/hardware_test.go @@ -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) + } +} diff --git a/internal/platform/java.go b/internal/platform/java.go new file mode 100644 index 0000000..6916c5d --- /dev/null +++ b/internal/platform/java.go @@ -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 "" +} diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index ef2588f..916104b 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -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: " [-- 应用参数...]", + 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", Group: "pkg", diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index d7ab90d..58db317 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -25,7 +25,7 @@ func TestBuiltinIDsStable(t *testing.T) { "docker.prunebuilder", "docker.prunevolumes", "docker.clearall", "docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs", "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) { 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) + } + } +}