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:
2026-11-01 09:16:19 +08:00
parent 14817bcfe7
commit f62c1f4872
16 changed files with 2139 additions and 7 deletions
+9 -2
View File
@@ -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
}
+1
View File
@@ -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,
+204
View File
@@ -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)
}
+236
View File
@@ -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)
}
}
+395
View File
@@ -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
}
+321
View File
@@ -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)
}
}
}
+40
View File
@@ -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)
}
}
+11
View File
@@ -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() {}
}