From 9ab488d6a4203b8298ecbd3fc638a2a5c99fd2a0 Mon Sep 17 00:00:00 2001 From: et-nik Date: Sun, 20 Sep 2026 02:50:45 +0200 Subject: [PATCH 1/2] fixes --- .gitignore | 49 ++- README.md | 122 ++++-- cmd/mcrun/registry.go | 30 ++ cmd/mcrun/root.go | 65 ++- cmd/mcrun/run.go | 71 ++-- internal/config/config.go | 41 +- internal/config/config_test.go | 106 +++++ internal/config/server_properties.go | 10 +- internal/config/server_properties_test.go | 71 ++++ internal/java/detector.go | 17 +- internal/java/installer.go | 65 ++- internal/providers/bungeecord.go | 7 +- internal/providers/cauldron.go | 72 ++-- internal/providers/craftbukkit.go | 4 +- internal/providers/fabric.go | 9 +- internal/providers/fill.go | 184 +++++++++ internal/providers/forge.go | 135 +++---- internal/providers/installer.go | 86 ++++ internal/providers/launch_test.go | 196 +++++++++ internal/providers/leaf.go | 165 ++++++++ internal/providers/live_test.go | 108 +++++ internal/providers/mohist.go | 174 ++++++++ internal/providers/neoforge.go | 195 +++++++++ internal/providers/paper.go | 129 ------ internal/providers/pick_test.go | 168 ++++++++ internal/providers/provider.go | 55 ++- internal/providers/pufferfish.go | 267 ++++++++++++ internal/providers/purpur.go | 147 +++++++ internal/providers/quilt.go | 177 ++++++++ internal/providers/spigot.go | 4 +- internal/providers/sponge.go | 170 ++++++++ internal/providers/vanilla.go | 2 +- internal/providers/velocity.go | 131 ------ internal/providers/version.go | 78 ++++ internal/providers/version_test.go | 71 ++++ internal/providers/waterfall.go | 126 ------ internal/server/downloader.go | 160 +++----- internal/server/process_unix.go | 14 + internal/server/process_unix_test.go | 100 +++++ internal/server/process_windows.go | 13 + internal/server/runner.go | 472 +++++++++++++++++----- internal/server/runner_test.go | 464 +++++++++++++++++++++ internal/server/state.go | 111 +++++ internal/utils/download.go | 164 ++++++-- internal/utils/download_test.go | 193 +++++++++ internal/utils/env.go | 62 +++ internal/utils/env_test.go | 56 +++ internal/utils/hash.go | 53 ++- pkg/api/adoptium/client.go | 27 +- pkg/api/forge/client.go | 102 +++-- pkg/api/forge/client_test.go | 72 ++++ pkg/api/jenkins/client.go | 97 +++-- pkg/api/leaf/client.go | 94 +++++ pkg/api/mohist/client.go | 92 +++++ pkg/api/neoforge/client.go | 181 +++++++++ pkg/api/neoforge/client_test.go | 66 +++ pkg/api/paper/client.go | 240 ++++++++--- pkg/api/paper/client_test.go | 177 ++++++++ pkg/api/purpur/client.go | 103 +++++ pkg/api/quilt/client.go | 123 ++++++ pkg/api/sponge/client.go | 172 ++++++++ pkg/api/sponge/client_test.go | 42 ++ 62 files changed, 5911 insertions(+), 1046 deletions(-) create mode 100644 cmd/mcrun/registry.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/server_properties_test.go create mode 100644 internal/providers/fill.go create mode 100644 internal/providers/installer.go create mode 100644 internal/providers/launch_test.go create mode 100644 internal/providers/leaf.go create mode 100644 internal/providers/live_test.go create mode 100644 internal/providers/mohist.go create mode 100644 internal/providers/neoforge.go delete mode 100644 internal/providers/paper.go create mode 100644 internal/providers/pick_test.go create mode 100644 internal/providers/pufferfish.go create mode 100644 internal/providers/purpur.go create mode 100644 internal/providers/quilt.go create mode 100644 internal/providers/sponge.go delete mode 100644 internal/providers/velocity.go create mode 100644 internal/providers/version.go create mode 100644 internal/providers/version_test.go delete mode 100644 internal/providers/waterfall.go create mode 100644 internal/server/process_unix.go create mode 100644 internal/server/process_unix_test.go create mode 100644 internal/server/process_windows.go create mode 100644 internal/server/runner_test.go create mode 100644 internal/server/state.go create mode 100644 internal/utils/download_test.go create mode 100644 internal/utils/env.go create mode 100644 internal/utils/env_test.go create mode 100644 pkg/api/forge/client_test.go create mode 100644 pkg/api/leaf/client.go create mode 100644 pkg/api/mohist/client.go create mode 100644 pkg/api/neoforge/client.go create mode 100644 pkg/api/neoforge/client_test.go create mode 100644 pkg/api/paper/client_test.go create mode 100644 pkg/api/purpur/client.go create mode 100644 pkg/api/quilt/client.go create mode 100644 pkg/api/sponge/client.go create mode 100644 pkg/api/sponge/client_test.go diff --git a/.gitignore b/.gitignore index 62d3d8e..7a1a612 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,28 @@ -.paper/ -banned-ips.json -banned-players.json -bukkit.yml -cache/ -commands.yml -config/ -eula.txt -help.yml -libraries/ -logs/ -mcrun -ops.json -paper-*.jar -permissions.yml -plugins/ -server.properties -spigot.yml -usercache.json -versions/ -whitelist.json -world/ +# Anchored to the repository root: an unanchored "mcrun" or "config/" would also +# hide new sources under cmd/mcrun/ and internal/config/. +/.paper/ +/banned-ips.json +/banned-players.json +/bukkit.yml +/cache/ +/commands.yml +/config/ +/eula.txt +/help.yml +/libraries/ +/logs/ +/mcrun +/mcrun.exe +/mods/ +/ops.json +/*.jar +/permissions.yml +/plugins/ +/server.properties +/spigot.yml +/usercache.json +/versions/ +/whitelist.json +/world/ +/.mcrun-state.json *.bak diff --git a/README.md b/README.md index b183a9b..3ec1915 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ A cross-platform CLI tool to download and run Minecraft servers with integrated ## Features - **Cross-platform**: Works on Linux, Windows, and macOS -- **Multiple server types**: Vanilla, Paper, Fabric, Forge, proxy servers (Waterfall, Velocity, Bungeecord), and more +- **Multiple server types**: Vanilla, Paper and its forks (Folia, Purpur, Leaf, Pufferfish), mod loaders (Forge, NeoForge, Fabric, Quilt), hybrids (Mohist, Banner), SpongeVanilla and proxy servers (Velocity, Waterfall, Bungeecord) - **Automatic Java management**: Detects, installs, and manages Java versions -- **Official API integration**: Downloads from official sources (Mojang, PaperMC, Fabric, Forge) +- **Official API integration**: Downloads from official sources and verifies the published checksums - **Smart version mapping**: Automatically selects correct Java version for each Minecraft version +- **Panel friendly**: console input is passed straight to the server, stop signals shut it down gracefully, and an installed server still starts when a download API is unreachable - **Configuration files**: Global and per-server YAML configuration ## Installation @@ -70,7 +71,10 @@ mcrun run --version=1.20.4 --accept-eula mcrun run --mod=paper --version=1.20.4 --rcon-port=25575 --rcon-password=secret --accept-eula # Run Forge with custom memory -mcrun run --mod=forge --version=1.20.4 --memory=8G --min-memory=4G --accept-eula +mcrun run --mod=forge --version=1.20.1 --memory=8G --min-memory=4G --accept-eula + +# Run a specific NeoForge version +mcrun run --mod=neoforge --version=1.21.1 --mod-version=21.1.251 --accept-eula # Run Waterfall proxy mcrun run --mod=waterfall --version=1.21 @@ -82,6 +86,35 @@ mcrun run --mod=velocity mcrun run --mod=bungeecord ``` +#### Installation and updates + +`mcrun run` asks the provider which file serves the requested version, installs it if +that exact version is not in the server directory yet, and starts it: + +- Without `--mod-version` the newest build is used, so a restart picks up a new Paper + build or a new recommended Forge. The JAR a newer build replaced is removed. +- Installer-based servers (Forge, NeoForge, Quilt) are installed once per version. Forge + 1.17+ and NeoForge are started from the JVM argument file the installer writes + (`@libraries/.../unix_args.txt`), older Forge from its JAR. A `user_jvm_args.txt` in + the server directory is honoured; `--memory` and `--min-memory` win over it. +- What was installed is recorded in `.mcrun-state.json`. When the provider's API cannot + be reached, the installed server is started with a warning instead of failing - as + long as it matches the requested mod and version. + +#### Stopping and console input + +The server reads the console input of `mcrun` directly, so a control panel can write +commands to the stdin of `mcrun`. When stdin is not a terminal, `-Dterminal.jline=false` +is passed to make Paper, Forge and the like read plain lines; pass +`--jvm-args=-Dterminal.jline=true` to override. + +On `SIGINT`, `SIGTERM` and `SIGHUP` the server is asked to stop and gets 90 seconds to +save the world before it is killed; `mcrun` then exits with code 0. If the server stops +on its own with an error, `mcrun` exits with the same exit code. + +The selected Java is put first on `PATH` and exported as `JAVA_HOME` for the server +process: hybrid servers restart themselves through `java` after installing their libraries. + ### list List available server versions. @@ -177,8 +210,8 @@ mcrun install java --version=21 --set-default ``` -c, --config string Config file path (default: ~/.mcrun/config.yaml) -d, --dir string Server working directory (default: current directory) --m, --mod string Server type: vanilla, paper, forge, fabric, waterfall, velocity, bungeecord, spigot, craftbukkit, cauldron - --mod-version string Mod-specific version (e.g., Paper build number) +-m, --mod string Server type, see "Supported Server Types" (default: vanilla) + --mod-version string Mod-specific version (Paper build, Forge or NeoForge version, Fabric/Quilt loader, ...) --version string Minecraft version (e.g., 1.20.4) --java int Java version override (8, 11, 17, 21, 25) --java-path string Custom Java binary path @@ -189,25 +222,40 @@ mcrun install java --version=21 --set-default ### Game Servers -| Type | Description | Download Source | -|------|-------------|-----------------| -| `vanilla` | Official Minecraft server | Mojang API | -| `paper` | Paper (high-performance Spigot fork) | PaperMC API | -| `fabric` | Fabric mod loader | Fabric Meta API | -| `forge` | Forge mod loader | Forge Maven | -| `spigot` | Spigot (suggests Paper) | - | -| `craftbukkit` | CraftBukkit (suggests Paper) | - | -| `cauldron` | Cauldron (legacy, MC 1.7.10 max) | Archived | +| Type | Description | Download Source | `--mod-version` | +|-----------------|-----------------------------------------------|--------------------------|-----------------------| +| `vanilla` | Official Minecraft server | Mojang API | - | +| `paper` | Paper (high-performance Spigot fork) | PaperMC Fill API v3 | build number | +| `folia` | Folia (regionised multithreading, by PaperMC) | PaperMC Fill API v3 | build number | +| `purpur` | Purpur (Paper fork) | Purpur API | build number | +| `leaf` | Leaf (Paper fork) | Leaf API | build number | +| `pufferfish` | Pufferfish (Paper fork) | Pufferfish Jenkins | build number | +| `fabric` | Fabric mod loader | Fabric Meta API | loader version | +| `quilt` | Quilt mod loader | Quilt Meta API + Maven | loader version | +| `forge` | Forge mod loader | Forge Maven | Forge version | +| `neoforge` | NeoForge mod loader (1.20.1+) | NeoForged Maven | NeoForge version | +| `mohist` | Mohist (Forge + Bukkit hybrid) | MohistMC API | build number | +| `banner` | Banner (Fabric + Bukkit hybrid) | MohistMC API | build number | +| `spongevanilla` | SpongeVanilla (Sponge API) | Sponge Downloads API | SpongeVanilla version | +| `spigot` | Alias: installs Paper | PaperMC Fill API v3 | build number | +| `craftbukkit` | Alias: installs Paper | PaperMC Fill API v3 | build number | +| `cauldron` | Alias: installs Mohist 1.7.10 | MohistMC API | build number | + +Without `--version` the newest release with a stable build is used; a version that only +has pre-release builds yet (for example ALPHA builds of Paper right after a Minecraft +release) can still be requested explicitly. ### Proxy Servers -| Type | Description | Download Source | -|------|-------------|-----------------| -| `waterfall` | Waterfall proxy (BungeeCord fork by PaperMC) | PaperMC API | -| `velocity` | Velocity proxy (modern, high-performance) | PaperMC API | -| `bungeecord` | BungeeCord proxy (original) | Jenkins CI | +| Type | Description | Download Source | +|--------------|----------------------------------------------|---------------------| +| `waterfall` | Waterfall proxy (BungeeCord fork by PaperMC) | PaperMC Fill API v3 | +| `velocity` | Velocity proxy (modern, high-performance) | PaperMC Fill API v3 | +| `bungeecord` | BungeeCord proxy (original) | Jenkins CI | -**Note:** Proxy servers don't require EULA acceptance or server.properties - they use their own `config.yml` configuration. +**Note:** Proxy servers don't require EULA acceptance or server.properties - they use their own configuration +(`velocity.toml`, `config.yml`). `--port` is passed to Velocity on the command line; Waterfall and Bungeecord +take their listen address from `config.yml` only. ## Java Version Requirements @@ -225,7 +273,9 @@ metadata is unavailable, this fallback table is used: | 1.17 - 1.17.1 | Java 17 | | 1.16.5 and older | Java 8 | -**Proxy servers** (Waterfall, Velocity, Bungeecord) require **Java 17**. +**Proxy servers**: Waterfall and Bungeecord run on **Java 17**, Velocity on **Java 25**. + +On Alpine and other musl based systems the Alpine builds of Temurin are installed. ## Configuration @@ -262,7 +312,8 @@ cache: ### Per-Server Configuration -Location: `.mcrun.yaml` in server directory +Location: `.mcrun.yaml` in server directory. Its values are the defaults of the +corresponding flags: a flag given on the command line always wins. ```yaml version: "1.20.4" @@ -286,7 +337,7 @@ server: ## Default JVM Arguments -mcrun uses optimized JVM flags by default (Aikar's flags): +Unless `server.jvm_args` is set in the config, mcrun starts the server with: ``` -XX:+UseG1GC @@ -295,18 +346,19 @@ mcrun uses optimized JVM flags by default (Aikar's flags): -XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch --XX:G1NewSizePercent=30 --XX:G1MaxNewSizePercent=40 --XX:G1HeapRegionSize=8M --XX:G1ReservePercent=20 --XX:G1HeapWastePercent=5 --XX:G1MixedGCCountTarget=4 --XX:InitiatingHeapOccupancyPercent=15 --XX:G1MixedGCLiveThresholdPercent=90 --XX:G1RSetUpdatingPauseTimePercent=5 --XX:SurvivorRatio=32 --XX:+PerfDisableSharedMem --XX:MaxTenuringThreshold=1 +``` + +The heap is `-Xmx2G -Xms1G` by default (`--memory`, `--min-memory`); an initial heap +above the maximum is lowered to it. For large heaps consider the full set of +[Aikar's flags](https://docs.papermc.io/paper/aikars-flags) through `server.jvm_args`. + +## Development + +```bash +go vet ./... && go test ./... + +# Resolve the default server of every provider against the real download APIs +go test -tags live ./internal/providers/ -run TestLiveResolve -v ``` ## License diff --git a/cmd/mcrun/registry.go b/cmd/mcrun/registry.go new file mode 100644 index 0000000..0f441ce --- /dev/null +++ b/cmd/mcrun/registry.go @@ -0,0 +1,30 @@ +package mcrun + +import "github.com/gameap/minecraft-runner/internal/providers" + +// createRegistry creates a provider registry with all providers +func createRegistry() *providers.Registry { + registry := providers.NewRegistry() + + registry.Register(providers.NewVanillaProvider()) + registry.Register(providers.NewPaperProvider()) + registry.Register(providers.NewFoliaProvider()) + registry.Register(providers.NewPurpurProvider()) + registry.Register(providers.NewLeafProvider()) + registry.Register(providers.NewPufferfishProvider()) + registry.Register(providers.NewFabricProvider()) + registry.Register(providers.NewQuiltProvider()) + registry.Register(providers.NewForgeProvider()) + registry.Register(providers.NewNeoForgeProvider()) + registry.Register(providers.NewMohistProvider()) + registry.Register(providers.NewBannerProvider()) + registry.Register(providers.NewSpongeVanillaProvider()) + registry.Register(providers.NewSpigotProvider()) + registry.Register(providers.NewCraftBukkitProvider()) + registry.Register(providers.NewCauldronProvider()) + registry.Register(providers.NewWaterfallProvider()) + registry.Register(providers.NewVelocityProvider()) + registry.Register(providers.NewBungeecordProvider()) + + return registry +} diff --git a/cmd/mcrun/root.go b/cmd/mcrun/root.go index 7d03730..568ec3c 100644 --- a/cmd/mcrun/root.go +++ b/cmd/mcrun/root.go @@ -1,12 +1,16 @@ package mcrun import ( + "errors" "fmt" "os" + "strings" "github.com/spf13/cobra" "github.com/gameap/minecraft-runner/internal/config" + "github.com/gameap/minecraft-runner/internal/server" + "github.com/gameap/minecraft-runner/internal/utils" ) var ( @@ -54,39 +58,78 @@ var rootCmd = &cobra.Command{ Cross-platform CLI tool to download and run Minecraft servers with integrated Java management. -Supported server types: vanilla, paper, forge, fabric, spigot, craftbukkit, cauldron, -waterfall, velocity, bungeecord`, +Supported server types: +` + supportedModsHelp(), + // A server that exits with an error is not a usage mistake, and Execute + // reports the error itself + SilenceUsage: true, + SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + utils.SetVersion(Version) + var err error cfg, err = config.Load(cfgFile, serverDir) if err != nil { return fmt.Errorf("failed to load config: %w", err) } - // Apply config defaults if flags not set - if mcVersion == "" && cfg.Defaults.Version != "" { - mcVersion = cfg.Defaults.Version - } - if mod == "" && cfg.Defaults.Mod != "" { - mod = cfg.Defaults.Mod - } + applyConfigDefaults(cmd) return nil }, } +// applyConfigDefaults lets the config files stand in for flags that were not +// given. The mod flag has a default of its own, so only Changed can tell +// whether the user asked for vanilla. +func applyConfigDefaults(cmd *cobra.Command) { + flags := cmd.Flags() + + if !flags.Changed("version") && cfg.Defaults.Version != "" { + mcVersion = cfg.Defaults.Version + } + if !flags.Changed("mod") && cfg.Defaults.Mod != "" { + mod = cfg.Defaults.Mod + } + if !flags.Changed("mod-version") && cfg.Defaults.ModVersion != "" { + modVersion = cfg.Defaults.ModVersion + } + if !flags.Changed("java") && cfg.Java.Version != 0 { + javaVersion = cfg.Java.Version + } + if !flags.Changed("java-path") && cfg.Java.Path != "" { + javaPath = cfg.Java.Path + } +} + +func supportedModsHelp() string { + return " " + strings.Join(createRegistry().List(), ", ") +} + func Execute() { - if err := rootCmd.Execute(); err != nil { + err := rootCmd.Execute() + if err == nil { + return + } + + var exitErr *server.ExitError + if errors.As(err, &exitErr) { fmt.Fprintf(os.Stderr, "Error: %v\n", err) + if exitErr.Code > 0 { + os.Exit(exitErr.Code) + } os.Exit(1) } + + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } func init() { rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file (default: ~/.mcrun/config.yaml)") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "V", false, "enable verbose output") rootCmd.PersistentFlags().StringVar(&mcVersion, "version", "", "Minecraft version (e.g., 1.20.4)") - rootCmd.PersistentFlags().StringVarP(&mod, "mod", "m", "vanilla", "server mod type (vanilla, paper, forge, fabric, spigot, craftbukkit, cauldron, waterfall, velocity, bungeecord)") + rootCmd.PersistentFlags().StringVarP(&mod, "mod", "m", "vanilla", "server mod type (see the list of supported server types)") rootCmd.PersistentFlags().StringVar(&modVersion, "mod-version", "", "mod-specific version") rootCmd.PersistentFlags().IntVar(&javaVersion, "java", 0, "Java version override (8, 11, 17, 21, 25)") rootCmd.PersistentFlags().StringVar(&javaPath, "java-path", "", "custom Java binary path") diff --git a/cmd/mcrun/run.go b/cmd/mcrun/run.go index 6c2dcd8..e5ab52f 100644 --- a/cmd/mcrun/run.go +++ b/cmd/mcrun/run.go @@ -9,7 +9,6 @@ import ( "github.com/spf13/cobra" "github.com/gameap/minecraft-runner/internal/java" - "github.com/gameap/minecraft-runner/internal/providers" "github.com/gameap/minecraft-runner/internal/server" ) @@ -30,35 +29,24 @@ var runCmd = &cobra.Command{ Short: "Download (if needed) and run a Minecraft server", Long: `Downloads the specified Minecraft server (if not already present) and runs it. +The server is stopped gracefully on SIGINT, SIGTERM and SIGHUP: it gets time to +save the world before mcrun gives up and kills it. + Examples: mcrun run # Run latest vanilla server mcrun run --version=1.20.4 # Run specific vanilla version mcrun run --mod=paper --version=1.20.4 # Run Paper server - mcrun run --mod=forge --version=1.20.4 # Run Forge server + mcrun run --mod=forge --version=1.20.1 # Run Forge server + mcrun run --mod=neoforge --version=1.21.1 # Run NeoForge server mcrun run --version=1.20.4 --memory=4G # Run with 4GB memory`, RunE: func(cmd *cobra.Command, args []string) error { - // Create context that cancels on interrupt - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Handle interrupt signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - go func() { - <-sigChan - cancel() - }() - - // Create provider registry - registry := createRegistry() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + defer stop() - // Create Java manager - javaManager := java.NewManager(cfg) + applyNetworkDefaults(cmd) - // Create runner - runner := server.NewRunner(registry, javaManager, cfg) + runner := server.NewRunner(createRegistry(), java.NewManager(cfg), cfg) - // Run server return runner.Run(ctx, server.RunOptions{ Version: mcVersion, Mod: mod, @@ -79,6 +67,29 @@ Examples: }, } +// applyNetworkDefaults fills the network flags that were not given from the +// per-server config +func applyNetworkDefaults(cmd *cobra.Command) { + flags := cmd.Flags() + network := cfg.Server.Network + + if !flags.Changed("ip") && network.IP != "" { + runIP = network.IP + } + if !flags.Changed("port") && network.Port != 0 { + runPort = network.Port + } + if !flags.Changed("query-port") && network.QueryPort != 0 { + runQueryPort = network.QueryPort + } + if !flags.Changed("rcon-port") && network.RconPort != 0 { + runRconPort = network.RconPort + } + if !flags.Changed("rcon-password") && network.RconPassword != "" { + runRconPassword = network.RconPassword + } +} + func init() { rootCmd.AddCommand(runCmd) @@ -92,21 +103,3 @@ func init() { runCmd.Flags().StringSliceVar(&runJVMArgs, "jvm-args", nil, "additional JVM arguments") runCmd.Flags().BoolVar(&runAcceptEULA, "accept-eula", false, "automatically accept Minecraft EULA") } - -// createRegistry creates a provider registry with all providers -func createRegistry() *providers.Registry { - registry := providers.NewRegistry() - - registry.Register(providers.NewVanillaProvider()) - registry.Register(providers.NewPaperProvider()) - registry.Register(providers.NewFabricProvider()) - registry.Register(providers.NewForgeProvider()) - registry.Register(providers.NewSpigotProvider()) - registry.Register(providers.NewCraftBukkitProvider()) - registry.Register(providers.NewCauldronProvider()) - registry.Register(providers.NewWaterfallProvider()) - registry.Register(providers.NewVelocityProvider()) - registry.Register(providers.NewBungeecordProvider()) - - return registry -} diff --git a/internal/config/config.go b/internal/config/config.go index b2062ee..4c3c8b1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" @@ -19,6 +20,7 @@ type Config struct { type DefaultsConfig struct { Version string `yaml:"version"` Mod string `yaml:"mod"` + ModVersion string `yaml:"mod_version"` AcceptEULA bool `yaml:"accept_eula"` Memory string `yaml:"memory"` MinMemory string `yaml:"min_memory"` @@ -29,12 +31,21 @@ type JavaConfig struct { PreferBundled bool `yaml:"prefer_bundled"` AutoInstall bool `yaml:"auto_install"` Paths map[int]string `yaml:"paths"` + + // Version and Path come from the per-server config and act as the + // defaults of the --java and --java-path flags + Version int `yaml:"-"` + Path string `yaml:"-"` } // ServerConfig contains server-related settings type ServerConfig struct { JVMArgs []string `yaml:"jvm_args"` Properties map[string]string `yaml:"properties"` + + // Network comes from the per-server config and acts as the defaults of + // the --ip, --port, --query-port and --rcon-* flags + Network ServerLocalValues `yaml:"-"` } // CacheConfig contains cache settings @@ -118,7 +129,7 @@ func Load(configPath string, serverDir string) (*Config, error) { if globalPath != "" { if err := loadFromFile(globalPath, cfg); err != nil && !os.IsNotExist(err) { - return nil, err + return nil, fmt.Errorf("failed to load %s: %w", globalPath, err) } } @@ -126,8 +137,11 @@ func Load(configPath string, serverDir string) (*Config, error) { if serverDir != "" { localPath := filepath.Join(serverDir, ".mcrun.yaml") localCfg := &ServerLocalConfig{} - if err := loadFromFile(localPath, localCfg); err == nil { - // Merge local config into main config + err := loadFromFile(localPath, localCfg) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to load %s: %w", localPath, err) + } + if err == nil { mergeLocalConfig(cfg, localCfg) } } @@ -153,19 +167,36 @@ func mergeLocalConfig(cfg *Config, local *ServerLocalConfig) { if local.Mod != "" { cfg.Defaults.Mod = local.Mod } + if local.ModVersion != "" { + cfg.Defaults.ModVersion = local.ModVersion + } if local.Java.Memory != "" { cfg.Defaults.Memory = local.Java.Memory } if local.Java.MinMem != "" { cfg.Defaults.MinMemory = local.Java.MinMem } - if local.Java.Version != 0 { - // Store in paths map for later lookup + + // A path next to a version pins the binary of that Java version; a path on + // its own is the binary to run the server with + switch { + case local.Java.Version != 0 && local.Java.Path != "": + if cfg.Java.Paths == nil { + cfg.Java.Paths = make(map[int]string) + } cfg.Java.Paths[local.Java.Version] = local.Java.Path + cfg.Java.Version = local.Java.Version + case local.Java.Version != 0: + cfg.Java.Version = local.Java.Version + case local.Java.Path != "": + cfg.Java.Path = local.Java.Path } + if len(local.Java.Args) > 0 { cfg.Server.JVMArgs = append(cfg.Server.JVMArgs, local.Java.Args...) } + + cfg.Server.Network = local.Server } // Save saves the config to a file diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..ccb2348 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,106 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadAppliesLocalConfig(t *testing.T) { + dir := t.TempDir() + + local := `version: "1.20.1" +mod: forge +mod_version: "47.4.10" +java: + version: 17 + memory: 6G + min_memory: 2G + args: + - "-Dfoo=bar" +server: + ip: "0.0.0.0" + port: 25570 + query_port: 25571 + rcon_port: 25575 + rcon_password: "secret" +` + if err := os.WriteFile(filepath.Join(dir, ".mcrun.yaml"), []byte(local), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(filepath.Join(dir, "no-global-config.yaml"), dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Defaults.Version != "1.20.1" || cfg.Defaults.Mod != "forge" || cfg.Defaults.ModVersion != "47.4.10" { + t.Errorf("defaults = %+v", cfg.Defaults) + } + if cfg.Defaults.Memory != "6G" || cfg.Defaults.MinMemory != "2G" { + t.Errorf("memory = %s/%s", cfg.Defaults.Memory, cfg.Defaults.MinMemory) + } + if cfg.Java.Version != 17 || cfg.Java.Path != "" { + t.Errorf("java = %d %q", cfg.Java.Version, cfg.Java.Path) + } + if _, pinned := cfg.Java.Paths[17]; pinned { + t.Error("a Java version without a path must not pin an empty binary path") + } + if cfg.Server.Network.Port != 25570 || cfg.Server.Network.RconPassword != "secret" { + t.Errorf("network = %+v", cfg.Server.Network) + } + + last := cfg.Server.JVMArgs[len(cfg.Server.JVMArgs)-1] + if last != "-Dfoo=bar" { + t.Errorf("JVM args = %v", cfg.Server.JVMArgs) + } +} + +func TestLoadJavaPathVariants(t *testing.T) { + t.Run("a path with a version pins that version", func(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, ".mcrun.yaml"), []byte("java:\n version: 21\n path: /opt/jdk21/bin/java\n"), 0644) + + cfg, err := Load(filepath.Join(dir, "none.yaml"), dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Java.Paths[21] != "/opt/jdk21/bin/java" || cfg.Java.Version != 21 || cfg.Java.Path != "" { + t.Errorf("java = %+v", cfg.Java) + } + }) + + t.Run("a path on its own is the binary to use", func(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, ".mcrun.yaml"), []byte("java:\n path: /opt/java/bin/java\n"), 0644) + + cfg, err := Load(filepath.Join(dir, "none.yaml"), dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Java.Path != "/opt/java/bin/java" || cfg.Java.Version != 0 { + t.Errorf("java = %+v", cfg.Java) + } + }) +} + +func TestLoadRejectsBrokenLocalConfig(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, ".mcrun.yaml"), []byte("java: [not, a, mapping\n"), 0644) + + if _, err := Load(filepath.Join(dir, "none.yaml"), dir); err == nil { + t.Error("a malformed .mcrun.yaml must be reported, not silently ignored") + } +} + +func TestLoadWithoutAnyConfig(t *testing.T) { + dir := t.TempDir() + + cfg, err := Load(filepath.Join(dir, "none.yaml"), dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Defaults.Mod != "vanilla" { + t.Errorf("mod = %q", cfg.Defaults.Mod) + } +} diff --git a/internal/config/server_properties.go b/internal/config/server_properties.go index ed11c90..360c778 100644 --- a/internal/config/server_properties.go +++ b/internal/config/server_properties.go @@ -52,11 +52,17 @@ func (sp *ServerProperties) Get(key string) string { return sp.properties[key] } -// Set sets a property value +// Set sets a property value. Values are kept the way they are written to the +// file, so the value is escaped here, once, like java.util.Properties expects. func (sp *ServerProperties) Set(key, value string) { - sp.properties[key] = value + sp.properties[key] = propertyValueEscaper.Replace(value) } +// propertyValueEscaper protects the characters java.util.Properties treats +// specially in a value. A backslash in an RCON password would otherwise be +// read by the server as the start of an escape sequence and silently dropped. +var propertyValueEscaper = strings.NewReplacer("\\", "\\\\", "\n", "\\n", "\r", "\\r", "\t", "\\t") + // SetIP sets the server IP func (sp *ServerProperties) SetIP(ip string) { sp.Set("server-ip", ip) diff --git a/internal/config/server_properties_test.go b/internal/config/server_properties_test.go new file mode 100644 index 0000000..fad3543 --- /dev/null +++ b/internal/config/server_properties_test.go @@ -0,0 +1,71 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestServerPropertiesRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "server.properties") + + original := "#Minecraft server properties\n" + + "motd=A \\u00A7aGreen\\u00A7r Server\n" + + "level-type=minecraft\\:normal\n" + + "server-port=25565\n" + + "enable-rcon=false\n" + if err := os.WriteFile(path, []byte(original), 0644); err != nil { + t.Fatal(err) + } + + props, err := LoadServerProperties(dir) + if err != nil { + t.Fatalf("LoadServerProperties: %v", err) + } + + props.SetPort(25570) + props.SetRcon(25575, `pa\ss=wo:rd`) + + if err := props.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + saved := string(data) + + for _, want := range []string{ + "#Minecraft server properties\n", + "motd=A \\u00A7aGreen\\u00A7r Server\n", + "level-type=minecraft\\:normal\n", + "server-port=25570\n", + "enable-rcon=true\n", + "rcon.port=25575\n", + `rcon.password=pa\\ss=wo:rd` + "\n", + } { + if !strings.Contains(saved, want) { + t.Errorf("saved file misses %q:\n%s", want, saved) + } + } + + if strings.Count(saved, "server-port=") != 1 { + t.Errorf("server-port must be replaced in place:\n%s", saved) + } +} + +func TestCreateEULA(t *testing.T) { + dir := t.TempDir() + + if err := CreateEULA(dir); err != nil { + t.Fatalf("CreateEULA: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "eula.txt")) + if err != nil || !strings.Contains(string(data), "eula=true") { + t.Errorf("eula.txt = %q, %v", data, err) + } +} diff --git a/internal/java/detector.go b/internal/java/detector.go index 84df7b4..28ea70a 100644 --- a/internal/java/detector.go +++ b/internal/java/detector.go @@ -9,6 +9,8 @@ import ( "runtime" "strconv" "strings" + + "github.com/gameap/minecraft-runner/internal/utils" ) // Installation represents a detected Java installation @@ -156,6 +158,9 @@ func (d *Detector) findJavaBinaries(searchPath string) []string { } if info.IsDir() { + if strings.HasPrefix(info.Name(), stagingPrefix) { + return filepath.SkipDir + } return nil } @@ -191,12 +196,14 @@ func (d *Detector) getInstallationInfo(javaBin string) (*Installation, error) { // Parse vendor vendor := parseJavaVendor(outputStr) - // Detect architecture - arch := runtime.GOARCH - if strings.Contains(strings.ToLower(outputStr), "64-bit") || strings.Contains(outputStr, "amd64") { - arch = "x64" - } else if strings.Contains(outputStr, "aarch64") { + // "64-Bit" is printed by every 64-bit VM, so only an explicit architecture + // name in the output can refine what the host already tells + arch := utils.GetArch() + switch { + case strings.Contains(outputStr, "aarch64"): arch = "aarch64" + case strings.Contains(outputStr, "amd64"): + arch = "x64" } return &Installation{ diff --git a/internal/java/installer.go b/internal/java/installer.go index c6d5152..a2699fe 100644 --- a/internal/java/installer.go +++ b/internal/java/installer.go @@ -11,6 +11,10 @@ import ( "github.com/gameap/minecraft-runner/pkg/api/adoptium" ) +// stagingPrefix names the directories a Java archive is extracted into before +// the finished Java home is moved into place; the detector skips them +const stagingPrefix = ".install-" + // InstallOptions contains options for Java installation type InstallOptions struct { SystemWide bool // Install to system directory vs local @@ -53,32 +57,48 @@ func (i *Installer) Install(ctx context.Context, version int, opts InstallOption fmt.Printf("Downloading Java %d (%s)...\n", version, asset.Version.Semver) - // Download the archive - archivePath := filepath.Join(os.TempDir(), asset.Binary.Package.Name) + // Several servers may start at once and all find Java missing. Each one + // downloads into a file of its own and extracts into a staging directory, + // so that no process ever sees a half-written archive or Java home. + archive, err := os.CreateTemp("", "mcrun-java-*-"+asset.Binary.Package.Name) + if err != nil { + return nil, fmt.Errorf("failed to create temporary archive: %w", err) + } + archivePath := archive.Name() + archive.Close() + defer os.Remove(archivePath) + if err := i.http.DownloadFile(ctx, asset.Binary.Package.Link, archivePath); err != nil { return nil, fmt.Errorf("failed to download Java: %w", err) } - defer os.Remove(archivePath) - // Verify checksum fmt.Println("Verifying checksum...") if err := utils.VerifySHA256(archivePath, asset.Binary.Package.Checksum); err != nil { return nil, fmt.Errorf("checksum verification failed: %w", err) } - // Extract archive fmt.Println("Extracting...") - var javaHome string + staging, err := os.MkdirTemp(installDir, stagingPrefix+"*") + if err != nil { + return nil, fmt.Errorf("failed to create staging directory: %w", err) + } + defer os.RemoveAll(staging) + + var extracted string if runtime.GOOS == "windows" { - javaHome, err = utils.ExtractZip(archivePath, installDir) + extracted, err = utils.ExtractZip(archivePath, staging) } else { - javaHome, err = utils.ExtractTarGz(archivePath, installDir) + extracted, err = utils.ExtractTarGz(archivePath, staging) } if err != nil { return nil, fmt.Errorf("failed to extract Java: %w", err) } - // Find java binary + javaHome, err := i.promote(extracted, staging, installDir, version) + if err != nil { + return nil, err + } + javaBin := i.findJavaBinary(javaHome) if javaBin == "" { return nil, fmt.Errorf("could not find java binary in extracted archive") @@ -102,6 +122,33 @@ func (i *Installer) Install(ctx context.Context, version int, opts InstallOption }, nil } +// promote moves an extracted Java home out of the staging directory into its +// final place. If another process got there first, its copy is used instead. +func (i *Installer) promote(extracted, staging, installDir string, version int) (string, error) { + name := filepath.Base(extracted) + if filepath.Clean(extracted) == filepath.Clean(staging) { + name = fmt.Sprintf("java-%d", version) + } + + javaHome := filepath.Join(installDir, name) + + if i.findJavaBinary(javaHome) != "" { + return javaHome, nil + } + + // A home without a java binary is the leftover of an interrupted install + os.RemoveAll(javaHome) + + if err := os.Rename(extracted, javaHome); err != nil { + if i.findJavaBinary(javaHome) != "" { + return javaHome, nil + } + return "", fmt.Errorf("failed to move Java into %s: %w", javaHome, err) + } + + return javaHome, nil +} + // getInstallDir determines the installation directory func (i *Installer) getInstallDir(opts InstallOptions) (string, error) { if opts.Directory != "" { diff --git a/internal/providers/bungeecord.go b/internal/providers/bungeecord.go index 692fbee..beb2068 100644 --- a/internal/providers/bungeecord.go +++ b/internal/providers/bungeecord.go @@ -25,6 +25,11 @@ func (p *BungeecordProvider) Name() string { return "bungeecord" } +// IsProxy reports that Bungeecord is a proxy rather than a game server +func (p *BungeecordProvider) IsProxy() bool { + return true +} + // ListVersions returns available builds (Bungeecord doesn't have versions, only builds) func (p *BungeecordProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { builds, err := p.client.GetBuilds(ctx) @@ -102,7 +107,7 @@ func (p *BungeecordProvider) GetServerJar(ctx context.Context, _, modVersion str } // PostDownload handles post-download steps (none for Bungeecord) -func (p *BungeecordProvider) PostDownload(ctx context.Context, jarPath string, javaPath string) error { +func (p *BungeecordProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { return nil } diff --git a/internal/providers/cauldron.go b/internal/providers/cauldron.go index 5917696..89f6e92 100644 --- a/internal/providers/cauldron.go +++ b/internal/providers/cauldron.go @@ -5,12 +5,21 @@ import ( "fmt" ) -// CauldronProvider handles legacy Cauldron servers -type CauldronProvider struct{} +// cauldronMinecraftVersion is the last Minecraft version Cauldron supported +const cauldronMinecraftVersion = "1.7.10" + +// CauldronProvider keeps the discontinued Cauldron server type working. Its +// downloads are gone, so it serves Mohist 1.7.10 instead: the maintained +// Forge + Bukkit hybrid for the same Minecraft version. +type CauldronProvider struct { + mohist *MohistProvider +} // NewCauldronProvider creates a new Cauldron provider func NewCauldronProvider() *CauldronProvider { - return &CauldronProvider{} + return &CauldronProvider{ + mohist: NewMohistProvider(), + } } // Name returns the provider name @@ -19,38 +28,35 @@ func (p *CauldronProvider) Name() string { } func (p *CauldronProvider) printDeprecationWarning() { - fmt.Println("\n[WARNING] Cauldron is a legacy project (last version: MC 1.7.10)") - fmt.Println(" It is no longer maintained and may have security vulnerabilities.") - fmt.Println("\n Modern alternatives:") - fmt.Println(" - Mohist (Forge + Bukkit for modern MC versions)") - fmt.Println(" - SpongeForge (Forge + Sponge API)") - fmt.Println(" - Magma (Forge + Bukkit/Spigot)") - fmt.Println() + fmt.Println("\n[WARNING] Cauldron is discontinued and its downloads are no longer available.") + fmt.Printf(" Using Mohist %s instead - a maintained Forge + Bukkit hybrid.\n", cauldronMinecraftVersion) + fmt.Printf(" Run: mcrun run --mod=mohist --version=%s\n\n", cauldronMinecraftVersion) } -// ListVersions returns available Minecraft versions -func (p *CauldronProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { +// ListVersions returns the only Minecraft version Cauldron is served for +func (p *CauldronProvider) ListVersions(_ context.Context) ([]VersionInfo, error) { p.printDeprecationWarning() - // Cauldron only supports these versions return []VersionInfo{ - {MinecraftVersion: "1.7.10", IsStable: true, Type: "legacy"}, - {MinecraftVersion: "1.7.2", IsStable: true, Type: "legacy"}, - {MinecraftVersion: "1.6.4", IsStable: true, Type: "legacy"}, + {MinecraftVersion: cauldronMinecraftVersion, IsStable: true, Type: "legacy"}, }, nil } -// ListModVersions returns mod versions for a specific Minecraft version +// ListModVersions returns the Mohist builds that stand in for Cauldron func (p *CauldronProvider) ListModVersions(ctx context.Context, mcVersion string) ([]VersionInfo, error) { p.printDeprecationWarning() - return []VersionInfo{}, nil + + if mcVersion != cauldronMinecraftVersion { + return []VersionInfo{}, nil + } + + return p.mohist.ListModVersions(ctx, mcVersion) } // GetLatestVersion returns the latest stable version -func (p *CauldronProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { - p.printDeprecationWarning() +func (p *CauldronProvider) GetLatestVersion(_ context.Context) (*VersionInfo, error) { return &VersionInfo{ - MinecraftVersion: "1.7.10", + MinecraftVersion: cauldronMinecraftVersion, IsStable: true, Type: "legacy", }, nil @@ -60,32 +66,20 @@ func (p *CauldronProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, func (p *CauldronProvider) GetServerJar(ctx context.Context, mcVersion, modVersion string) (*ServerJar, error) { p.printDeprecationWarning() - // Known Cauldron download URLs (archived) - // Note: These may not be available as the project is discontinued - urls := map[string]string{ - "1.7.10": "https://sourceforge.net/projects/cauldron-unofficial/files/1.7.10/cauldron-1.7.10-1.1388.1.0.jar/download", + if mcVersion != cauldronMinecraftVersion { + return nil, fmt.Errorf("cauldron is only available for Minecraft %s, not %s", + cauldronMinecraftVersion, mcVersion) } - url, ok := urls[mcVersion] - if !ok { - return nil, fmt.Errorf("Cauldron version %s not available. Cauldron only supports MC 1.6.4-1.7.10", mcVersion) - } - - return &ServerJar{ - Version: mcVersion, - URL: url, - Filename: fmt.Sprintf("cauldron-%s.jar", mcVersion), - RequiresInstall: false, - }, nil + return p.mohist.GetServerJar(ctx, mcVersion, modVersion) } // PostDownload handles post-download steps -func (p *CauldronProvider) PostDownload(ctx context.Context, jarPath string, javaPath string) error { - return nil +func (p *CauldronProvider) PostDownload(ctx context.Context, dir string, jar *ServerJar, javaPath string) error { + return p.mohist.PostDownload(ctx, dir, jar, javaPath) } // GetRecommendedJavaVersion returns the recommended Java version func (p *CauldronProvider) GetRecommendedJavaVersion(_ context.Context, _ string) int { - // Cauldron is old and requires Java 8 return 8 } diff --git a/internal/providers/craftbukkit.go b/internal/providers/craftbukkit.go index a9a7b88..dba5723 100644 --- a/internal/providers/craftbukkit.go +++ b/internal/providers/craftbukkit.go @@ -7,7 +7,7 @@ import ( // CraftBukkitProvider suggests Paper as a replacement for CraftBukkit type CraftBukkitProvider struct { - paper *PaperProvider + paper *FillProvider } // NewCraftBukkitProvider creates a new CraftBukkit provider @@ -56,7 +56,7 @@ func (p *CraftBukkitProvider) GetServerJar(ctx context.Context, mcVersion, modVe } // PostDownload handles post-download steps -func (p *CraftBukkitProvider) PostDownload(ctx context.Context, jarPath string, javaPath string) error { +func (p *CraftBukkitProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { return nil } diff --git a/internal/providers/fabric.go b/internal/providers/fabric.go index c56fa0c..019e081 100644 --- a/internal/providers/fabric.go +++ b/internal/providers/fabric.go @@ -33,10 +33,15 @@ func (p *FabricProvider) ListVersions(ctx context.Context) ([]VersionInfo, error versions := make([]VersionInfo, 0, len(gameVersions)) for _, v := range gameVersions { + vType := "release" + if !v.Stable { + vType = "snapshot" + } + versions = append(versions, VersionInfo{ MinecraftVersion: v.Version, IsStable: v.Stable, - Type: "release", + Type: vType, }) } @@ -121,7 +126,7 @@ func (p *FabricProvider) GetServerJar(ctx context.Context, mcVersion, modVersion } // PostDownload handles post-download steps (none for Fabric) -func (p *FabricProvider) PostDownload(ctx context.Context, jarPath string, javaPath string) error { +func (p *FabricProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { return nil } diff --git a/internal/providers/fill.go b/internal/providers/fill.go new file mode 100644 index 0000000..1baf9b4 --- /dev/null +++ b/internal/providers/fill.go @@ -0,0 +1,184 @@ +package providers + +import ( + "context" + "fmt" + "strconv" + + "github.com/gameap/minecraft-runner/pkg/api/paper" +) + +// FillProvider serves the projects published through the PaperMC Fill API: +// Paper, Folia and the Velocity and Waterfall proxies +type FillProvider struct { + client *paper.Client + name string + project string + proxy bool + versionType string + javaVersion func(ctx context.Context, version string) int +} + +// NewPaperProvider creates a new Paper provider +func NewPaperProvider() *FillProvider { + return newFillProvider(paper.NewClient(), "paper", false, GetRecommendedJavaVersion) +} + +// NewFoliaProvider creates a new Folia provider +func NewFoliaProvider() *FillProvider { + return newFillProvider(paper.NewClient(), "folia", false, GetRecommendedJavaVersion) +} + +// NewVelocityProvider creates a new Velocity proxy provider +func NewVelocityProvider() *FillProvider { + return newFillProvider(paper.NewClient(), "velocity", true, fixedJavaVersion(25)) +} + +// NewWaterfallProvider creates a new Waterfall proxy provider +func NewWaterfallProvider() *FillProvider { + return newFillProvider(paper.NewClient(), "waterfall", true, fixedJavaVersion(17)) +} + +func newFillProvider( + client *paper.Client, project string, proxy bool, javaVersion func(context.Context, string) int, +) *FillProvider { + versionType := "release" + if proxy { + versionType = "proxy" + } + + return &FillProvider{ + client: client, + name: project, + project: project, + proxy: proxy, + versionType: versionType, + javaVersion: javaVersion, + } +} + +func fixedJavaVersion(version int) func(context.Context, string) int { + return func(context.Context, string) int { + return version + } +} + +// Name returns the provider name +func (p *FillProvider) Name() string { + return p.name +} + +// IsProxy reports whether the project is a proxy rather than a game server +func (p *FillProvider) IsProxy() bool { + return p.proxy +} + +// ListenArgs returns the command line arguments that set the listen port. +// Only Velocity takes its port this way; Waterfall reads it from config.yml. +func (p *FillProvider) ListenArgs(port int) []string { + if p.project != "velocity" || port <= 0 { + return nil + } + return []string{"--port", strconv.Itoa(port)} +} + +// ListVersions returns available versions, newest first +func (p *FillProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { + project, err := p.client.GetProject(ctx, p.project) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(project.Versions)) + for _, version := range project.Versions { + versions = append(versions, VersionInfo{ + MinecraftVersion: version, + IsStable: !isPreRelease(version), + Type: p.versionType, + }) + } + + return versions, nil +} + +// ListModVersions returns builds for a specific version +func (p *FillProvider) ListModVersions(ctx context.Context, version string) ([]VersionInfo, error) { + builds, err := p.client.GetVersionBuilds(ctx, p.project, version) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(builds)) + for _, build := range builds { + versions = append(versions, VersionInfo{ + MinecraftVersion: version, + ModVersion: strconv.Itoa(build.ID), + IsStable: build.IsStable(), + Type: "build", + }) + } + + return versions, nil +} + +// GetLatestVersion returns the newest version that has a stable build +func (p *FillProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { + version, err := p.client.GetLatestStableVersion(ctx, p.project) + if err != nil { + return nil, err + } + + return &VersionInfo{ + MinecraftVersion: version, + IsStable: true, + Type: p.versionType, + }, nil +} + +// GetServerJar returns download info for a specific version +func (p *FillProvider) GetServerJar(ctx context.Context, version, modVersion string) (*ServerJar, error) { + var build *paper.BuildInfo + var err error + + if modVersion != "" { + number, parseErr := strconv.Atoi(modVersion) + if parseErr != nil { + return nil, fmt.Errorf("invalid build number: %s", modVersion) + } + build, err = p.client.GetBuildInfo(ctx, p.project, version, number) + } else { + build, err = p.client.GetLatestBuild(ctx, p.project, version) + } + + if err != nil { + return nil, err + } + + download, ok := build.ServerDownload() + if !ok { + return nil, fmt.Errorf("build %d of %s %s has no server download", build.ID, p.name, version) + } + + if !build.IsStable() { + fmt.Printf("[WARNING] %s %s has no stable build yet, using %s build %d\n", + p.name, version, build.Channel, build.ID) + } + + return &ServerJar{ + Version: version, + ModVersion: strconv.Itoa(build.ID), + URL: download.URL, + SHA256: download.Checksums.SHA256, + Filename: download.Name, + }, nil +} + +// PostDownload handles post-download steps (none for Fill projects) +func (p *FillProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { + return nil +} + +// GetRecommendedJavaVersion returns the recommended Java version +func (p *FillProvider) GetRecommendedJavaVersion(ctx context.Context, version string) int { + return p.javaVersion(ctx, version) +} diff --git a/internal/providers/forge.go b/internal/providers/forge.go index 1bd7d93..42b7579 100644 --- a/internal/providers/forge.go +++ b/internal/providers/forge.go @@ -3,9 +3,6 @@ package providers import ( "context" "fmt" - "os" - "os/exec" - "path/filepath" "github.com/gameap/minecraft-runner/pkg/api/forge" ) @@ -44,40 +41,48 @@ func (p *ForgeProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) }) } + sortVersionInfosDesc(result) + return result, nil } -// ListModVersions returns Forge versions for a specific Minecraft version +// ListModVersions returns Forge versions for a specific Minecraft version, newest first func (p *ForgeProvider) ListModVersions(ctx context.Context, mcVersion string) ([]VersionInfo, error) { - // Forge typically has one recommended and one latest version per MC version promotions, err := p.client.GetPromotions(ctx) if err != nil { return nil, err } - var versions []VersionInfo + metadata, err := p.client.GetMavenMetadata(ctx) + if err != nil { + return nil, err + } + + recommended := promotions.Promos[mcVersion+"-recommended"] + latest := promotions.Promos[mcVersion+"-latest"] + + artifacts := metadata[mcVersion] + versions := make([]VersionInfo, 0, len(artifacts)) + + for i := len(artifacts) - 1; i >= 0; i-- { + forgeVersion := forge.ForgeVersionOf(artifacts[i], mcVersion) + + vType := "build" + switch forgeVersion { + case recommended: + vType = "recommended" + case latest: + vType = "latest" + } - if recommended, ok := promotions.Promos[mcVersion+"-recommended"]; ok { versions = append(versions, VersionInfo{ MinecraftVersion: mcVersion, - ModVersion: recommended, - IsStable: true, - Type: "recommended", + ModVersion: forgeVersion, + IsStable: forgeVersion == recommended, + Type: vType, }) } - if latest, ok := promotions.Promos[mcVersion+"-latest"]; ok { - // Only add if different from recommended - if len(versions) == 0 || versions[0].ModVersion != latest { - versions = append(versions, VersionInfo{ - MinecraftVersion: mcVersion, - ModVersion: latest, - IsStable: false, - Type: "latest", - }) - } - } - return versions, nil } @@ -88,29 +93,21 @@ func (p *ForgeProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, err return nil, err } - if len(versions) == 0 { - return nil, fmt.Errorf("no Forge versions available") - } - - // Find the highest MC version with a recommended build + // Prefer the highest MC version with a recommended build var latest *forge.ForgeVersion - for i := range versions { - v := &versions[i] - if v.IsRecommended { - if latest == nil || compareVersions(v.MinecraftVersion, latest.MinecraftVersion) > 0 { - latest = v - } - } - } - - // Fallback to any version - if latest == nil { + for _, recommendedOnly := range []bool{true, false} { for i := range versions { v := &versions[i] - if latest == nil || compareVersions(v.MinecraftVersion, latest.MinecraftVersion) > 0 { + if recommendedOnly && !v.IsRecommended { + continue + } + if latest == nil || compareMCVersions(v.MinecraftVersion, latest.MinecraftVersion) > 0 { latest = v } } + if latest != nil { + break + } } if latest == nil { @@ -136,41 +133,44 @@ func (p *ForgeProvider) GetServerJar(ctx context.Context, mcVersion, modVersion forgeVersion = v.ForgeVersion } - installerURL := p.client.GetInstallerURL(mcVersion, forgeVersion) - installerName := fmt.Sprintf("forge-%s-%s-installer.jar", mcVersion, forgeVersion) + artifact, err := p.client.ResolveArtifactVersion(ctx, mcVersion, forgeVersion) + if err != nil { + return nil, err + } return &ServerJar{ Version: mcVersion, ModVersion: forgeVersion, - URL: installerURL, - Filename: installerName, + URL: p.client.GetInstallerURL(artifact), + Filename: forge.InstallerName(artifact), RequiresInstall: true, + ServerArgs: []string{"nogui"}, }, nil } -// PostDownload runs the Forge installer -func (p *ForgeProvider) PostDownload(ctx context.Context, jarPath string, javaPath string) error { - if javaPath == "" { - javaPath = "java" - } +// ResolveLaunch finds the launch target of an installed Forge version. +// Forge 1.17+ is modular and starts from a JVM argument file; since 1.20.3 that +// file merely points at the shim JAR, so it covers every modern release. Older +// installers leave a runnable JAR in the server directory instead. +func (p *ForgeProvider) ResolveLaunch(dir string, jar *ServerJar) *LaunchTarget { + prefix := forge.ArtifactPrefix(jar.Version, jar.ModVersion) - dir := filepath.Dir(jarPath) + if target := resolveArgFileLaunch(dir, "net", "minecraftforge", "forge", prefix); target != nil { + return target + } - // Run the Forge installer - cmd := exec.CommandContext(ctx, javaPath, "-jar", jarPath, "--installServer") - cmd.Dir = dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + return resolveLegacyForgeJar(dir, "forge-"+prefix) +} +// PostDownload runs the Forge installer +func (p *ForgeProvider) PostDownload(ctx context.Context, dir string, jar *ServerJar, javaPath string) error { fmt.Println("Running Forge installer...") - if err := cmd.Run(); err != nil { - return fmt.Errorf("forge installer failed: %w", err) + + if err := runInstaller(ctx, javaPath, dir, jar.Filename, "--installServer"); err != nil { + return installerError("Forge", err) } - // Clean up installer - os.Remove(jarPath) - // Also remove installer log - os.Remove(filepath.Join(dir, "installer.log")) + removeInstaller(dir, jar.Filename) return nil } @@ -179,18 +179,3 @@ func (p *ForgeProvider) PostDownload(ctx context.Context, jarPath string, javaPa func (p *ForgeProvider) GetRecommendedJavaVersion(ctx context.Context, mcVersion string) int { return GetRecommendedJavaVersion(ctx, mcVersion) } - -// compareVersions compares two version strings -func compareVersions(v1, v2 string) int { - var major1, minor1, patch1, major2, minor2, patch2 int - fmt.Sscanf(v1, "%d.%d.%d", &major1, &minor1, &patch1) - fmt.Sscanf(v2, "%d.%d.%d", &major2, &minor2, &patch2) - - if major1 != major2 { - return major1 - major2 - } - if minor1 != minor2 { - return minor1 - minor2 - } - return patch1 - patch2 -} diff --git a/internal/providers/installer.go b/internal/providers/installer.go new file mode 100644 index 0000000..282d76c --- /dev/null +++ b/internal/providers/installer.go @@ -0,0 +1,86 @@ +package providers + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + + "github.com/gameap/minecraft-runner/internal/utils" +) + +// runInstaller runs a downloaded installer JAR inside the server directory. +// The installer gets no stdin of its own: the console input of the future +// server must not be consumed by it. +func runInstaller(ctx context.Context, javaPath, dir, installerFile string, args ...string) error { + if javaPath == "" { + javaPath = "java" + } + + cmdArgs := append([]string{"-jar", installerFile}, args...) + + cmd := exec.CommandContext(ctx, javaPath, cmdArgs...) + cmd.Dir = dir + cmd.Env = utils.JavaEnvironment(javaPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// removeInstaller deletes an installer JAR and the logs it leaves behind +func removeInstaller(dir, installerFile string) { + os.Remove(filepath.Join(dir, installerFile)) + os.Remove(filepath.Join(dir, installerFile+".log")) + os.Remove(filepath.Join(dir, "installer.log")) +} + +// argFileName is the JVM argument file modular Forge and NeoForge ship per platform +func argFileName() string { + if runtime.GOOS == "windows" { + return "win_args.txt" + } + return "unix_args.txt" +} + +// resolveArgFileLaunch returns the @argfile launch of a modular Forge-like +// install, or nil when libraries// holds none +func resolveArgFileLaunch(dir string, librarySubPath ...string) *LaunchTarget { + argFile := filepath.Join(append(append([]string{"libraries"}, librarySubPath...), argFileName())...) + + if _, err := os.Stat(filepath.Join(dir, argFile)); err != nil { + return nil + } + + return &LaunchTarget{ArgFiles: []string{filepath.ToSlash(argFile)}} +} + +// resolveLegacyForgeJar finds the runnable JAR a pre-1.17 Forge installer +// leaves in the server directory. Old releases append a Minecraft suffix or +// "-universal" to the name, hence the glob. +func resolveLegacyForgeJar(dir, prefix string) *LaunchTarget { + matches, err := filepath.Glob(filepath.Join(dir, prefix+"*.jar")) + if err != nil { + return nil + } + + sort.Strings(matches) + + for _, match := range matches { + name := filepath.Base(match) + if strings.HasSuffix(name, "-installer.jar") || strings.HasSuffix(name, "-shim.jar") { + continue + } + return &LaunchTarget{Jar: name} + } + + return nil +} + +func installerError(name string, err error) error { + return fmt.Errorf("%s installer failed (its log is kept in the server directory): %w", name, err) +} diff --git a/internal/providers/launch_test.go b/internal/providers/launch_test.go new file mode 100644 index 0000000..3cfbc5e --- /dev/null +++ b/internal/providers/launch_test.go @@ -0,0 +1,196 @@ +package providers + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func touch(t *testing.T, dir string, name ...string) { + t.Helper() + + path := filepath.Join(append([]string{dir}, name...)...) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0644); err != nil { + t.Fatal(err) + } +} + +func TestForgeResolveLaunch(t *testing.T) { + provider := NewForgeProvider() + + t.Run("nothing installed", func(t *testing.T) { + jar := &ServerJar{Version: "1.20.1", ModVersion: "47.4.10"} + if target := provider.ResolveLaunch(t.TempDir(), jar); target != nil { + t.Errorf("target = %+v, want nil", target) + } + }) + + t.Run("modular Forge starts from its argument file", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "libraries", "net", "minecraftforge", "forge", "1.20.1-47.4.10", argFileName()) + + target := provider.ResolveLaunch(dir, &ServerJar{Version: "1.20.1", ModVersion: "47.4.10"}) + + want := &LaunchTarget{ArgFiles: []string{"libraries/net/minecraftforge/forge/1.20.1-47.4.10/" + argFileName()}} + if !reflect.DeepEqual(target, want) { + t.Errorf("target = %+v, want %+v", target, want) + } + }) + + t.Run("the argument file wins over the shim JAR next to it", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "libraries", "net", "minecraftforge", "forge", "1.21.1-52.1.0", argFileName()) + touch(t, dir, "forge-1.21.1-52.1.0-shim.jar") + + target := provider.ResolveLaunch(dir, &ServerJar{Version: "1.21.1", ModVersion: "52.1.0"}) + if target == nil || len(target.ArgFiles) != 1 || target.Jar != "" { + t.Errorf("target = %+v, want an argument file launch", target) + } + }) + + t.Run("another Forge version does not count as installed", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "libraries", "net", "minecraftforge", "forge", "1.20.1-47.4.10", argFileName()) + touch(t, dir, "forge-1.16.5-36.2.34.jar") + + if target := provider.ResolveLaunch(dir, &ServerJar{Version: "1.20.1", ModVersion: "47.4.23"}); target != nil { + t.Errorf("target = %+v, want nil", target) + } + }) + + t.Run("legacy Forge runs the JAR the installer left behind", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "forge-1.12.2-14.23.5.2859.jar") + touch(t, dir, "forge-1.12.2-14.23.5.2859-installer.jar") + touch(t, dir, "minecraft_server.1.12.2.jar") + + target := provider.ResolveLaunch(dir, &ServerJar{Version: "1.12.2", ModVersion: "14.23.5.2859"}) + + want := &LaunchTarget{Jar: "forge-1.12.2-14.23.5.2859.jar"} + if !reflect.DeepEqual(target, want) { + t.Errorf("target = %+v, want %+v", target, want) + } + }) + + t.Run("old releases name their JAR with extra suffixes", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "forge-1.7.10-10.13.4.1614-1.7.10-universal.jar") + + target := provider.ResolveLaunch(dir, &ServerJar{Version: "1.7.10", ModVersion: "10.13.4.1614"}) + + want := &LaunchTarget{Jar: "forge-1.7.10-10.13.4.1614-1.7.10-universal.jar"} + if !reflect.DeepEqual(target, want) { + t.Errorf("target = %+v, want %+v", target, want) + } + }) + + t.Run("an installer alone is not a launch target", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "forge-1.12.2-14.23.5.2859-installer.jar") + + if target := provider.ResolveLaunch(dir, &ServerJar{Version: "1.12.2", ModVersion: "14.23.5.2859"}); target != nil { + t.Errorf("target = %+v, want nil", target) + } + }) +} + +func TestNeoForgeResolveLaunch(t *testing.T) { + provider := NewNeoForgeProvider() + + t.Run("modern", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "libraries", "net", "neoforged", "neoforge", "21.1.251", argFileName()) + + target := provider.ResolveLaunch(dir, &ServerJar{Version: "1.21.1", ModVersion: "21.1.251"}) + + want := &LaunchTarget{ArgFiles: []string{"libraries/net/neoforged/neoforge/21.1.251/" + argFileName()}} + if !reflect.DeepEqual(target, want) { + t.Errorf("target = %+v, want %+v", target, want) + } + }) + + t.Run("1.20.1 keeps the Forge layout", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "libraries", "net", "neoforged", "forge", "1.20.1-47.1.106", argFileName()) + + target := provider.ResolveLaunch(dir, &ServerJar{Version: "1.20.1", ModVersion: "47.1.106"}) + + want := &LaunchTarget{ArgFiles: []string{"libraries/net/neoforged/forge/1.20.1-47.1.106/" + argFileName()}} + if !reflect.DeepEqual(target, want) { + t.Errorf("target = %+v, want %+v", target, want) + } + }) + + t.Run("not installed", func(t *testing.T) { + if target := provider.ResolveLaunch(t.TempDir(), &ServerJar{Version: "1.21.1", ModVersion: "21.1.251"}); target != nil { + t.Errorf("target = %+v, want nil", target) + } + }) +} + +func TestQuiltResolveLaunch(t *testing.T) { + provider := NewQuiltProvider() + jar := &ServerJar{Version: "1.21.1", ModVersion: "0.30.1"} + + t.Run("installed", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "quilt-server-launch-1.21.1-0.30.1.jar") + touch(t, dir, "server.jar") + + want := &LaunchTarget{Jar: "quilt-server-launch-1.21.1-0.30.1.jar"} + if target := provider.ResolveLaunch(dir, jar); !reflect.DeepEqual(target, want) { + t.Errorf("target = %+v, want %+v", target, want) + } + }) + + t.Run("a launcher of another version does not count", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "quilt-server-launch-1.20.4-0.30.1.jar") + touch(t, dir, "server.jar") + + if target := provider.ResolveLaunch(dir, jar); target != nil { + t.Errorf("target = %+v, want nil", target) + } + }) + + t.Run("a launcher without the vanilla server does not count", func(t *testing.T) { + dir := t.TempDir() + touch(t, dir, "quilt-server-launch-1.21.1-0.30.1.jar") + + if target := provider.ResolveLaunch(dir, jar); target != nil { + t.Errorf("target = %+v, want nil", target) + } + }) +} + +func TestProxyProviders(t *testing.T) { + proxies := map[string]bool{ + "velocity": true, "waterfall": true, "bungeecord": true, + "paper": false, "folia": false, "forge": false, "vanilla": false, + } + + all := []Provider{ + NewVelocityProvider(), NewWaterfallProvider(), NewBungeecordProvider(), + NewPaperProvider(), NewFoliaProvider(), NewForgeProvider(), NewVanillaProvider(), + } + + for _, provider := range all { + if got := IsProxy(provider); got != proxies[provider.Name()] { + t.Errorf("IsProxy(%s) = %v, want %v", provider.Name(), got, proxies[provider.Name()]) + } + } + + if got := NewVelocityProvider().ListenArgs(25577); !reflect.DeepEqual(got, []string{"--port", "25577"}) { + t.Errorf("velocity ListenArgs = %v", got) + } + if got := NewVelocityProvider().ListenArgs(0); got != nil { + t.Errorf("velocity ListenArgs(0) = %v, want nil", got) + } + if got := NewWaterfallProvider().ListenArgs(25577); got != nil { + t.Errorf("waterfall ListenArgs = %v, want nil", got) + } +} diff --git a/internal/providers/leaf.go b/internal/providers/leaf.go new file mode 100644 index 0000000..a487a3b --- /dev/null +++ b/internal/providers/leaf.go @@ -0,0 +1,165 @@ +package providers + +import ( + "context" + "fmt" + "strconv" + + "github.com/gameap/minecraft-runner/pkg/api/leaf" +) + +// LeafProvider provides Leaf server downloads +type LeafProvider struct { + client *leaf.Client +} + +// NewLeafProvider creates a new Leaf provider +func NewLeafProvider() *LeafProvider { + return &LeafProvider{ + client: leaf.NewClient(), + } +} + +// Name returns the provider name +func (p *LeafProvider) Name() string { + return "leaf" +} + +// ListVersions returns available Minecraft versions, newest first +func (p *LeafProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { + mcVersions, err := p.sortedVersions(ctx) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(mcVersions)) + for _, version := range mcVersions { + versions = append(versions, VersionInfo{ + MinecraftVersion: version, + IsStable: !isPreRelease(version), + Type: "release", + }) + } + + return versions, nil +} + +// ListModVersions returns builds for a specific Minecraft version, newest first +func (p *LeafProvider) ListModVersions(ctx context.Context, mcVersion string) ([]VersionInfo, error) { + builds, err := p.client.GetBuilds(ctx, mcVersion) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(builds)) + for i := len(builds) - 1; i >= 0; i-- { + versions = append(versions, VersionInfo{ + MinecraftVersion: mcVersion, + ModVersion: strconv.Itoa(builds[i].Build), + IsStable: builds[i].IsStable(), + Type: "build", + }) + } + + return versions, nil +} + +// GetLatestVersion returns the newest Minecraft release Leaf was built for +func (p *LeafProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { + mcVersions, err := p.sortedVersions(ctx) + if err != nil { + return nil, err + } + + for _, version := range mcVersions { + if !isPreRelease(version) { + return &VersionInfo{MinecraftVersion: version, IsStable: true, Type: "release"}, nil + } + } + + if len(mcVersions) == 0 { + return nil, fmt.Errorf("no versions available") + } + + return &VersionInfo{MinecraftVersion: mcVersions[0], Type: "release"}, nil +} + +// GetServerJar returns download info for a specific version +func (p *LeafProvider) GetServerJar(ctx context.Context, mcVersion, modVersion string) (*ServerJar, error) { + builds, err := p.client.GetBuilds(ctx, mcVersion) + if err != nil { + return nil, err + } + + build, err := pickLeafBuild(builds, mcVersion, modVersion) + if err != nil { + return nil, err + } + + if !build.IsStable() { + fmt.Printf("[WARNING] Leaf %s has no build in the default channel, using %s build %d\n", + mcVersion, build.Channel, build.Build) + } + + return &ServerJar{ + Version: mcVersion, + ModVersion: strconv.Itoa(build.Build), + URL: p.client.GetDownloadURL(mcVersion, build.Build, build.Downloads.Primary.Name), + SHA256: build.Downloads.Primary.SHA256, + Filename: build.Downloads.Primary.Name, + }, nil +} + +// pickLeafBuild returns the requested build, or the newest one of the default +// channel, falling back to the newest build of any channel +func pickLeafBuild(builds []leaf.Build, mcVersion, modVersion string) (*leaf.Build, error) { + if modVersion != "" { + number, err := strconv.Atoi(modVersion) + if err != nil { + return nil, fmt.Errorf("invalid build number: %s", modVersion) + } + + for i := range builds { + if builds[i].Build == number { + return &builds[i], nil + } + } + + return nil, fmt.Errorf("build %d not found for version %s", number, mcVersion) + } + + for _, stableOnly := range []bool{true, false} { + for i := len(builds) - 1; i >= 0; i-- { + if stableOnly && !builds[i].IsStable() { + continue + } + if builds[i].Downloads.Primary.Name != "" { + return &builds[i], nil + } + } + } + + return nil, fmt.Errorf("no builds available for version %s", mcVersion) +} + +func (p *LeafProvider) sortedVersions(ctx context.Context) ([]string, error) { + project, err := p.client.GetProject(ctx) + if err != nil { + return nil, err + } + + versions := append([]string{}, project.Versions...) + sortMCVersionsDesc(versions) + + return versions, nil +} + +// PostDownload handles post-download steps (none for Leaf) +func (p *LeafProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { + return nil +} + +// GetRecommendedJavaVersion returns the recommended Java version +func (p *LeafProvider) GetRecommendedJavaVersion(ctx context.Context, mcVersion string) int { + return GetRecommendedJavaVersion(ctx, mcVersion) +} diff --git a/internal/providers/live_test.go b/internal/providers/live_test.go new file mode 100644 index 0000000..14f4619 --- /dev/null +++ b/internal/providers/live_test.go @@ -0,0 +1,108 @@ +//go:build live + +package providers + +import ( + "context" + "net/http" + "testing" + "time" +) + +// TestLiveResolve resolves the default server of every provider against the +// real download APIs. It needs network access and is not part of the regular +// test run: +// +// go test -tags live ./internal/providers/ -run TestLiveResolve -v +func TestLiveResolve(t *testing.T) { + all := []Provider{ + NewVanillaProvider(), + NewPaperProvider(), + NewFoliaProvider(), + NewPurpurProvider(), + NewLeafProvider(), + NewPufferfishProvider(), + NewFabricProvider(), + NewQuiltProvider(), + NewForgeProvider(), + NewNeoForgeProvider(), + NewMohistProvider(), + NewBannerProvider(), + NewSpongeVanillaProvider(), + NewSpigotProvider(), + NewCraftBukkitProvider(), + NewCauldronProvider(), + NewWaterfallProvider(), + NewVelocityProvider(), + NewBungeecordProvider(), + } + + for _, provider := range all { + t.Run(provider.Name(), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + latest, err := provider.GetLatestVersion(ctx) + if err != nil { + t.Fatalf("GetLatestVersion: %v", err) + } + + jar, err := provider.GetServerJar(ctx, latest.MinecraftVersion, "") + if err != nil { + t.Fatalf("GetServerJar(%s): %v", latest.MinecraftVersion, err) + } + + if jar.URL == "" || jar.Filename == "" || jar.Version == "" { + t.Fatalf("incomplete jar: %+v", jar) + } + + assertDownloadable(ctx, t, jar.URL) + + t.Logf("%s %s (mod %s): %s [sha256=%t sha1=%t md5=%t]", provider.Name(), jar.Version, + jar.ModVersion, jar.Filename, jar.SHA256 != "", jar.SHA1 != "", jar.MD5 != "") + }) + } +} + +// TestLiveResolveForgeEras covers the Forge releases whose artifact version +// cannot be assembled from the Minecraft and Forge versions alone +func TestLiveResolveForgeEras(t *testing.T) { + provider := NewForgeProvider() + + for _, mcVersion := range []string{"1.7.10", "1.8.9", "1.12.2", "1.16.5", "1.20.1", "1.21.1"} { + t.Run(mcVersion, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + jar, err := provider.GetServerJar(ctx, mcVersion, "") + if err != nil { + t.Fatalf("GetServerJar: %v", err) + } + + assertDownloadable(ctx, t, jar.URL) + + t.Logf("forge %s -> %s", mcVersion, jar.Filename) + }) + } +} + +func assertDownloadable(ctx context.Context, t *testing.T, url string) { + t.Helper() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Fatalf("invalid download URL %q: %v", url, err) + } + req.Header.Set("Range", "bytes=0-0") + req.Header.Set("User-Agent", "mcrun-live-test (+https://github.com/gameap/minecraft-runner)") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("download of %s failed: %v", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { + t.Fatalf("download of %s answered %d", url, resp.StatusCode) + } +} diff --git a/internal/providers/mohist.go b/internal/providers/mohist.go new file mode 100644 index 0000000..ca8aa9b --- /dev/null +++ b/internal/providers/mohist.go @@ -0,0 +1,174 @@ +package providers + +import ( + "context" + "fmt" + "strconv" + + "github.com/gameap/minecraft-runner/pkg/api/mohist" +) + +// mohistVersionProbes limits how many Minecraft versions are inspected when +// looking for the newest one that has a build +const mohistVersionProbes = 6 + +// MohistProvider provides the MohistMC hybrid servers: Mohist (Forge + Bukkit) +// and Banner (Fabric + Bukkit) +type MohistProvider struct { + client *mohist.Client + project string +} + +// NewMohistProvider creates a new Mohist provider +func NewMohistProvider() *MohistProvider { + return &MohistProvider{client: mohist.NewClient(), project: mohist.ProjectMohist} +} + +// NewBannerProvider creates a new Banner provider +func NewBannerProvider() *MohistProvider { + return &MohistProvider{client: mohist.NewClient(), project: mohist.ProjectBanner} +} + +// Name returns the provider name +func (p *MohistProvider) Name() string { + return p.project +} + +// ListVersions returns available Minecraft versions, newest first +func (p *MohistProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { + mcVersions, err := p.sortedVersions(ctx) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(mcVersions)) + for _, version := range mcVersions { + versions = append(versions, VersionInfo{ + MinecraftVersion: version, + IsStable: !isPreRelease(version), + Type: "release", + }) + } + + return versions, nil +} + +// ListModVersions returns builds for a specific Minecraft version, newest first +func (p *MohistProvider) ListModVersions(ctx context.Context, mcVersion string) ([]VersionInfo, error) { + builds, err := p.client.GetBuilds(ctx, p.project, mcVersion) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(builds)) + for _, build := range builds { + versions = append(versions, VersionInfo{ + MinecraftVersion: mcVersion, + ModVersion: strconv.Itoa(build.ID), + IsStable: true, + Type: "build", + }) + } + + return versions, nil +} + +// GetLatestVersion returns the newest Minecraft release that has a build. The +// API also lists versions that are announced but not built yet. +func (p *MohistProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { + mcVersions, err := p.sortedVersions(ctx) + if err != nil { + return nil, err + } + + probes := 0 + for _, version := range mcVersions { + if isPreRelease(version) { + continue + } + + if probes++; probes > mohistVersionProbes { + break + } + + builds, err := p.client.GetBuilds(ctx, p.project, version) + if err != nil { + return nil, err + } + + if _, err := pickMohistBuild(builds, version, ""); err == nil { + return &VersionInfo{MinecraftVersion: version, IsStable: true, Type: "release"}, nil + } + } + + return nil, fmt.Errorf("no %s builds available", p.project) +} + +// GetServerJar returns download info for a specific version +func (p *MohistProvider) GetServerJar(ctx context.Context, mcVersion, modVersion string) (*ServerJar, error) { + builds, err := p.client.GetBuilds(ctx, p.project, mcVersion) + if err != nil { + return nil, err + } + + build, err := pickMohistBuild(builds, mcVersion, modVersion) + if err != nil { + return nil, err + } + + return &ServerJar{ + Version: mcVersion, + ModVersion: strconv.Itoa(build.ID), + URL: p.client.GetDownloadURL(p.project, mcVersion, build.ID), + SHA256: build.FileSHA256, + Filename: fmt.Sprintf("%s-%s-%d.jar", p.project, mcVersion, build.ID), + ServerArgs: []string{"nogui"}, + }, nil +} + +// pickMohistBuild returns the requested build, or the newest one +func pickMohistBuild(builds []mohist.Build, mcVersion, modVersion string) (*mohist.Build, error) { + if len(builds) == 0 { + return nil, fmt.Errorf("no builds available for version %s", mcVersion) + } + + if modVersion == "" { + return &builds[0], nil + } + + number, err := strconv.Atoi(modVersion) + if err != nil { + return nil, fmt.Errorf("invalid build number: %s", modVersion) + } + + for i := range builds { + if builds[i].ID == number { + return &builds[i], nil + } + } + + return nil, fmt.Errorf("build %d not found for version %s", number, mcVersion) +} + +func (p *MohistProvider) sortedVersions(ctx context.Context) ([]string, error) { + mcVersions, err := p.client.GetVersions(ctx, p.project) + if err != nil { + return nil, err + } + + versions := append([]string{}, mcVersions...) + sortMCVersionsDesc(versions) + + return versions, nil +} + +// PostDownload handles post-download steps: none, the hybrids install their +// libraries themselves on the first start +func (p *MohistProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { + return nil +} + +// GetRecommendedJavaVersion returns the recommended Java version +func (p *MohistProvider) GetRecommendedJavaVersion(ctx context.Context, mcVersion string) int { + return GetRecommendedJavaVersion(ctx, mcVersion) +} diff --git a/internal/providers/neoforge.go b/internal/providers/neoforge.go new file mode 100644 index 0000000..fbdcf28 --- /dev/null +++ b/internal/providers/neoforge.go @@ -0,0 +1,195 @@ +package providers + +import ( + "context" + "fmt" + + "github.com/gameap/minecraft-runner/pkg/api/neoforge" +) + +// NeoForgeProvider provides NeoForge server downloads +type NeoForgeProvider struct { + client *neoforge.Client +} + +// NewNeoForgeProvider creates a new NeoForge provider +func NewNeoForgeProvider() *NeoForgeProvider { + return &NeoForgeProvider{ + client: neoforge.NewClient(), + } +} + +// Name returns the provider name +func (p *NeoForgeProvider) Name() string { + return "neoforge" +} + +// ListVersions returns the Minecraft versions NeoForge supports, newest first, +// each with its newest stable NeoForge version +func (p *NeoForgeProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { + releases, err := p.client.GetReleases(ctx) + if err != nil { + return nil, err + } + + newest := make(map[string]neoforge.Release) + for _, release := range releases { + current, seen := newest[release.MinecraftVersion] + if !seen || release.Stable || !current.Stable { + newest[release.MinecraftVersion] = release + } + } + + versions := make([]VersionInfo, 0, len(newest)) + for _, release := range newest { + versions = append(versions, releaseVersionInfo(release)) + } + + sortVersionInfosDesc(versions) + + return versions, nil +} + +// ListModVersions returns NeoForge versions for a specific Minecraft version, newest first +func (p *NeoForgeProvider) ListModVersions(ctx context.Context, mcVersion string) ([]VersionInfo, error) { + releases, err := p.client.GetReleases(ctx) + if err != nil { + return nil, err + } + + var versions []VersionInfo + for i := len(releases) - 1; i >= 0; i-- { + if releases[i].MinecraftVersion == mcVersion { + versions = append(versions, releaseVersionInfo(releases[i])) + } + } + + return versions, nil +} + +// GetLatestVersion returns the newest Minecraft version with a stable NeoForge release +func (p *NeoForgeProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { + versions, err := p.ListVersions(ctx) + if err != nil { + return nil, err + } + + for i := range versions { + if versions[i].IsStable { + return &versions[i], nil + } + } + + if len(versions) == 0 { + return nil, fmt.Errorf("no NeoForge versions available") + } + + return &versions[0], nil +} + +// GetServerJar returns download info for a specific version +func (p *NeoForgeProvider) GetServerJar(ctx context.Context, mcVersion, modVersion string) (*ServerJar, error) { + releases, err := p.client.GetReleases(ctx) + if err != nil { + return nil, err + } + + release, err := pickNeoForgeRelease(releases, mcVersion, modVersion) + if err != nil { + return nil, err + } + + if !release.Stable { + fmt.Printf("[WARNING] NeoForge has no stable release for Minecraft %s yet, using %s\n", + mcVersion, release.Version) + } + + checksum, err := p.client.GetInstallerSHA256(ctx, *release) + if err != nil { + return nil, err + } + + return &ServerJar{ + Version: mcVersion, + ModVersion: release.Version, + URL: release.InstallerURL(), + SHA256: checksum, + Filename: release.InstallerName(), + RequiresInstall: true, + ServerArgs: []string{"nogui"}, + }, nil +} + +// pickNeoForgeRelease returns the requested release, or the newest stable one +// for the Minecraft version, falling back to the newest pre-release +func pickNeoForgeRelease(releases []neoforge.Release, mcVersion, modVersion string) (*neoforge.Release, error) { + var newest *neoforge.Release + + for i := len(releases) - 1; i >= 0; i-- { + release := &releases[i] + if release.MinecraftVersion != mcVersion { + continue + } + + if modVersion != "" { + if release.Version == modVersion { + return release, nil + } + continue + } + + if release.Stable { + return release, nil + } + if newest == nil { + newest = release + } + } + + if newest != nil { + return newest, nil + } + + if modVersion != "" { + return nil, fmt.Errorf("no NeoForge %s published for Minecraft %s", modVersion, mcVersion) + } + + return nil, fmt.Errorf("no NeoForge version found for Minecraft %s", mcVersion) +} + +// ResolveLaunch finds the JVM argument file of an installed NeoForge version +func (p *NeoForgeProvider) ResolveLaunch(dir string, jar *ServerJar) *LaunchTarget { + if target := resolveArgFileLaunch(dir, "net", "neoforged", neoforge.ArtifactNeoForge, jar.ModVersion); target != nil { + return target + } + + legacyVersion := jar.Version + "-" + jar.ModVersion + return resolveArgFileLaunch(dir, "net", "neoforged", neoforge.ArtifactLegacyForge, legacyVersion) +} + +// PostDownload runs the NeoForge installer +func (p *NeoForgeProvider) PostDownload(ctx context.Context, dir string, jar *ServerJar, javaPath string) error { + fmt.Println("Running NeoForge installer...") + + if err := runInstaller(ctx, javaPath, dir, jar.Filename, "--installServer"); err != nil { + return installerError("NeoForge", err) + } + + removeInstaller(dir, jar.Filename) + + return nil +} + +// GetRecommendedJavaVersion returns the recommended Java version +func (p *NeoForgeProvider) GetRecommendedJavaVersion(ctx context.Context, mcVersion string) int { + return GetRecommendedJavaVersion(ctx, mcVersion) +} + +func releaseVersionInfo(release neoforge.Release) VersionInfo { + return VersionInfo{ + MinecraftVersion: release.MinecraftVersion, + ModVersion: release.Version, + IsStable: release.Stable, + Type: "neoforge", + } +} diff --git a/internal/providers/paper.go b/internal/providers/paper.go deleted file mode 100644 index ed52bd6..0000000 --- a/internal/providers/paper.go +++ /dev/null @@ -1,129 +0,0 @@ -package providers - -import ( - "context" - "fmt" - "strconv" - - "github.com/gameap/minecraft-runner/pkg/api/paper" -) - -// PaperProvider provides Paper server downloads -type PaperProvider struct { - client *paper.Client -} - -// NewPaperProvider creates a new Paper provider -func NewPaperProvider() *PaperProvider { - return &PaperProvider{ - client: paper.NewClient(), - } -} - -// Name returns the provider name -func (p *PaperProvider) Name() string { - return "paper" -} - -// ListVersions returns available Minecraft versions -func (p *PaperProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { - project, err := p.client.GetProject(ctx, "paper") - if err != nil { - return nil, err - } - - versions := make([]VersionInfo, 0, len(project.Versions)) - for i := len(project.Versions) - 1; i >= 0; i-- { - versions = append(versions, VersionInfo{ - MinecraftVersion: project.Versions[i], - IsStable: true, - Type: "release", - }) - } - - return versions, nil -} - -// ListModVersions returns builds for a specific Minecraft version -func (p *PaperProvider) ListModVersions(ctx context.Context, mcVersion string) ([]VersionInfo, error) { - vb, err := p.client.GetVersionBuilds(ctx, "paper", mcVersion) - if err != nil { - return nil, err - } - - versions := make([]VersionInfo, 0, len(*vb)) - for _, build := range *vb { - versions = append(versions, VersionInfo{ - MinecraftVersion: mcVersion, - ModVersion: strconv.Itoa(build.ID), - IsStable: build.Channel == "STABLE", - Type: "build", - }) - } - - return versions, nil -} - -// GetLatestVersion returns the latest stable version -func (p *PaperProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { - project, err := p.client.GetProject(ctx, "paper") - if err != nil { - return nil, err - } - - if len(project.Versions) == 0 { - return nil, fmt.Errorf("no versions available") - } - - // Latest version is the last in the list - latest := project.Versions[len(project.Versions)-1] - - return &VersionInfo{ - MinecraftVersion: latest, - IsStable: true, - Type: "release", - }, nil -} - -// GetServerJar returns download info for a specific version -func (p *PaperProvider) GetServerJar(ctx context.Context, mcVersion, modVersion string) (*ServerJar, error) { - var buildInfo *paper.BuildInfo - var err error - - if modVersion != "" { - // Get specific build - build, parseErr := strconv.Atoi(modVersion) - if parseErr != nil { - return nil, fmt.Errorf("invalid build number: %s", modVersion) - } - buildInfo, err = p.client.GetBuildInfo(ctx, "paper", mcVersion, build) - } else { - // Get latest build - buildInfo, err = p.client.GetLatestBuild(ctx, "paper", mcVersion) - } - - if err != nil { - return nil, err - } - - downloadURL := buildInfo.Downloads.Application.URL - - return &ServerJar{ - Version: mcVersion, - ModVersion: strconv.Itoa(buildInfo.ID), - URL: downloadURL, - SHA256: buildInfo.Downloads.Application.SHA256, - Filename: buildInfo.Downloads.Application.Name, - RequiresInstall: false, - }, nil -} - -// PostDownload handles post-download steps (none for Paper) -func (p *PaperProvider) PostDownload(ctx context.Context, jarPath string, javaPath string) error { - return nil -} - -// GetRecommendedJavaVersion returns the recommended Java version -func (p *PaperProvider) GetRecommendedJavaVersion(ctx context.Context, mcVersion string) int { - return GetRecommendedJavaVersion(ctx, mcVersion) -} diff --git a/internal/providers/pick_test.go b/internal/providers/pick_test.go new file mode 100644 index 0000000..630dfac --- /dev/null +++ b/internal/providers/pick_test.go @@ -0,0 +1,168 @@ +package providers + +import ( + "strings" + "testing" + + "github.com/gameap/minecraft-runner/pkg/api/leaf" + "github.com/gameap/minecraft-runner/pkg/api/mohist" + "github.com/gameap/minecraft-runner/pkg/api/neoforge" +) + +func neoForgeReleases(t *testing.T, versions ...string) []neoforge.Release { + t.Helper() + + releases := make([]neoforge.Release, 0, len(versions)) + for _, version := range versions { + release, ok := neoforge.ParseRelease(version) + if !ok { + t.Fatalf("ParseRelease(%q) failed", version) + } + releases = append(releases, release) + } + return releases +} + +func TestPickNeoForgeRelease(t *testing.T) { + releases := neoForgeReleases(t, + "21.1.1-beta", "21.1.250", "21.1.251", "21.1.252-beta", "26.3.0.6-beta", "26.3.0.7-beta") + + tests := []struct { + name, mc, mod, want string + wantErr bool + }{ + {name: "newest stable wins over a newer beta", mc: "1.21.1", want: "21.1.251"}, + {name: "explicit version", mc: "1.21.1", mod: "21.1.250", want: "21.1.250"}, + {name: "only betas yet", mc: "26.3", want: "26.3.0.7-beta"}, + {name: "unknown Minecraft version", mc: "1.19.2", wantErr: true}, + {name: "version of another Minecraft version", mc: "26.3", mod: "21.1.251", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + release, err := pickNeoForgeRelease(releases, tt.mc, tt.mod) + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got %+v", release) + } + return + } + if err != nil { + t.Fatalf("pickNeoForgeRelease: %v", err) + } + if release.Version != tt.want { + t.Errorf("version = %q, want %q", release.Version, tt.want) + } + }) + } +} + +func TestPickPufferfishBuild(t *testing.T) { + builds := []pufferfishBuild{ + {job: "Pufferfish-1.21", number: 39, minecraftVersion: "1.21.10"}, + {job: "Pufferfish-1.21", number: 38, minecraftVersion: "1.21.10"}, + {job: "Pufferfish-1.21", number: 33, minecraftVersion: "1.21.8"}, + {job: "Pufferfish-1.21", number: 32, minecraftVersion: "1.21.8"}, + } + + tests := []struct { + name, mc, mod string + want int + wantErr string + }{ + {name: "newest build of the exact version", mc: "1.21.8", want: 33}, + {name: "a line matches its newest build", mc: "1.21", want: 39}, + {name: "explicit build", mc: "1.21.8", mod: "32", want: 32}, + {name: "build of another version", mc: "1.21.8", mod: "39", wantErr: "not found"}, + {name: "unbuilt version lists what exists", mc: "1.21.5", wantErr: "1.21.10, 1.21.8"}, + {name: "invalid build number", mc: "1.21.8", mod: "abc", wantErr: "invalid build number"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + build, err := pickPufferfishBuild(builds, tt.mc, tt.mod) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want it to contain %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("pickPufferfishBuild: %v", err) + } + if build.number != tt.want { + t.Errorf("build = %d, want %d", build.number, tt.want) + } + }) + } +} + +func TestPufferfishArtifactVersion(t *testing.T) { + match := pufferfishArtifactVersion.FindStringSubmatch("pufferfish-paperclip-1.21.10-R0.1-SNAPSHOT-mojmap.jar") + if match == nil || match[1] != "1.21.10" { + t.Errorf("match = %v, want 1.21.10", match) + } + + if got := minecraftLine("1.21.8"); got != "1.21" { + t.Errorf("minecraftLine(1.21.8) = %q", got) + } + if got := minecraftLine("1.21"); got != "1.21" { + t.Errorf("minecraftLine(1.21) = %q", got) + } +} + +func leafBuild(number int, channel string) leaf.Build { + build := leaf.Build{Build: number, Channel: channel} + build.Downloads.Primary.Name = "leaf.jar" + return build +} + +func TestPickLeafBuild(t *testing.T) { + builds := []leaf.Build{leafBuild(10, "default"), leafBuild(11, "default"), leafBuild(12, "experimental")} + + build, err := pickLeafBuild(builds, "1.21.8", "") + if err != nil || build.Build != 11 { + t.Errorf("default channel: build = %+v, err = %v, want 11", build, err) + } + + build, err = pickLeafBuild([]leaf.Build{leafBuild(5, "experimental"), leafBuild(6, "experimental")}, "26.2", "") + if err != nil || build.Build != 6 { + t.Errorf("experimental only: build = %+v, err = %v, want 6", build, err) + } + + build, err = pickLeafBuild(builds, "1.21.8", "12") + if err != nil || build.Build != 12 { + t.Errorf("explicit: build = %+v, err = %v, want 12", build, err) + } + + if _, err := pickLeafBuild(builds, "1.21.8", "99"); err == nil { + t.Error("expected an error for an unknown build") + } + if _, err := pickLeafBuild(nil, "1.21.8", ""); err == nil { + t.Error("expected an error without builds") + } +} + +func TestPickMohistBuild(t *testing.T) { + builds := []mohist.Build{{ID: 471}, {ID: 470}, {ID: 54}} + + build, err := pickMohistBuild(builds, "1.20.1", "") + if err != nil || build.ID != 471 { + t.Errorf("newest: build = %+v, err = %v", build, err) + } + + build, err = pickMohistBuild(builds, "1.20.1", "54") + if err != nil || build.ID != 54 { + t.Errorf("explicit: build = %+v, err = %v", build, err) + } + + if _, err := pickMohistBuild(builds, "1.20.1", "999"); err == nil { + t.Error("expected an error for an unknown build") + } + if _, err := pickMohistBuild(builds, "1.20.1", "abc"); err == nil { + t.Error("expected an error for an invalid build number") + } + if _, err := pickMohistBuild(nil, "1.21.4", ""); err == nil { + t.Error("expected an error without builds") + } +} diff --git a/internal/providers/provider.go b/internal/providers/provider.go index 67d0db6..0d04a09 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -3,18 +3,28 @@ package providers import ( "context" "fmt" + "sort" "time" ) // ServerJar contains information about a downloadable server JAR type ServerJar struct { - Version string // Minecraft version - ModVersion string // Mod-specific version (e.g., Paper build number) - URL string // Download URL - SHA256 string // SHA256 hash for verification (optional) - SHA1 string // SHA1 hash for verification (optional) - Filename string // Suggested filename - RequiresInstall bool // True if post-download installation is needed (e.g., Forge) + Version string // Minecraft version + ModVersion string // Mod-specific version (e.g., Paper build number) + URL string // Download URL + SHA256 string // SHA256 hash for verification (optional) + SHA1 string // SHA1 hash for verification (optional) + MD5 string // MD5 hash for verification (optional) + Filename string // Suggested filename + RequiresInstall bool // True if post-download installation is needed (e.g., Forge) + ServerArgs []string // Program arguments; empty means the runner default +} + +// LaunchTarget describes how an installed server is started. +// Paths are relative to the server directory. +type LaunchTarget struct { + Jar string `json:"jar,omitempty"` // Started with -jar + ArgFiles []string `json:"arg_files,omitempty"` // JVM @argfiles (Forge 1.17+, NeoForge) } // VersionInfo contains information about an available version @@ -44,12 +54,38 @@ type Provider interface { GetServerJar(ctx context.Context, mcVersion, modVersion string) (*ServerJar, error) // PostDownload handles any post-download steps (e.g., Forge installer) - PostDownload(ctx context.Context, jarPath string, javaPath string) error + PostDownload(ctx context.Context, dir string, jar *ServerJar, javaPath string) error // GetRecommendedJavaVersion returns the recommended Java version for this MC version GetRecommendedJavaVersion(ctx context.Context, mcVersion string) int } +// LaunchResolver is implemented by providers whose installed server is not +// started with `-jar `. ResolveLaunch looks only at +// version-qualified paths, so it needs no record of earlier runs, and returns +// nil when that exact version is not installed in dir. +type LaunchResolver interface { + ResolveLaunch(dir string, jar *ServerJar) *LaunchTarget +} + +// ProxyProvider is implemented by proxy servers, which have neither an EULA +// nor a server.properties +type ProxyProvider interface { + IsProxy() bool +} + +// ListenArgsProvider is implemented by servers that take their listen port on +// the command line instead of from server.properties +type ListenArgsProvider interface { + ListenArgs(port int) []string +} + +// IsProxy reports whether the provider serves a proxy rather than a game server +func IsProxy(p Provider) bool { + proxy, ok := p.(ProxyProvider) + return ok && proxy.IsProxy() +} + // Registry holds all available providers type Registry struct { providers map[string]Provider @@ -76,11 +112,12 @@ func (r *Registry) Get(name string) (Provider, error) { return p, nil } -// List returns all registered provider names +// List returns all registered provider names in alphabetical order func (r *Registry) List() []string { names := make([]string, 0, len(r.providers)) for name := range r.providers { names = append(names, name) } + sort.Strings(names) return names } diff --git a/internal/providers/pufferfish.go b/internal/providers/pufferfish.go new file mode 100644 index 0000000..5d117d7 --- /dev/null +++ b/internal/providers/pufferfish.go @@ -0,0 +1,267 @@ +package providers + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/gameap/minecraft-runner/pkg/api/jenkins" +) + +const ( + pufferfishJenkinsURL = "https://ci.pufferfish.host" + + // pufferfishJobPrefix is followed by the Minecraft line a job builds: "Pufferfish-1.21" + pufferfishJobPrefix = "Pufferfish-" +) + +// pufferfishArtifactVersion extracts the Minecraft version from an artifact +// name such as "pufferfish-paperclip-1.21.10-R0.1-SNAPSHOT-mojmap.jar" +var pufferfishArtifactVersion = regexp.MustCompile(`paperclip-(\d+(?:\.\d+)+)-`) + +// PufferfishProvider provides Pufferfish server downloads. Pufferfish publishes +// through Jenkins with one job per Minecraft line, each build targeting +// whichever patch version was current when it ran. +type PufferfishProvider struct { + client *jenkins.Client +} + +// pufferfishBuild is a Jenkins build that produced a server JAR +type pufferfishBuild struct { + job string + number int + minecraftVersion string + artifactPath string +} + +// NewPufferfishProvider creates a new Pufferfish provider +func NewPufferfishProvider() *PufferfishProvider { + return &PufferfishProvider{ + client: jenkins.NewClient(pufferfishJenkinsURL), + } +} + +// Name returns the provider name +func (p *PufferfishProvider) Name() string { + return "pufferfish" +} + +// ListVersions returns the Minecraft versions Pufferfish has builds for, newest first +func (p *PufferfishProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { + lines, err := p.minecraftLines(ctx) + if err != nil { + return nil, err + } + + var versions []VersionInfo + seen := make(map[string]bool) + + for _, line := range lines { + builds, err := p.jobBuilds(ctx, line) + if err != nil { + return nil, err + } + + for _, build := range builds { + if seen[build.minecraftVersion] { + continue + } + seen[build.minecraftVersion] = true + + versions = append(versions, VersionInfo{ + MinecraftVersion: build.minecraftVersion, + ModVersion: strconv.Itoa(build.number), + IsStable: true, + Type: "release", + }) + } + } + + sortVersionInfosDesc(versions) + + return versions, nil +} + +// ListModVersions returns builds for a specific Minecraft version, newest first +func (p *PufferfishProvider) ListModVersions(ctx context.Context, mcVersion string) ([]VersionInfo, error) { + builds, err := p.jobBuilds(ctx, minecraftLine(mcVersion)) + if err != nil { + return nil, err + } + + var versions []VersionInfo + for _, build := range builds { + if build.minecraftVersion != mcVersion { + continue + } + versions = append(versions, VersionInfo{ + MinecraftVersion: mcVersion, + ModVersion: strconv.Itoa(build.number), + IsStable: true, + Type: "build", + }) + } + + return versions, nil +} + +// GetLatestVersion returns the Minecraft version of the newest Pufferfish build +func (p *PufferfishProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { + lines, err := p.minecraftLines(ctx) + if err != nil { + return nil, err + } + + for _, line := range lines { + builds, err := p.jobBuilds(ctx, line) + if err != nil { + return nil, err + } + if len(builds) > 0 { + return &VersionInfo{ + MinecraftVersion: builds[0].minecraftVersion, + ModVersion: strconv.Itoa(builds[0].number), + IsStable: true, + Type: "release", + }, nil + } + } + + return nil, fmt.Errorf("no Pufferfish builds available") +} + +// GetServerJar returns download info for a specific version +func (p *PufferfishProvider) GetServerJar(ctx context.Context, mcVersion, modVersion string) (*ServerJar, error) { + builds, err := p.jobBuilds(ctx, minecraftLine(mcVersion)) + if err != nil { + return nil, err + } + + build, err := pickPufferfishBuild(builds, mcVersion, modVersion) + if err != nil { + return nil, err + } + + return &ServerJar{ + Version: build.minecraftVersion, + ModVersion: strconv.Itoa(build.number), + URL: p.client.Job(build.job).GetArtifactURL(build.number, build.artifactPath), + Filename: fmt.Sprintf("pufferfish-%s-%d.jar", build.minecraftVersion, build.number), + }, nil +} + +// pickPufferfishBuild returns the newest build for the Minecraft version. A +// version given as a line ("1.21") matches the newest build of that line. +func pickPufferfishBuild(builds []pufferfishBuild, mcVersion, modVersion string) (*pufferfishBuild, error) { + number := 0 + if modVersion != "" { + n, err := strconv.Atoi(modVersion) + if err != nil { + return nil, fmt.Errorf("invalid build number: %s", modVersion) + } + number = n + } + + available := make(map[string]bool) + + for i := range builds { + build := &builds[i] + available[build.minecraftVersion] = true + + if build.minecraftVersion != mcVersion && minecraftLine(build.minecraftVersion) != mcVersion { + continue + } + if number == 0 || build.number == number { + return build, nil + } + } + + if number != 0 { + return nil, fmt.Errorf("build %d of Pufferfish not found for Minecraft %s", number, mcVersion) + } + + versions := make([]string, 0, len(available)) + for version := range available { + versions = append(versions, version) + } + sortMCVersionsDesc(versions) + + return nil, fmt.Errorf("no Pufferfish build found for Minecraft %s (available: %s)", + mcVersion, strings.Join(versions, ", ")) +} + +// minecraftLines returns the Minecraft lines Pufferfish has a job for, newest first +func (p *PufferfishProvider) minecraftLines(ctx context.Context) ([]string, error) { + jobs, err := p.client.GetJobs(ctx) + if err != nil { + return nil, err + } + + var lines []string + for _, job := range jobs { + line, ok := strings.CutPrefix(job.Name, pufferfishJobPrefix) + if !ok || strings.Contains(line, "-") { + continue + } + lines = append(lines, line) + } + + sortMCVersionsDesc(lines) + + return lines, nil +} + +// jobBuilds returns the successful builds of a Minecraft line that produced a server JAR, newest first +func (p *PufferfishProvider) jobBuilds(ctx context.Context, line string) ([]pufferfishBuild, error) { + job := pufferfishJobPrefix + line + + jenkinsBuilds, err := p.client.Job(job).GetBuildsWithArtifacts(ctx) + if err != nil { + return nil, fmt.Errorf("no Pufferfish builds for Minecraft %s: %w", line, err) + } + + var builds []pufferfishBuild + for _, b := range jenkinsBuilds { + if !b.IsSuccessful() { + continue + } + + for _, artifact := range b.Artifacts { + match := pufferfishArtifactVersion.FindStringSubmatch(artifact.FileName) + if match == nil { + continue + } + + builds = append(builds, pufferfishBuild{ + job: job, + number: b.Number, + minecraftVersion: match[1], + artifactPath: artifact.RelativePath, + }) + break + } + } + + return builds, nil +} + +// minecraftLine reduces a Minecraft version to its line: "1.21.8" -> "1.21" +func minecraftLine(version string) string { + parts := strings.Split(version, ".") + if len(parts) > 2 { + parts = parts[:2] + } + return strings.Join(parts, ".") +} + +// PostDownload handles post-download steps (none for Pufferfish) +func (p *PufferfishProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { + return nil +} + +// GetRecommendedJavaVersion returns the recommended Java version +func (p *PufferfishProvider) GetRecommendedJavaVersion(ctx context.Context, mcVersion string) int { + return GetRecommendedJavaVersion(ctx, mcVersion) +} diff --git a/internal/providers/purpur.go b/internal/providers/purpur.go new file mode 100644 index 0000000..a1d9f02 --- /dev/null +++ b/internal/providers/purpur.go @@ -0,0 +1,147 @@ +package providers + +import ( + "context" + "fmt" + + "github.com/gameap/minecraft-runner/pkg/api/purpur" +) + +// purpurBuildProbes limits how far back a successful build is searched for +const purpurBuildProbes = 5 + +// PurpurProvider provides Purpur server downloads +type PurpurProvider struct { + client *purpur.Client +} + +// NewPurpurProvider creates a new Purpur provider +func NewPurpurProvider() *PurpurProvider { + return &PurpurProvider{ + client: purpur.NewClient(), + } +} + +// Name returns the provider name +func (p *PurpurProvider) Name() string { + return "purpur" +} + +// ListVersions returns available Minecraft versions, newest first +func (p *PurpurProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { + project, err := p.client.GetProject(ctx) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(project.Versions)) + for i := len(project.Versions) - 1; i >= 0; i-- { + versions = append(versions, VersionInfo{ + MinecraftVersion: project.Versions[i], + IsStable: !isPreRelease(project.Versions[i]), + Type: "release", + }) + } + + return versions, nil +} + +// ListModVersions returns builds for a specific Minecraft version, newest first +func (p *PurpurProvider) ListModVersions(ctx context.Context, mcVersion string) ([]VersionInfo, error) { + version, err := p.client.GetVersion(ctx, mcVersion) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(version.Builds.All)) + for i := len(version.Builds.All) - 1; i >= 0; i-- { + versions = append(versions, VersionInfo{ + MinecraftVersion: mcVersion, + ModVersion: version.Builds.All[i], + IsStable: true, + Type: "build", + }) + } + + return versions, nil +} + +// GetLatestVersion returns the Minecraft version Purpur currently recommends +func (p *PurpurProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { + project, err := p.client.GetProject(ctx) + if err != nil { + return nil, err + } + + latest := project.Metadata.Current + if latest == "" && len(project.Versions) > 0 { + latest = project.Versions[len(project.Versions)-1] + } + if latest == "" { + return nil, fmt.Errorf("no versions available") + } + + return &VersionInfo{ + MinecraftVersion: latest, + IsStable: true, + Type: "release", + }, nil +} + +// GetServerJar returns download info for a specific version +func (p *PurpurProvider) GetServerJar(ctx context.Context, mcVersion, modVersion string) (*ServerJar, error) { + build, err := p.resolveBuild(ctx, mcVersion, modVersion) + if err != nil { + return nil, err + } + + return &ServerJar{ + Version: mcVersion, + ModVersion: build.Build, + URL: p.client.GetDownloadURL(mcVersion, build.Build), + MD5: build.MD5, + Filename: fmt.Sprintf("purpur-%s-%s.jar", mcVersion, build.Build), + }, nil +} + +// resolveBuild returns the requested build, or the newest one that produced a JAR +func (p *PurpurProvider) resolveBuild(ctx context.Context, mcVersion, modVersion string) (*purpur.Build, error) { + if modVersion != "" { + build, err := p.client.GetBuild(ctx, mcVersion, modVersion) + if err != nil { + return nil, err + } + if !build.IsSuccessful() { + return nil, fmt.Errorf("build %s of Purpur %s has no JAR (result: %s)", modVersion, mcVersion, build.Result) + } + return build, nil + } + + version, err := p.client.GetVersion(ctx, mcVersion) + if err != nil { + return nil, err + } + + all := version.Builds.All + for i := len(all) - 1; i >= 0 && i >= len(all)-purpurBuildProbes; i-- { + build, err := p.client.GetBuild(ctx, mcVersion, all[i]) + if err != nil { + return nil, err + } + if build.IsSuccessful() { + return build, nil + } + } + + return nil, fmt.Errorf("no successful Purpur build found for %s", mcVersion) +} + +// PostDownload handles post-download steps (none for Purpur) +func (p *PurpurProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { + return nil +} + +// GetRecommendedJavaVersion returns the recommended Java version +func (p *PurpurProvider) GetRecommendedJavaVersion(ctx context.Context, mcVersion string) int { + return GetRecommendedJavaVersion(ctx, mcVersion) +} diff --git a/internal/providers/quilt.go b/internal/providers/quilt.go new file mode 100644 index 0000000..f231e33 --- /dev/null +++ b/internal/providers/quilt.go @@ -0,0 +1,177 @@ +package providers + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/gameap/minecraft-runner/pkg/api/quilt" +) + +const ( + // quiltLauncher is the launcher JAR the Quilt installer writes. It carries + // no version, and the vanilla server.jar next to it belongs to whichever + // Minecraft version was installed last. + quiltLauncher = "quilt-server-launch.jar" + + quiltServerJar = "server.jar" +) + +// QuiltProvider provides Quilt server downloads +type QuiltProvider struct { + client *quilt.Client +} + +// NewQuiltProvider creates a new Quilt provider +func NewQuiltProvider() *QuiltProvider { + return &QuiltProvider{ + client: quilt.NewClient(), + } +} + +// Name returns the provider name +func (p *QuiltProvider) Name() string { + return "quilt" +} + +// ListVersions returns available Minecraft versions, newest first +func (p *QuiltProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { + gameVersions, err := p.client.GetGameVersions(ctx) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(gameVersions)) + for _, v := range gameVersions { + vType := "release" + if !v.Stable { + vType = "snapshot" + } + + versions = append(versions, VersionInfo{ + MinecraftVersion: v.Version, + IsStable: v.Stable, + Type: vType, + }) + } + + return versions, nil +} + +// ListModVersions returns loader versions, newest first +func (p *QuiltProvider) ListModVersions(ctx context.Context, mcVersion string) ([]VersionInfo, error) { + loaderVersions, err := p.client.GetLoaderVersions(ctx) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(loaderVersions)) + for _, v := range loaderVersions { + versions = append(versions, VersionInfo{ + MinecraftVersion: mcVersion, + ModVersion: v.Version, + IsStable: v.IsStable(), + Type: "loader", + }) + } + + return versions, nil +} + +// GetLatestVersion returns the newest stable Minecraft version Quilt supports +func (p *QuiltProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { + gameVersions, err := p.client.GetGameVersions(ctx) + if err != nil { + return nil, err + } + + for _, v := range gameVersions { + if v.Stable { + return &VersionInfo{MinecraftVersion: v.Version, IsStable: true, Type: "release"}, nil + } + } + + return nil, fmt.Errorf("no stable versions available") +} + +// GetServerJar returns download info for a specific version. The download is +// the Quilt installer; ModVersion is the loader it will install. +func (p *QuiltProvider) GetServerJar(ctx context.Context, mcVersion, modVersion string) (*ServerJar, error) { + loaderVersion := modVersion + if loaderVersion == "" { + loader, err := p.client.GetLatestStableLoader(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get loader version: %w", err) + } + loaderVersion = loader.Version + } + + installer, err := p.client.GetLatestInstaller(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get installer version: %w", err) + } + + checksum, err := p.client.GetInstallerSHA256(ctx, installer) + if err != nil { + return nil, err + } + + return &ServerJar{ + Version: mcVersion, + ModVersion: loaderVersion, + URL: installer.URL, + SHA256: checksum, + Filename: fmt.Sprintf("quilt-installer-%s.jar", installer.Version), + RequiresInstall: true, + }, nil +} + +// ResolveLaunch finds the launcher of an installed Quilt version +func (p *QuiltProvider) ResolveLaunch(dir string, jar *ServerJar) *LaunchTarget { + launcher := quiltVersionedLauncher(jar) + + for _, file := range []string{launcher, quiltServerJar} { + if _, err := os.Stat(filepath.Join(dir, file)); err != nil { + return nil + } + } + + return &LaunchTarget{Jar: launcher} +} + +// PostDownload runs the Quilt installer and gives the launcher a versioned +// name, which is what lets ResolveLaunch tell the installed version. Launchers +// of other versions are removed: they would start against the server.jar that +// was just replaced. +func (p *QuiltProvider) PostDownload(ctx context.Context, dir string, jar *ServerJar, javaPath string) error { + fmt.Println("Running Quilt installer...") + + err := runInstaller(ctx, javaPath, dir, jar.Filename, + "install", "server", jar.Version, jar.ModVersion, "--install-dir=.", "--download-server") + if err != nil { + return installerError("Quilt", err) + } + + stale, _ := filepath.Glob(filepath.Join(dir, "quilt-server-launch-*.jar")) + for _, launcher := range stale { + os.Remove(launcher) + } + + if err := os.Rename(filepath.Join(dir, quiltLauncher), filepath.Join(dir, quiltVersionedLauncher(jar))); err != nil { + return fmt.Errorf("failed to rename the Quilt launcher: %w", err) + } + + removeInstaller(dir, jar.Filename) + + return nil +} + +func quiltVersionedLauncher(jar *ServerJar) string { + return fmt.Sprintf("quilt-server-launch-%s-%s.jar", jar.Version, jar.ModVersion) +} + +// GetRecommendedJavaVersion returns the recommended Java version +func (p *QuiltProvider) GetRecommendedJavaVersion(ctx context.Context, mcVersion string) int { + return GetRecommendedJavaVersion(ctx, mcVersion) +} diff --git a/internal/providers/spigot.go b/internal/providers/spigot.go index f7df897..1dfc77b 100644 --- a/internal/providers/spigot.go +++ b/internal/providers/spigot.go @@ -7,7 +7,7 @@ import ( // SpigotProvider suggests Paper as a replacement for Spigot type SpigotProvider struct { - paper *PaperProvider + paper *FillProvider } // NewSpigotProvider creates a new Spigot provider @@ -56,7 +56,7 @@ func (p *SpigotProvider) GetServerJar(ctx context.Context, mcVersion, modVersion } // PostDownload handles post-download steps -func (p *SpigotProvider) PostDownload(ctx context.Context, jarPath string, javaPath string) error { +func (p *SpigotProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { return nil } diff --git a/internal/providers/sponge.go b/internal/providers/sponge.go new file mode 100644 index 0000000..2b74266 --- /dev/null +++ b/internal/providers/sponge.go @@ -0,0 +1,170 @@ +package providers + +import ( + "context" + "fmt" + "path" + + "github.com/gameap/minecraft-runner/pkg/api/sponge" +) + +// spongeVersionProbes limits how many Minecraft versions are inspected when +// looking for the newest one that already has a recommended build +const spongeVersionProbes = 6 + +// SpongeVanillaProvider provides SpongeVanilla server downloads +type SpongeVanillaProvider struct { + client *sponge.Client +} + +// NewSpongeVanillaProvider creates a new SpongeVanilla provider +func NewSpongeVanillaProvider() *SpongeVanillaProvider { + return &SpongeVanillaProvider{ + client: sponge.NewClient(), + } +} + +// Name returns the provider name +func (p *SpongeVanillaProvider) Name() string { + return sponge.ArtifactVanilla +} + +// ListVersions returns available Minecraft versions, newest first +func (p *SpongeVanillaProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { + mcVersions, err := p.client.GetMinecraftVersions(ctx, sponge.ArtifactVanilla) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(mcVersions)) + for _, version := range mcVersions { + vType := "release" + if isPreRelease(version) { + vType = "snapshot" + } + + versions = append(versions, VersionInfo{ + MinecraftVersion: version, + IsStable: vType == "release", + Type: vType, + }) + } + + return versions, nil +} + +// ListModVersions returns SpongeVanilla builds for a specific Minecraft version, newest first +func (p *SpongeVanillaProvider) ListModVersions(ctx context.Context, mcVersion string) ([]VersionInfo, error) { + builds, err := p.client.GetVersions(ctx, sponge.ArtifactVanilla, mcVersion, false) + if err != nil { + return nil, err + } + + versions := make([]VersionInfo, 0, len(builds)) + for _, build := range builds { + versions = append(versions, VersionInfo{ + MinecraftVersion: mcVersion, + ModVersion: build.Version, + IsStable: build.Recommended, + Type: "build", + }) + } + + return versions, nil +} + +// GetLatestVersion returns the newest Minecraft release with a recommended build +func (p *SpongeVanillaProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { + mcVersions, err := p.client.GetMinecraftVersions(ctx, sponge.ArtifactVanilla) + if err != nil { + return nil, err + } + + var releases []string + for _, version := range mcVersions { + if !isPreRelease(version) { + releases = append(releases, version) + } + } + + if len(releases) == 0 { + return nil, fmt.Errorf("no versions available") + } + + probes := releases + if len(probes) > spongeVersionProbes { + probes = probes[:spongeVersionProbes] + } + + for _, version := range probes { + builds, err := p.client.GetVersions(ctx, sponge.ArtifactVanilla, version, true) + if err != nil { + return nil, err + } + if len(builds) > 0 { + return &VersionInfo{MinecraftVersion: version, IsStable: true, Type: "release"}, nil + } + } + + return &VersionInfo{MinecraftVersion: releases[0], Type: "release"}, nil +} + +// GetServerJar returns download info for a specific version +func (p *SpongeVanillaProvider) GetServerJar(ctx context.Context, mcVersion, modVersion string) (*ServerJar, error) { + version := modVersion + if version == "" { + build, err := p.latestBuild(ctx, mcVersion) + if err != nil { + return nil, err + } + version = build + } + + asset, err := p.client.GetServerAsset(ctx, sponge.ArtifactVanilla, version) + if err != nil { + return nil, err + } + + return &ServerJar{ + Version: mcVersion, + ModVersion: version, + URL: asset.DownloadURL, + SHA1: asset.SHA1, + MD5: asset.MD5, + Filename: path.Base(asset.DownloadURL), + }, nil +} + +// latestBuild returns the newest recommended build, falling back to the newest build of any kind +func (p *SpongeVanillaProvider) latestBuild(ctx context.Context, mcVersion string) (string, error) { + recommended, err := p.client.GetVersions(ctx, sponge.ArtifactVanilla, mcVersion, true) + if err != nil { + return "", err + } + if len(recommended) > 0 { + return recommended[0].Version, nil + } + + builds, err := p.client.GetVersions(ctx, sponge.ArtifactVanilla, mcVersion, false) + if err != nil { + return "", err + } + if len(builds) == 0 { + return "", fmt.Errorf("no SpongeVanilla builds found for Minecraft %s", mcVersion) + } + + fmt.Printf("[WARNING] SpongeVanilla has no recommended build for Minecraft %s yet, using %s\n", + mcVersion, builds[0].Version) + + return builds[0].Version, nil +} + +// PostDownload handles post-download steps (none for SpongeVanilla) +func (p *SpongeVanillaProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { + return nil +} + +// GetRecommendedJavaVersion returns the recommended Java version +func (p *SpongeVanillaProvider) GetRecommendedJavaVersion(ctx context.Context, mcVersion string) int { + return GetRecommendedJavaVersion(ctx, mcVersion) +} diff --git a/internal/providers/vanilla.go b/internal/providers/vanilla.go index 0f0b190..b373f3e 100644 --- a/internal/providers/vanilla.go +++ b/internal/providers/vanilla.go @@ -101,7 +101,7 @@ func (p *VanillaProvider) GetServerJar(ctx context.Context, mcVersion, modVersio } // PostDownload handles post-download steps (none for vanilla) -func (p *VanillaProvider) PostDownload(ctx context.Context, jarPath string, javaPath string) error { +func (p *VanillaProvider) PostDownload(_ context.Context, _ string, _ *ServerJar, _ string) error { return nil } diff --git a/internal/providers/velocity.go b/internal/providers/velocity.go deleted file mode 100644 index 63b92eb..0000000 --- a/internal/providers/velocity.go +++ /dev/null @@ -1,131 +0,0 @@ -package providers - -import ( - "context" - "fmt" - "strconv" - - "github.com/gameap/minecraft-runner/pkg/api/paper" -) - -// VelocityProvider provides Velocity proxy server downloads -type VelocityProvider struct { - client *paper.Client -} - -// NewVelocityProvider creates a new Velocity provider -func NewVelocityProvider() *VelocityProvider { - return &VelocityProvider{ - client: paper.NewClient(), - } -} - -// Name returns the provider name -func (p *VelocityProvider) Name() string { - return "velocity" -} - -// ListVersions returns available Velocity versions (proxy versions, not MC versions) -func (p *VelocityProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { - project, err := p.client.GetProject(ctx, "velocity") - if err != nil { - return nil, err - } - - versions := make([]VersionInfo, 0, len(project.Versions)) - for i := len(project.Versions) - 1; i >= 0; i-- { - versions = append(versions, VersionInfo{ - MinecraftVersion: project.Versions[i], - IsStable: true, - Type: "proxy", - }) - } - - return versions, nil -} - -// ListModVersions returns builds for a specific Velocity version -func (p *VelocityProvider) ListModVersions(ctx context.Context, version string) ([]VersionInfo, error) { - vb, err := p.client.GetVersionBuilds(ctx, "velocity", version) - if err != nil { - return nil, err - } - - versions := make([]VersionInfo, 0, len(*vb)) - for _, build := range *vb { - versions = append(versions, VersionInfo{ - MinecraftVersion: version, - ModVersion: strconv.Itoa(build.ID), - IsStable: build.Channel == "STABLE", - Type: "build", - }) - } - - return versions, nil -} - -// GetLatestVersion returns the latest Velocity version -func (p *VelocityProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { - project, err := p.client.GetProject(ctx, "velocity") - if err != nil { - return nil, err - } - - if len(project.Versions) == 0 { - return nil, fmt.Errorf("no versions available") - } - - latest := project.Versions[len(project.Versions)-1] - - return &VersionInfo{ - MinecraftVersion: latest, - IsStable: true, - Type: "proxy", - }, nil -} - -// GetServerJar returns download info for Velocity -func (p *VelocityProvider) GetServerJar(ctx context.Context, version, modVersion string) (*ServerJar, error) { - var buildInfo *paper.BuildInfo - var err error - - if modVersion != "" { - build, parseErr := strconv.Atoi(modVersion) - if parseErr != nil { - return nil, fmt.Errorf("invalid build number: %s", modVersion) - } - buildInfo, err = p.client.GetBuildInfo(ctx, "velocity", version, build) - } else { - buildInfo, err = p.client.GetLatestBuild(ctx, "velocity", version) - } - - if err != nil { - return nil, err - } - - downloadURL := buildInfo.Downloads.Application.URL - - buildNumber := buildInfo.ID - if buildNumber == 0 { - buildNumber = buildInfo.Build - } - - return &ServerJar{ - Version: version, - ModVersion: strconv.Itoa(buildNumber), - URL: downloadURL, - SHA256: buildInfo.Downloads.Application.SHA256, - Filename: buildInfo.Downloads.Application.Name, - RequiresInstall: false, - }, nil -} - -// PostDownload handles post-download steps (none for Velocity) -func (p *VelocityProvider) PostDownload(ctx context.Context, jarPath string, javaPath string) error { - return nil -} - -// GetRecommendedJavaVersion returns Java 17 for modern Velocity -func (p *VelocityProvider) GetRecommendedJavaVersion(_ context.Context, _ string) int { - return 25 -} diff --git a/internal/providers/version.go b/internal/providers/version.go new file mode 100644 index 0000000..ad09dd1 --- /dev/null +++ b/internal/providers/version.go @@ -0,0 +1,78 @@ +package providers + +import ( + "sort" + "strconv" + "strings" +) + +// compareMCVersions compares Minecraft version strings component by component, +// so that "1.21.11" sorts above "1.21.4" and the year-based "26.2" above every +// "1.x". A pre-release ("1.20.4-pre1", "26.3-rc-3") sorts below its release. +func compareMCVersions(a, b string) int { + aNumbers, aSuffix := splitMCVersion(a) + bNumbers, bSuffix := splitMCVersion(b) + + for i := 0; i < len(aNumbers) || i < len(bNumbers); i++ { + var x, y int + if i < len(aNumbers) { + x = aNumbers[i] + } + if i < len(bNumbers) { + y = bNumbers[i] + } + if x != y { + return x - y + } + } + + switch { + case aSuffix == bSuffix: + return 0 + case aSuffix == "": + return 1 + case bSuffix == "": + return -1 + } + return strings.Compare(aSuffix, bSuffix) +} + +func splitMCVersion(version string) ([]int, string) { + base, suffix, _ := strings.Cut(version, "-") + + parts := strings.Split(base, ".") + numbers := make([]int, 0, len(parts)) + for _, part := range parts { + n, err := strconv.Atoi(part) + if err != nil { + n = 0 + } + numbers = append(numbers, n) + } + + return numbers, suffix +} + +// isPreRelease reports whether a Minecraft version is a snapshot, pre-release +// or release candidate rather than a release +func isPreRelease(version string) bool { + if strings.Contains(version, "-") { + return true + } + _, ok := parseSnapshotYear(version) + return ok +} + +// sortMCVersionsDesc sorts Minecraft versions newest first +func sortMCVersionsDesc(versions []string) { + sort.SliceStable(versions, func(i, j int) bool { + return compareMCVersions(versions[i], versions[j]) > 0 + }) +} + +// sortVersionInfosDesc sorts versions by Minecraft version, newest first +func sortVersionInfosDesc(versions []VersionInfo) { + sort.SliceStable(versions, func(i, j int) bool { + return compareMCVersions(versions[i].MinecraftVersion, versions[j].MinecraftVersion) > 0 + }) +} diff --git a/internal/providers/version_test.go b/internal/providers/version_test.go new file mode 100644 index 0000000..0dd49f2 --- /dev/null +++ b/internal/providers/version_test.go @@ -0,0 +1,71 @@ +package providers + +import ( + "reflect" + "testing" +) + +func TestCompareMCVersions(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"1.21.11", "1.21.4", 1}, + {"1.21.4", "1.21.11", -1}, + {"1.9", "1.10", -1}, + {"26.2", "1.21.11", 1}, + {"26.1.2", "26.1", 1}, + {"26.2", "26.1.2", 1}, + {"1.21", "1.21.0", 0}, + {"1.20.4", "1.20.4", 0}, + {"1.20.4-pre1", "1.20.4", -1}, + {"26.3", "26.3-rc-3", 1}, + {"26.3-rc-3", "26.3-rc-2", 1}, + {"1.20.4-pre1", "1.20.3", 1}, + } + + for _, tt := range tests { + got := compareMCVersions(tt.a, tt.b) + if sign(got) != tt.want { + t.Errorf("compareMCVersions(%q, %q) = %d, want sign %d", tt.a, tt.b, got, tt.want) + } + } +} + +func TestSortMCVersionsDesc(t *testing.T) { + versions := []string{"1.21.11", "1.21.4", "1.21.5", "26.1.2", "1.21.8", "26.2", "1.9", "1.10"} + want := []string{"26.2", "26.1.2", "1.21.11", "1.21.8", "1.21.5", "1.21.4", "1.10", "1.9"} + + sortMCVersionsDesc(versions) + + if !reflect.DeepEqual(versions, want) { + t.Errorf("sortMCVersionsDesc() = %v, want %v", versions, want) + } +} + +func TestIsPreRelease(t *testing.T) { + tests := map[string]bool{ + "1.21.4": false, + "26.3": false, + "26.3-rc-3": true, + "1.20.4-pre1": true, + "26.3-snapshot-8": true, + "24w14a": true, + } + + for version, want := range tests { + if got := isPreRelease(version); got != want { + t.Errorf("isPreRelease(%q) = %v, want %v", version, got, want) + } + } +} + +func sign(n int) int { + switch { + case n > 0: + return 1 + case n < 0: + return -1 + } + return 0 +} diff --git a/internal/providers/waterfall.go b/internal/providers/waterfall.go deleted file mode 100644 index 4ebde91..0000000 --- a/internal/providers/waterfall.go +++ /dev/null @@ -1,126 +0,0 @@ -package providers - -import ( - "context" - "fmt" - "strconv" - - "github.com/gameap/minecraft-runner/pkg/api/paper" -) - -// WaterfallProvider provides Waterfall proxy server downloads -type WaterfallProvider struct { - client *paper.Client -} - -// NewWaterfallProvider creates a new Waterfall provider -func NewWaterfallProvider() *WaterfallProvider { - return &WaterfallProvider{ - client: paper.NewClient(), - } -} - -// Name returns the provider name -func (p *WaterfallProvider) Name() string { - return "waterfall" -} - -// ListVersions returns available Waterfall versions (proxy versions, not MC versions) -func (p *WaterfallProvider) ListVersions(ctx context.Context) ([]VersionInfo, error) { - project, err := p.client.GetProject(ctx, "waterfall") - if err != nil { - return nil, err - } - - versions := make([]VersionInfo, 0, len(project.Versions)) - for i := len(project.Versions) - 1; i >= 0; i-- { - versions = append(versions, VersionInfo{ - MinecraftVersion: project.Versions[i], - IsStable: true, - Type: "proxy", - }) - } - - return versions, nil -} - -// ListModVersions returns builds for a specific Waterfall version -func (p *WaterfallProvider) ListModVersions(ctx context.Context, version string) ([]VersionInfo, error) { - vb, err := p.client.GetVersionBuilds(ctx, "waterfall", version) - if err != nil { - return nil, err - } - - versions := make([]VersionInfo, 0, len(*vb)) - for _, build := range *vb { - versions = append(versions, VersionInfo{ - MinecraftVersion: version, - ModVersion: strconv.Itoa(build.ID), - IsStable: build.Channel == "STABLE", - Type: "build", - }) - } - - return versions, nil -} - -// GetLatestVersion returns the latest Waterfall version -func (p *WaterfallProvider) GetLatestVersion(ctx context.Context) (*VersionInfo, error) { - project, err := p.client.GetProject(ctx, "waterfall") - if err != nil { - return nil, err - } - - if len(project.Versions) == 0 { - return nil, fmt.Errorf("no versions available") - } - - latest := project.Versions[len(project.Versions)-1] - - return &VersionInfo{ - MinecraftVersion: latest, - IsStable: true, - Type: "proxy", - }, nil -} - -// GetServerJar returns download info for Waterfall -func (p *WaterfallProvider) GetServerJar(ctx context.Context, version, modVersion string) (*ServerJar, error) { - var buildInfo *paper.BuildInfo - var err error - - if modVersion != "" { - build, parseErr := strconv.Atoi(modVersion) - if parseErr != nil { - return nil, fmt.Errorf("invalid build number: %s", modVersion) - } - buildInfo, err = p.client.GetBuildInfo(ctx, "waterfall", version, build) - } else { - buildInfo, err = p.client.GetLatestBuild(ctx, "waterfall", version) - } - - if err != nil { - return nil, err - } - - downloadURL := p.client.GetDownloadURL("waterfall", version, buildInfo.Build, buildInfo.Downloads.Application.Name) - - return &ServerJar{ - Version: version, - ModVersion: strconv.Itoa(buildInfo.Build), - URL: downloadURL, - SHA256: buildInfo.Downloads.Application.SHA256, - Filename: buildInfo.Downloads.Application.Name, - RequiresInstall: false, - }, nil -} - -// PostDownload handles post-download steps (none for Waterfall) -func (p *WaterfallProvider) PostDownload(ctx context.Context, jarPath string, javaPath string) error { - return nil -} - -// GetRecommendedJavaVersion returns Java 17 for modern Waterfall -func (p *WaterfallProvider) GetRecommendedJavaVersion(_ context.Context, _ string) int { - return 17 -} diff --git a/internal/server/downloader.go b/internal/server/downloader.go index 3e2a45e..cf0e743 100644 --- a/internal/server/downloader.go +++ b/internal/server/downloader.go @@ -33,15 +33,28 @@ func NewDownloader(registry *providers.Registry) *Downloader { } } -// Download downloads a server JAR +// Download resolves and downloads a server JAR func (d *Downloader) Download(ctx context.Context, opts DownloadOptions) (*providers.ServerJar, error) { - // Get provider + jar, err := d.Resolve(ctx, opts) + if err != nil { + return nil, err + } + + if err := d.Fetch(ctx, opts.Directory, jar, opts.Force); err != nil { + return nil, err + } + + return jar, nil +} + +// Resolve asks the provider which file serves the requested version. It is the +// only step that needs the provider's API. +func (d *Downloader) Resolve(ctx context.Context, opts DownloadOptions) (*providers.ServerJar, error) { provider, err := d.registry.Get(opts.Mod) if err != nil { return nil, fmt.Errorf("unknown server type '%s': %w", opts.Mod, err) } - // Resolve version if not specified version := opts.Version if version == "" { latest, err := provider.GetLatestVersion(ctx) @@ -52,137 +65,74 @@ func (d *Downloader) Download(ctx context.Context, opts DownloadOptions) (*provi fmt.Printf("Using latest version: %s\n", version) } - // Get JAR info jar, err := provider.GetServerJar(ctx, version, opts.ModVersion) if err != nil { return nil, fmt.Errorf("failed to get server info: %w", err) } - // Determine target path - targetPath := filepath.Join(opts.Directory, jar.Filename) + return jar, nil +} - // Check if JAR already exists - if !opts.Force && d.jarExists(targetPath, jar) { +// Fetch downloads a resolved JAR into the directory unless a verified copy is already there +func (d *Downloader) Fetch(ctx context.Context, directory string, jar *providers.ServerJar, force bool) error { + targetPath := filepath.Join(directory, jar.Filename) + + if !force && d.IsDownloaded(directory, jar) { fmt.Printf("Server JAR already exists: %s\n", jar.Filename) - return jar, nil + return nil } - // Ensure directory exists - if err := os.MkdirAll(opts.Directory, 0755); err != nil { - return nil, fmt.Errorf("failed to create directory: %w", err) + if err := os.MkdirAll(directory, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) } - // Download JAR fmt.Printf("Downloading %s...\n", jar.Filename) if err := d.http.DownloadFile(ctx, jar.URL, targetPath); err != nil { - return nil, fmt.Errorf("failed to download: %w", err) + return fmt.Errorf("failed to download: %w", err) } - // Verify checksum - if jar.SHA256 != "" { - fmt.Println("Verifying SHA256 checksum...") - if err := utils.VerifySHA256(targetPath, jar.SHA256); err != nil { - os.Remove(targetPath) - return nil, fmt.Errorf("checksum verification failed: %w", err) - } - } else if jar.SHA1 != "" { - fmt.Println("Verifying SHA1 checksum...") - if err := utils.VerifySHA1(targetPath, jar.SHA1); err != nil { - os.Remove(targetPath) - return nil, fmt.Errorf("checksum verification failed: %w", err) - } + if err := verifyChecksum(targetPath, jar, true); err != nil { + os.Remove(targetPath) + return fmt.Errorf("checksum verification failed: %w", err) } - // Make executable on Unix os.Chmod(targetPath, 0755) fmt.Printf("Downloaded successfully: %s\n", jar.Filename) - return jar, nil + return nil } -// jarExists checks if a JAR file exists and optionally verifies its checksum -func (d *Downloader) jarExists(path string, jar *providers.ServerJar) bool { - if _, err := os.Stat(path); os.IsNotExist(err) { - return false - } +// IsDownloaded checks that the JAR is present and, when the provider published +// a checksum, that it still matches +func (d *Downloader) IsDownloaded(directory string, jar *providers.ServerJar) bool { + path := filepath.Join(directory, jar.Filename) - // If we have a checksum, verify it - if jar.SHA256 != "" { - if err := utils.VerifySHA256(path, jar.SHA256); err != nil { - return false - } - } else if jar.SHA1 != "" { - if err := utils.VerifySHA1(path, jar.SHA1); err != nil { - return false - } + if _, err := os.Stat(path); err != nil { + return false } - return true + return verifyChecksum(path, jar, false) == nil } -// FindServerJar finds the server JAR file in a directory -func FindServerJar(directory string, mod string, version string) (string, error) { - // Common JAR patterns - patterns := []string{ - fmt.Sprintf("minecraft_server.%s.jar", version), - fmt.Sprintf("server.jar"), - fmt.Sprintf("paper-*.jar"), - fmt.Sprintf("forge-*.jar"), - fmt.Sprintf("fabric-server-*.jar"), +// verifyChecksum checks the strongest checksum the provider published +func verifyChecksum(path string, jar *providers.ServerJar, announce bool) error { + var name string + var verify func() error + + switch { + case jar.SHA256 != "": + name, verify = "SHA256", func() error { return utils.VerifySHA256(path, jar.SHA256) } + case jar.SHA1 != "": + name, verify = "SHA1", func() error { return utils.VerifySHA1(path, jar.SHA1) } + case jar.MD5 != "": + name, verify = "MD5", func() error { return utils.VerifyMD5(path, jar.MD5) } + default: + return nil } - // Add mod-specific patterns - switch mod { - case "paper": - patterns = append([]string{fmt.Sprintf("paper-%s-*.jar", version)}, patterns...) - case "forge": - patterns = append([]string{ - fmt.Sprintf("forge-%s-*.jar", version), - "forge-*.jar", - }, patterns...) - case "fabric": - patterns = append([]string{fmt.Sprintf("fabric-server-mc.%s-*.jar", version)}, patterns...) - case "vanilla": - patterns = append([]string{fmt.Sprintf("minecraft_server.%s.jar", version)}, patterns...) - case "waterfall": - patterns = append([]string{ - fmt.Sprintf("waterfall-%s-*.jar", version), - "waterfall-*.jar", - }, patterns...) - case "velocity": - patterns = append([]string{ - fmt.Sprintf("velocity-%s-*.jar", version), - "velocity-*.jar", - }, patterns...) - case "bungeecord": - patterns = append([]string{ - "BungeeCord-*.jar", - "BungeeCord.jar", - }, patterns...) - } - - // Search for JAR files - for _, pattern := range patterns { - matches, err := filepath.Glob(filepath.Join(directory, pattern)) - if err != nil { - continue - } - if len(matches) > 0 { - return matches[0], nil - } - } - - // Fallback: find any JAR file - entries, err := os.ReadDir(directory) - if err != nil { - return "", fmt.Errorf("failed to read directory: %w", err) - } - - for _, entry := range entries { - if !entry.IsDir() && filepath.Ext(entry.Name()) == ".jar" { - return filepath.Join(directory, entry.Name()), nil - } + if announce { + fmt.Printf("Verifying %s checksum...\n", name) } - return "", fmt.Errorf("no server JAR found in %s", directory) + return verify() } diff --git a/internal/server/process_unix.go b/internal/server/process_unix.go new file mode 100644 index 0000000..1acc5d5 --- /dev/null +++ b/internal/server/process_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package server + +import ( + "os" + "syscall" +) + +// terminateProcess asks the server to stop. The JVM runs the shutdown hook of +// the Minecraft server on SIGTERM, which saves the world before exiting. +func terminateProcess(process *os.Process) error { + return process.Signal(syscall.SIGTERM) +} diff --git a/internal/server/process_unix_test.go b/internal/server/process_unix_test.go new file mode 100644 index 0000000..43e6307 --- /dev/null +++ b/internal/server/process_unix_test.go @@ -0,0 +1,100 @@ +//go:build !windows + +package server + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "testing" + "time" +) + +const ( + helperModeEnv = "MCRUN_TEST_HELPER" + helperMarkerEnv = "MCRUN_TEST_HELPER_DIR" +) + +// TestMain lets the test binary stand in for the Java server: started with +// helperModeEnv set, it behaves like a server process instead of running tests +func TestMain(m *testing.M) { + switch os.Getenv(helperModeEnv) { + case "": + os.Exit(m.Run()) + case "graceful": + runGracefulHelper() + case "exit3": + os.Exit(3) + } +} + +// runGracefulHelper mimics a Minecraft server: it saves its "world" when asked to stop +func runGracefulHelper() { + dir := os.Getenv(helperMarkerEnv) + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM) + + os.WriteFile(filepath.Join(dir, "ready"), nil, 0644) + + <-signals + + os.WriteFile(filepath.Join(dir, "world-saved"), nil, 0644) + os.Exit(143) +} + +func TestStartStopsTheServerGracefully(t *testing.T) { + dir := t.TempDir() + t.Setenv(helperModeEnv, "graceful") + t.Setenv(helperMarkerEnv, dir) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(filepath.Join(dir, "ready")); err == nil { + break + } + time.Sleep(20 * time.Millisecond) + } + cancel() + }() + + runner := &Runner{} + + done := make(chan error, 1) + go func() { done <- runner.start(ctx, os.Args[0], dir, nil) }() + + select { + case err := <-done: + if err != nil { + t.Errorf("start() = %v, a requested stop is not an error", err) + } + case <-time.After(30 * time.Second): + t.Fatal("the server was not stopped") + } + + if _, err := os.Stat(filepath.Join(dir, "world-saved")); err != nil { + t.Error("the server was killed before it could save: SIGTERM never reached it") + } +} + +func TestStartReportsTheExitCode(t *testing.T) { + t.Setenv(helperModeEnv, "exit3") + + err := (&Runner{}).start(context.Background(), os.Args[0], t.TempDir(), nil) + + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 3 { + t.Errorf("start() = %v, want exit code 3", err) + } + if want := fmt.Sprintf("server exited with code %d", 3); err == nil || err.Error() != want { + t.Errorf("error = %v, want %q", err, want) + } +} diff --git a/internal/server/process_windows.go b/internal/server/process_windows.go new file mode 100644 index 0000000..78df6da --- /dev/null +++ b/internal/server/process_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package server + +import "os" + +// terminateProcess has nothing to deliver on Windows: a console control event +// reaches every process attached to the console, so the JVM is already +// shutting down by the time mcrun learns about the stop request. The runner +// only has to wait for it. +func terminateProcess(_ *os.Process) error { + return nil +} diff --git a/internal/server/runner.go b/internal/server/runner.go index 1b8b214..decbef4 100644 --- a/internal/server/runner.go +++ b/internal/server/runner.go @@ -2,15 +2,31 @@ package server import ( "context" + "errors" "fmt" "os" "os/exec" "path/filepath" + "reflect" + "strconv" "strings" + "time" "github.com/gameap/minecraft-runner/internal/config" "github.com/gameap/minecraft-runner/internal/java" "github.com/gameap/minecraft-runner/internal/providers" + "github.com/gameap/minecraft-runner/internal/utils" +) + +const ( + // stopTimeout is how long a server gets to save its world and exit after a + // stop request before it is killed; it matches systemd's default TimeoutStopSec + stopTimeout = 90 * time.Second + + // userJVMArgsFile is where Forge and NeoForge server packs keep their JVM arguments + userJVMArgsFile = "user_jvm_args.txt" + + defaultMemory = "1G" ) // RunOptions contains options for running a server @@ -32,6 +48,15 @@ type RunOptions struct { JavaVersion int } +// ExitError reports that the server process ended with a non-zero exit code +type ExitError struct { + Code int +} + +func (e *ExitError) Error() string { + return fmt.Sprintf("server exited with code %d", e.Code) +} + // Runner handles server execution type Runner struct { downloader *Downloader @@ -40,6 +65,13 @@ type Runner struct { config *config.Config } +// installation is a server that is ready to be started +type installation struct { + version string + launch providers.LaunchTarget + serverArgs []string +} + // NewRunner creates a new server runner func NewRunner(registry *providers.Registry, javaManager *java.Manager, cfg *config.Config) *Runner { return &Runner{ @@ -50,113 +82,298 @@ func NewRunner(registry *providers.Registry, javaManager *java.Manager, cfg *con } } -// Run downloads (if needed) and runs a Minecraft server +// Run downloads and installs a Minecraft server if needed, then runs it func (r *Runner) Run(ctx context.Context, opts RunOptions) error { - // Get provider provider, err := r.registry.Get(opts.Mod) if err != nil { return fmt.Errorf("unknown server type '%s': %w", opts.Mod, err) } - // Download server if needed - jar, err := r.downloader.Download(ctx, DownloadOptions{ - Version: opts.Version, - Mod: opts.Mod, - ModVersion: opts.ModVersion, - Directory: opts.Directory, - }) - if err != nil { - return fmt.Errorf("failed to download server: %w", err) - } - - // Update version from downloaded JAR - if opts.Version == "" { - opts.Version = jar.Version + if err := os.MkdirAll(opts.Directory, 0755); err != nil { + return fmt.Errorf("failed to create server directory: %w", err) } - // Find Java - javaInst, err := r.findJava(ctx, opts, provider) + inst, javaInst, err := r.prepare(ctx, provider, opts) if err != nil { - return fmt.Errorf("failed to find Java: %w", err) - } - - // Run post-download steps (e.g., Forge installer) - if jar.RequiresInstall { - jarPath := filepath.Join(opts.Directory, jar.Filename) - if err := provider.PostDownload(ctx, jarPath, javaInst.Path); err != nil { - return fmt.Errorf("post-download failed: %w", err) + if ctx.Err() != nil { + return nil } + return err } - // Check if this is a proxy server (proxies don't use EULA or server.properties) - isProxy := opts.Mod == "waterfall" || opts.Mod == "velocity" || opts.Mod == "bungeecord" - - if !isProxy { - // Create EULA if requested + if !providers.IsProxy(provider) { if opts.AcceptEULA || (r.config != nil && r.config.Defaults.AcceptEULA) { if err := config.CreateEULA(opts.Directory); err != nil { return fmt.Errorf("failed to create EULA: %w", err) } } - // Create default server.properties if it doesn't exist if err := config.CreateDefaultServerProperties(opts.Directory); err != nil { return fmt.Errorf("failed to create server.properties: %w", err) } - // Update server.properties if err := r.updateServerProperties(opts); err != nil { return fmt.Errorf("failed to update server.properties: %w", err) } } - // Use the exact JAR returned by the downloader. - serverJar := filepath.Join(opts.Directory, jar.Filename) - - // Build and run command - args := r.buildJVMArgs(opts, serverJar) + args := r.buildArgs(provider, opts, inst, stdinIsTerminal()) - fmt.Printf("\nStarting Minecraft server %s (%s)...\n", opts.Version, opts.Mod) + fmt.Printf("\nStarting Minecraft server %s (%s)...\n", inst.version, opts.Mod) fmt.Printf("Java: %s (version %d)\n", javaInst.Path, javaInst.Version) - fmt.Printf("JAR: %s\n\n", filepath.Base(serverJar)) + fmt.Printf("Launch: %s\n\n", describeLaunch(inst.launch)) - cmd := exec.CommandContext(ctx, javaInst.Path, args...) - cmd.Dir = opts.Directory - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + return r.start(ctx, javaInst.Path, opts.Directory, args) +} + +// prepare makes sure the requested server is installed and picks its Java +func (r *Runner) prepare( + ctx context.Context, provider providers.Provider, opts RunOptions, +) (*installation, *java.Installation, error) { + jar, resolveErr := r.downloader.Resolve(ctx, DownloadOptions{ + Version: opts.Version, + Mod: opts.Mod, + ModVersion: opts.ModVersion, + Directory: opts.Directory, + }) + if resolveErr != nil { + if ctx.Err() != nil { + return nil, nil, ctx.Err() + } + + inst, err := r.installedFallback(opts, resolveErr) + if err != nil { + return nil, nil, err + } + + javaInst, err := r.findJava(ctx, opts, provider, inst.version) + if err != nil { + return nil, nil, fmt.Errorf("failed to find Java: %w", err) + } + + return inst, javaInst, nil + } + + javaInst, err := r.findJava(ctx, opts, provider, jar.Version) + if err != nil { + return nil, nil, fmt.Errorf("failed to find Java: %w", err) + } + + launch, err := r.install(ctx, provider, jar, javaInst.Path, opts.Directory) + if err != nil { + return nil, nil, err + } + + inst := &installation{ + version: jar.Version, + launch: *launch, + serverArgs: jar.ServerArgs, + } + + r.recordInstall(opts, jar, inst) + + return inst, javaInst, nil +} + +// install downloads and installs the server unless that exact version is already in place +func (r *Runner) install( + ctx context.Context, provider providers.Provider, jar *providers.ServerJar, javaPath, directory string, +) (*providers.LaunchTarget, error) { + if target := r.resolveLaunch(provider, directory, jar); target != nil { + return target, nil + } + + if err := r.downloader.Fetch(ctx, directory, jar, false); err != nil { + return nil, fmt.Errorf("failed to download server: %w", err) + } + + if jar.RequiresInstall { + if err := provider.PostDownload(ctx, directory, jar, javaPath); err != nil { + return nil, fmt.Errorf("post-download failed: %w", err) + } + } + + target := r.resolveLaunch(provider, directory, jar) + if target == nil { + return nil, fmt.Errorf("%s %s was installed, but nothing to launch was found in %s", + provider.Name(), jar.Version, directory) + } + + return target, nil +} + +func (r *Runner) resolveLaunch( + provider providers.Provider, directory string, jar *providers.ServerJar, +) *providers.LaunchTarget { + if resolver, ok := provider.(providers.LaunchResolver); ok { + return resolver.ResolveLaunch(directory, jar) + } + + if jar.RequiresInstall || !r.downloader.IsDownloaded(directory, jar) { + return nil + } + + return &providers.LaunchTarget{Jar: jar.Filename} +} + +// installedFallback starts the server a previous run installed when the +// provider's API cannot tell which file serves the request. Servers that are +// already on disk must not go down together with a download API. +func (r *Runner) installedFallback(opts RunOptions, cause error) (*installation, error) { + failure := fmt.Errorf("failed to resolve server: %w", cause) + + state, err := LoadState(opts.Directory) + if err != nil || state == nil { + return nil, failure + } + + if !state.Matches(opts.Mod, opts.Version, opts.ModVersion) || !launchFilesExist(opts.Directory, state.Launch) { + return nil, failure + } + + fmt.Printf("[WARNING] Could not resolve the %s server to run: %v\n", opts.Mod, cause) + fmt.Printf(" Starting the installed %s %s instead\n\n", state.Mod, state.Version) - return cmd.Run() + return &installation{ + version: state.Version, + launch: state.Launch, + serverArgs: state.ServerArgs, + }, nil +} + +// recordInstall remembers what was installed and drops the JAR a newer build replaced +func (r *Runner) recordInstall(opts RunOptions, jar *providers.ServerJar, inst *installation) { + previous, _ := LoadState(opts.Directory) + + state := &State{ + Mod: opts.Mod, + Version: jar.Version, + ModVersion: jar.ModVersion, + File: jar.Filename, + Launch: inst.launch, + ServerArgs: inst.serverArgs, + InstalledAt: time.Now().UTC(), + } + + if previous != nil && previous.Matches(state.Mod, state.Version, state.ModVersion) && + reflect.DeepEqual(previous.Launch, state.Launch) { + return + } + + if err := state.Save(opts.Directory); err != nil { + fmt.Printf("[WARNING] %v\n", err) + return + } + + removeSuperseded(opts.Directory, previous, state) +} + +// removeSuperseded deletes the plain server JAR of the previous build of the +// same server. Installer-based servers share their libraries between versions +// and are left alone. +func removeSuperseded(directory string, previous, current *State) { + if previous == nil || previous.Mod != current.Mod { + return + } + if previous.File == "" || previous.Launch.Jar != previous.File || current.Launch.Jar == "" { + return + } + if previous.File == current.File || previous.File == current.Launch.Jar { + return + } + + if err := os.Remove(filepath.Join(directory, previous.File)); err == nil { + fmt.Printf("Removed superseded %s\n", previous.File) + } } // findJava finds an appropriate Java installation -func (r *Runner) findJava(ctx context.Context, opts RunOptions, provider providers.Provider) (*java.Installation, error) { - // Use custom path if specified +func (r *Runner) findJava( + ctx context.Context, opts RunOptions, provider providers.Provider, mcVersion string, +) (*java.Installation, error) { if opts.JavaPath != "" { - return r.javaManager.GetByPath(opts.JavaPath) + // The server runs from its own directory, which a relative path would then be resolved against + javaPath, err := filepath.Abs(opts.JavaPath) + if err != nil { + return nil, fmt.Errorf("invalid Java path %q: %w", opts.JavaPath, err) + } + return r.javaManager.GetByPath(javaPath) } - // Use specific version if specified if opts.JavaVersion != 0 { return r.javaManager.GetByVersion(ctx, opts.JavaVersion, true) } - // Use provider's recommended version (handles both MC servers and proxies) - recommended := provider.GetRecommendedJavaVersion(ctx, opts.Version) + recommended := provider.GetRecommendedJavaVersion(ctx, mcVersion) return r.javaManager.GetByVersion(ctx, recommended, true) } -// buildJVMArgs constructs JVM arguments for running the server -func (r *Runner) buildJVMArgs(opts RunOptions, jarPath string) []string { +// buildArgs constructs the java command line. Every path in it is relative to +// the server directory, which is the working directory of the process. +func (r *Runner) buildArgs( + provider providers.Provider, opts RunOptions, inst *installation, interactive bool, +) []string { var args []string - // Memory settings + usesArgFiles := len(inst.launch.ArgFiles) > 0 + + // Listed first so that the memory settings below win over the ones a server pack ships + if usesArgFiles { + if _, err := os.Stat(filepath.Join(opts.Directory, userJVMArgsFile)); err == nil { + args = append(args, "@"+userJVMArgsFile) + } + } + + memory, minMemory := r.memorySettings(opts) + args = append(args, "-Xmx"+memory, "-Xms"+minMemory) + + // Without a terminal the JLine console of Paper, Forge and the like has + // nothing to drive; this makes them read plain lines from stdin, which is + // what a control panel writes. A user argument below can still override it. + if !interactive { + args = append(args, "-Dterminal.jline=false") + } + + if r.config != nil { + args = append(args, r.config.Server.JVMArgs...) + } + args = append(args, opts.JVMArgs...) + + if usesArgFiles { + for _, argFile := range inst.launch.ArgFiles { + args = append(args, "@"+argFile) + } + } else { + args = append(args, "-jar", inst.launch.Jar) + } + + return append(args, serverArgs(provider, opts, inst)...) +} + +// serverArgs returns the program arguments. Proxies have no GUI switch, and +// Velocity rejects options it does not know. +func serverArgs(provider providers.Provider, opts RunOptions, inst *installation) []string { + if providers.IsProxy(provider) { + if listen, ok := provider.(providers.ListenArgsProvider); ok { + return listen.ListenArgs(opts.Port) + } + return nil + } + + if len(inst.serverArgs) > 0 { + return inst.serverArgs + } + + return []string{"--nogui"} +} + +func (r *Runner) memorySettings(opts RunOptions) (string, string) { memory := opts.Memory if memory == "" && r.config != nil { memory = r.config.Defaults.Memory } if memory == "" { - memory = "1G" + memory = defaultMemory } minMemory := opts.MinMemory @@ -164,54 +381,100 @@ func (r *Runner) buildJVMArgs(opts RunOptions, jarPath string) []string { minMemory = r.config.Defaults.MinMemory } if minMemory == "" { - minMemory = "1G" - } - - args = append(args, fmt.Sprintf("-Xmx%s", memory)) - args = append(args, fmt.Sprintf("-Xms%s", minMemory)) - - // Default optimization flags - defaultArgs := []string{ - "-XX:+UseG1GC", - "-XX:+ParallelRefProcEnabled", - "-XX:MaxGCPauseMillis=200", - "-XX:+UnlockExperimentalVMOptions", - "-XX:+DisableExplicitGC", - "-XX:+AlwaysPreTouch", - "-XX:G1NewSizePercent=30", - "-XX:G1MaxNewSizePercent=40", - "-XX:G1HeapRegionSize=8M", - "-XX:G1ReservePercent=20", - "-XX:G1HeapWastePercent=5", - "-XX:G1MixedGCCountTarget=4", - "-XX:InitiatingHeapOccupancyPercent=15", - "-XX:G1MixedGCLiveThresholdPercent=90", - "-XX:G1RSetUpdatingPauseTimePercent=5", - "-XX:SurvivorRatio=32", - "-XX:+PerfDisableSharedMem", - "-XX:MaxTenuringThreshold=1", - "-Dusing.aikars.flags=https://mcflags.emc.gs", - "-Daikars.new.flags=true", - } - - // Add config JVM args or defaults - if r.config != nil && len(r.config.Server.JVMArgs) > 0 { - args = append(args, r.config.Server.JVMArgs...) - } else { - args = append(args, defaultArgs...) + minMemory = defaultMemory } - // Add custom JVM args - args = append(args, opts.JVMArgs...) + // The JVM refuses to start with an initial heap above the maximum + maxBytes, maxOK := parseMemory(memory) + minBytes, minOK := parseMemory(minMemory) + if maxOK && minOK && minBytes > maxBytes { + minMemory = memory + } + + return memory, minMemory +} + +// parseMemory parses a JVM heap size such as "512M" or "4g" into bytes +func parseMemory(value string) (int64, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, false + } + + multiplier := int64(1) + switch value[len(value)-1] { + case 'k', 'K': + multiplier = 1 << 10 + case 'm', 'M': + multiplier = 1 << 20 + case 'g', 'G': + multiplier = 1 << 30 + case 't', 'T': + multiplier = 1 << 40 + } + if multiplier > 1 { + value = value[:len(value)-1] + } + + n, err := strconv.ParseInt(value, 10, 64) + if err != nil || n <= 0 { + return 0, false + } + + return n * multiplier, true +} + +// start runs the server until it exits or a stop is requested through ctx +func (r *Runner) start(ctx context.Context, javaPath, directory string, args []string) error { + cmd := exec.CommandContext(ctx, javaPath, args...) + cmd.Dir = directory + cmd.Env = utils.JavaEnvironment(javaPath) - // JAR and server-specific arguments - args = append(args, "-jar", jarPath) + // The descriptors are handed over as they are instead of being copied + // through pipes: the server reads the console input of mcrun directly, and + // a terminal stays a terminal for its line editor. + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + cmd.Cancel = func() error { + fmt.Println("\nStop requested, waiting for the server to shut down...") + return terminateProcess(cmd.Process) + } + cmd.WaitDelay = stopTimeout - if opts.Mod != "velocity" { - args = append(args, "--nogui") + err := cmd.Run() + + if ctx.Err() != nil { + if state := cmd.ProcessState; state != nil && !state.Exited() { + fmt.Printf("[WARNING] The server did not stop within %s and was killed\n", stopTimeout) + } + return nil } - return args + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return &ExitError{Code: exitErr.ExitCode()} + } + + return err +} + +func describeLaunch(target providers.LaunchTarget) string { + if len(target.ArgFiles) > 0 { + return "@" + strings.Join(target.ArgFiles, " @") + } + return target.Jar +} + +// stdinIsTerminal reports whether console input comes from a terminal rather +// than from a pipe or FIFO, as it does under a control panel +func stdinIsTerminal() bool { + info, err := os.Stdin.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 } // updateServerProperties updates server.properties with provided options @@ -221,7 +484,6 @@ func (r *Runner) updateServerProperties(opts RunOptions) error { return err } - // Only update if values are provided if opts.IP != "" { props.SetIP(opts.IP) } @@ -238,7 +500,6 @@ func (r *Runner) updateServerProperties(opts RunOptions) error { props.SetRcon(opts.RconPort, opts.RconPassword) } - // Apply config defaults if r.config != nil { for key, value := range r.config.Server.Properties { if props.Get(key) == "" { @@ -247,20 +508,9 @@ func (r *Runner) updateServerProperties(opts RunOptions) error { } } - // Only save if we have a properties file to update if _, err := os.Stat(filepath.Join(opts.Directory, "server.properties")); err == nil { return props.Save() } return nil } - -// GetDefaultJVMArgs returns the default JVM arguments as a string -func GetDefaultJVMArgs() string { - args := []string{ - "-XX:+UseG1GC", - "-XX:+ParallelRefProcEnabled", - "-XX:MaxGCPauseMillis=200", - } - return strings.Join(args, " ") -} diff --git a/internal/server/runner_test.go b/internal/server/runner_test.go new file mode 100644 index 0000000..fd38ebd --- /dev/null +++ b/internal/server/runner_test.go @@ -0,0 +1,464 @@ +package server + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/gameap/minecraft-runner/internal/config" + "github.com/gameap/minecraft-runner/internal/providers" +) + +// fakeProvider is a providers.Provider double whose installer writes the +// files listed in installs +type fakeProvider struct { + name string + proxy bool + jar *providers.ServerJar + resolveErr error + launch *providers.LaunchTarget + installs []string + + installCalls int +} + +func (p *fakeProvider) Name() string { return p.name } +func (p *fakeProvider) IsProxy() bool { return p.proxy } + +func (p *fakeProvider) ListVersions(context.Context) ([]providers.VersionInfo, error) { + return nil, nil +} + +func (p *fakeProvider) ListModVersions(context.Context, string) ([]providers.VersionInfo, error) { + return nil, nil +} + +func (p *fakeProvider) GetLatestVersion(context.Context) (*providers.VersionInfo, error) { + if p.resolveErr != nil { + return nil, p.resolveErr + } + return &providers.VersionInfo{MinecraftVersion: p.jar.Version}, nil +} + +func (p *fakeProvider) GetServerJar(context.Context, string, string) (*providers.ServerJar, error) { + if p.resolveErr != nil { + return nil, p.resolveErr + } + return p.jar, nil +} + +func (p *fakeProvider) PostDownload(_ context.Context, dir string, _ *providers.ServerJar, _ string) error { + p.installCalls++ + for _, file := range p.installs { + path := filepath.Join(dir, filepath.FromSlash(file)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + if err := os.WriteFile(path, nil, 0644); err != nil { + return err + } + } + return nil +} + +func (p *fakeProvider) GetRecommendedJavaVersion(context.Context, string) int { return 21 } + +// fakeInstallerProvider also resolves its own launch target, as Forge does +type fakeInstallerProvider struct { + fakeProvider +} + +func (p *fakeInstallerProvider) ResolveLaunch(dir string, _ *providers.ServerJar) *providers.LaunchTarget { + if !launchFilesExist(dir, *p.launch) { + return nil + } + return p.launch +} + +func newTestRunner(provider providers.Provider) *Runner { + registry := providers.NewRegistry() + registry.Register(provider) + + return NewRunner(registry, nil, &config.Config{}) +} + +func writeFile(t *testing.T, dir, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte("jar"), 0644); err != nil { + t.Fatal(err) + } +} + +func TestBuildArgs(t *testing.T) { + cfg := &config.Config{} + cfg.Defaults.Memory = "2G" + cfg.Defaults.MinMemory = "1G" + cfg.Server.JVMArgs = []string{"-XX:+UseG1GC"} + + runner := &Runner{config: cfg} + + t.Run("plain JAR under a control panel", func(t *testing.T) { + inst := &installation{launch: providers.LaunchTarget{Jar: "paper-1.21.8-60.jar"}} + + got := runner.buildArgs(&fakeProvider{name: "paper"}, RunOptions{JVMArgs: []string{"-Dfoo=bar"}}, inst, false) + + want := []string{ + "-Xmx2G", "-Xms1G", "-Dterminal.jline=false", "-XX:+UseG1GC", "-Dfoo=bar", + "-jar", "paper-1.21.8-60.jar", "--nogui", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("args = %v\nwant %v", got, want) + } + }) + + t.Run("a terminal keeps the JLine console", func(t *testing.T) { + inst := &installation{launch: providers.LaunchTarget{Jar: "server.jar"}} + + got := runner.buildArgs(&fakeProvider{name: "paper"}, RunOptions{}, inst, true) + + for _, arg := range got { + if arg == "-Dterminal.jline=false" { + t.Errorf("args = %v, JLine must stay enabled on a terminal", got) + } + } + }) + + t.Run("argument file launch with the user JVM arguments of a server pack", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, userJVMArgsFile) + + inst := &installation{ + launch: providers.LaunchTarget{ArgFiles: []string{"libraries/net/minecraftforge/forge/1.20.1-47.4.10/unix_args.txt"}}, + serverArgs: []string{"nogui"}, + } + + got := runner.buildArgs(&fakeProvider{name: "forge"}, RunOptions{Directory: dir, Memory: "6G"}, inst, false) + + want := []string{ + "@user_jvm_args.txt", "-Xmx6G", "-Xms1G", "-Dterminal.jline=false", "-XX:+UseG1GC", + "@libraries/net/minecraftforge/forge/1.20.1-47.4.10/unix_args.txt", "nogui", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("args = %v\nwant %v", got, want) + } + }) + + t.Run("argument file launch without a user JVM arguments file", func(t *testing.T) { + inst := &installation{launch: providers.LaunchTarget{ArgFiles: []string{"libraries/x/unix_args.txt"}}} + + got := runner.buildArgs(&fakeProvider{name: "forge"}, RunOptions{Directory: t.TempDir()}, inst, true) + + if got[0] != "-Xmx2G" { + t.Errorf("args = %v, must not reference a missing %s", got, userJVMArgsFile) + } + }) + + t.Run("proxies get no GUI switch", func(t *testing.T) { + inst := &installation{launch: providers.LaunchTarget{Jar: "BungeeCord-2096.jar"}} + + got := runner.buildArgs(&fakeProvider{name: "bungeecord", proxy: true}, RunOptions{Port: 25577}, inst, true) + + want := []string{"-Xmx2G", "-Xms1G", "-XX:+UseG1GC", "-jar", "BungeeCord-2096.jar"} + if !reflect.DeepEqual(got, want) { + t.Errorf("args = %v\nwant %v", got, want) + } + }) + + t.Run("Velocity takes its port on the command line", func(t *testing.T) { + inst := &installation{launch: providers.LaunchTarget{Jar: "velocity.jar"}} + + got := runner.buildArgs(providers.NewVelocityProvider(), RunOptions{Port: 25577}, inst, true) + + want := []string{"-Xmx2G", "-Xms1G", "-XX:+UseG1GC", "-jar", "velocity.jar", "--port", "25577"} + if !reflect.DeepEqual(got, want) { + t.Errorf("args = %v\nwant %v", got, want) + } + }) +} + +func TestMemorySettings(t *testing.T) { + cfg := &config.Config{} + cfg.Defaults.Memory = "2G" + cfg.Defaults.MinMemory = "1G" + + tests := []struct { + name string + runner *Runner + opts RunOptions + wantMax, wantMin string + }{ + {"config defaults", &Runner{config: cfg}, RunOptions{}, "2G", "1G"}, + {"flags win", &Runner{config: cfg}, RunOptions{Memory: "8G", MinMemory: "4G"}, "8G", "4G"}, + {"built-in defaults", &Runner{}, RunOptions{}, "1G", "1G"}, + {"an initial heap above the maximum is lowered", &Runner{config: cfg}, RunOptions{Memory: "512M"}, "512M", "512M"}, + {"units are compared, not strings", &Runner{}, RunOptions{Memory: "2G", MinMemory: "1024M"}, "2G", "1024M"}, + {"unparseable values pass through", &Runner{}, RunOptions{Memory: "lots", MinMemory: "4G"}, "lots", "4G"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMax, gotMin := tt.runner.memorySettings(tt.opts) + if gotMax != tt.wantMax || gotMin != tt.wantMin { + t.Errorf("memorySettings() = %s/%s, want %s/%s", gotMax, gotMin, tt.wantMax, tt.wantMin) + } + }) + } +} + +func TestParseMemory(t *testing.T) { + tests := []struct { + value string + want int64 + ok bool + }{ + {"512M", 512 << 20, true}, + {"4g", 4 << 30, true}, + {"1024k", 1024 << 10, true}, + {"1048576", 1048576, true}, + {" 2G ", 2 << 30, true}, + {"", 0, false}, + {"G", 0, false}, + {"-1G", 0, false}, + {"1.5G", 0, false}, + } + + for _, tt := range tests { + got, ok := parseMemory(tt.value) + if got != tt.want || ok != tt.ok { + t.Errorf("parseMemory(%q) = %d, %v; want %d, %v", tt.value, got, ok, tt.want, tt.ok) + } + } +} + +func TestInstall(t *testing.T) { + t.Run("a plain JAR that is already there is not fetched again", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "paper-1.21.8-60.jar") + + provider := &fakeProvider{name: "paper", jar: &providers.ServerJar{ + Version: "1.21.8", Filename: "paper-1.21.8-60.jar", URL: "http://127.0.0.1:1/unreachable", + }} + + target, err := newTestRunner(provider).install(context.Background(), provider, provider.jar, "java", dir) + if err != nil { + t.Fatalf("install: %v", err) + } + if target.Jar != "paper-1.21.8-60.jar" { + t.Errorf("target = %+v", target) + } + }) + + t.Run("an installed version does not run its installer again", func(t *testing.T) { + dir := t.TempDir() + argFile := "libraries/net/minecraftforge/forge/1.20.1-47.4.10/unix_args.txt" + + provider := &fakeInstallerProvider{fakeProvider{ + name: "forge", + jar: &providers.ServerJar{ + Version: "1.20.1", ModVersion: "47.4.10", Filename: "forge-installer.jar", + URL: "http://127.0.0.1:1/unreachable", RequiresInstall: true, + }, + launch: &providers.LaunchTarget{ArgFiles: []string{argFile}}, + installs: []string{argFile}, + }} + runner := newTestRunner(provider) + + // The installer is "downloaded" already, so the first run only has to install + writeFile(t, dir, "forge-installer.jar") + + for run := 1; run <= 2; run++ { + target, err := runner.install(context.Background(), provider, provider.jar, "java", dir) + if err != nil { + t.Fatalf("run %d: install: %v", run, err) + } + if !reflect.DeepEqual(target.ArgFiles, []string{argFile}) { + t.Errorf("run %d: target = %+v", run, target) + } + + // The real installers delete themselves; the second run must not need them + os.Remove(filepath.Join(dir, "forge-installer.jar")) + } + + if provider.installCalls != 1 { + t.Errorf("installer ran %d times, want 1", provider.installCalls) + } + }) + + t.Run("an installer that leaves nothing to launch is an error", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "forge-installer.jar") + + provider := &fakeInstallerProvider{fakeProvider{ + name: "forge", + jar: &providers.ServerJar{Version: "1.20.1", Filename: "forge-installer.jar", RequiresInstall: true}, + launch: &providers.LaunchTarget{ArgFiles: []string{"libraries/missing/unix_args.txt"}}, + }} + + if _, err := newTestRunner(provider).install(context.Background(), provider, provider.jar, "java", dir); err == nil { + t.Error("expected an error") + } + }) +} + +func TestInstalledFallback(t *testing.T) { + cause := errors.New("unexpected status code 410") + + setup := func(t *testing.T) (string, *Runner) { + t.Helper() + + dir := t.TempDir() + writeFile(t, dir, "paper-1.21.8-60.jar") + + state := &State{ + Mod: "paper", Version: "1.21.8", ModVersion: "60", File: "paper-1.21.8-60.jar", + Launch: providers.LaunchTarget{Jar: "paper-1.21.8-60.jar"}, + } + if err := state.Save(dir); err != nil { + t.Fatal(err) + } + + return dir, newTestRunner(&fakeProvider{name: "paper", resolveErr: cause}) + } + + t.Run("the installed server starts when the API is down", func(t *testing.T) { + dir, runner := setup(t) + + for _, opts := range []RunOptions{ + {Mod: "paper", Directory: dir}, + {Mod: "paper", Version: "1.21.8", Directory: dir}, + {Mod: "paper", Version: "1.21.8", ModVersion: "60", Directory: dir}, + } { + inst, err := runner.installedFallback(opts, cause) + if err != nil { + t.Fatalf("installedFallback(%+v): %v", opts, err) + } + if inst.version != "1.21.8" || inst.launch.Jar != "paper-1.21.8-60.jar" { + t.Errorf("inst = %+v", inst) + } + } + }) + + t.Run("a different request is not answered with the installed server", func(t *testing.T) { + dir, runner := setup(t) + + for _, opts := range []RunOptions{ + {Mod: "purpur", Directory: dir}, + {Mod: "paper", Version: "1.21.10", Directory: dir}, + {Mod: "paper", Version: "1.21.8", ModVersion: "61", Directory: dir}, + } { + if _, err := runner.installedFallback(opts, cause); !errors.Is(err, cause) { + t.Errorf("installedFallback(%+v) err = %v, want the resolve error", opts, err) + } + } + }) + + t.Run("a state whose files are gone is useless", func(t *testing.T) { + dir, runner := setup(t) + os.Remove(filepath.Join(dir, "paper-1.21.8-60.jar")) + + if _, err := runner.installedFallback(RunOptions{Mod: "paper", Directory: dir}, cause); !errors.Is(err, cause) { + t.Errorf("err = %v, want the resolve error", err) + } + }) + + t.Run("no state at all", func(t *testing.T) { + runner := newTestRunner(&fakeProvider{name: "paper", resolveErr: cause}) + + if _, err := runner.installedFallback(RunOptions{Mod: "paper", Directory: t.TempDir()}, cause); !errors.Is(err, cause) { + t.Errorf("err = %v, want the resolve error", err) + } + }) +} + +func TestRecordInstall(t *testing.T) { + runner := newTestRunner(&fakeProvider{name: "paper"}) + + record := func(dir, file, modVersion string) { + writeFile(t, dir, file) + runner.recordInstall( + RunOptions{Mod: "paper", Directory: dir}, + &providers.ServerJar{Version: "1.21.8", ModVersion: modVersion, Filename: file}, + &installation{version: "1.21.8", launch: providers.LaunchTarget{Jar: file}}, + ) + } + + dir := t.TempDir() + record(dir, "paper-1.21.8-59.jar", "59") + record(dir, "paper-1.21.8-60.jar", "60") + + if _, err := os.Stat(filepath.Join(dir, "paper-1.21.8-59.jar")); !os.IsNotExist(err) { + t.Error("the superseded JAR should have been removed") + } + if _, err := os.Stat(filepath.Join(dir, "paper-1.21.8-60.jar")); err != nil { + t.Errorf("the current JAR must stay: %v", err) + } + + state, err := LoadState(dir) + if err != nil || state == nil { + t.Fatalf("LoadState: %v, %v", state, err) + } + if state.ModVersion != "60" || state.File != "paper-1.21.8-60.jar" { + t.Errorf("state = %+v", state) + } + + installedAt := state.InstalledAt + record(dir, "paper-1.21.8-60.jar", "60") + + state, _ = LoadState(dir) + if !state.InstalledAt.Equal(installedAt) { + t.Error("an unchanged install must not rewrite the state") + } +} + +func TestRemoveSupersededLeavesInstallerBasedServersAlone(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "forge-1.12.2-14.23.5.2859.jar") + + previous := &State{ + Mod: "forge", File: "forge-1.12.2-14.23.5.2859-installer.jar", + Launch: providers.LaunchTarget{Jar: "forge-1.12.2-14.23.5.2859.jar"}, + } + current := &State{ + Mod: "forge", File: "forge-1.12.2-14.23.5.2864-installer.jar", + Launch: providers.LaunchTarget{Jar: "forge-1.12.2-14.23.5.2864.jar"}, + } + + removeSuperseded(dir, previous, current) + + if _, err := os.Stat(filepath.Join(dir, "forge-1.12.2-14.23.5.2859.jar")); err != nil { + t.Errorf("the Forge JAR must stay: %v", err) + } +} + +func TestStateMatches(t *testing.T) { + state := &State{Mod: "paper", Version: "1.21.8", ModVersion: "60"} + + tests := []struct { + mod, version, modVersion string + want bool + }{ + {"paper", "", "", true}, + {"paper", "1.21.8", "", true}, + {"paper", "1.21.8", "60", true}, + {"paper", "", "60", true}, + {"paper", "1.21.10", "", false}, + {"paper", "1.21.8", "61", false}, + {"folia", "1.21.8", "60", false}, + } + + for _, tt := range tests { + if got := state.Matches(tt.mod, tt.version, tt.modVersion); got != tt.want { + t.Errorf("Matches(%q, %q, %q) = %v, want %v", tt.mod, tt.version, tt.modVersion, got, tt.want) + } + } +} + +func TestLoadStateMissing(t *testing.T) { + state, err := LoadState(t.TempDir()) + if state != nil || err != nil { + t.Errorf("LoadState() = %v, %v; want nil, nil", state, err) + } +} diff --git a/internal/server/state.go b/internal/server/state.go new file mode 100644 index 0000000..a75696c --- /dev/null +++ b/internal/server/state.go @@ -0,0 +1,111 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/gameap/minecraft-runner/internal/providers" +) + +const stateFileName = ".mcrun-state.json" + +// State records the server mcrun last installed into a directory. It lets a +// server start while the provider's API is unreachable and tells which JAR a +// newer build has superseded. +type State struct { + Mod string `json:"mod"` + Version string `json:"version"` + ModVersion string `json:"mod_version,omitempty"` + File string `json:"file,omitempty"` + Launch providers.LaunchTarget `json:"launch"` + ServerArgs []string `json:"server_args,omitempty"` + InstalledAt time.Time `json:"installed_at"` +} + +// LoadState reads the state of a server directory; a directory without one yields nil +func LoadState(directory string) (*State, error) { + data, err := os.ReadFile(filepath.Join(directory, stateFileName)) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", stateFileName, err) + } + + var state State + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", stateFileName, err) + } + + return &state, nil +} + +// Save writes the state through a temporary file, so that an interrupted write +// cannot leave a truncated state behind +func (s *State) Save(directory string) error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("failed to encode %s: %w", stateFileName, err) + } + + tmp, err := os.CreateTemp(directory, stateFileName+".*.tmp") + if err != nil { + return fmt.Errorf("failed to create temporary state file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if _, err := tmp.Write(append(data, '\n')); err != nil { + tmp.Close() + return fmt.Errorf("failed to write %s: %w", stateFileName, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to close %s: %w", stateFileName, err) + } + + if err := os.Rename(tmpName, filepath.Join(directory, stateFileName)); err != nil { + return fmt.Errorf("failed to move %s into place: %w", stateFileName, err) + } + + return nil +} + +// Matches reports whether the state satisfies a request. An empty version or +// mod version in the request means "whatever is current", which the installed +// server is the best available answer to when the API cannot be asked. +func (s *State) Matches(mod, version, modVersion string) bool { + if s.Mod != mod { + return false + } + if version != "" && version != s.Version { + return false + } + if modVersion != "" && modVersion != s.ModVersion { + return false + } + return true +} + +// launchFilesExist checks that everything the launch target names is still on disk +func launchFilesExist(directory string, target providers.LaunchTarget) bool { + if target.Jar == "" && len(target.ArgFiles) == 0 { + return false + } + + files := append([]string{}, target.ArgFiles...) + if target.Jar != "" { + files = append(files, target.Jar) + } + + for _, file := range files { + if _, err := os.Stat(filepath.Join(directory, filepath.FromSlash(file))); err != nil { + return false + } + } + + return true +} diff --git a/internal/utils/download.go b/internal/utils/download.go index 7c6bbba..a876b40 100644 --- a/internal/utils/download.go +++ b/internal/utils/download.go @@ -2,6 +2,7 @@ package utils import ( "context" + "errors" "fmt" "io" "net/http" @@ -10,6 +11,32 @@ import ( "time" ) +const ( + projectURL = "https://github.com/gameap/minecraft-runner" + + // requestTimeout bounds a whole API request, body included + requestTimeout = 30 * time.Second + + // headerTimeout bounds the wait for response headers; downloads have no + // overall deadline, because a large file on a slow link takes as long as it takes + headerTimeout = 30 * time.Second + + // stallTimeout aborts a download that stopped delivering data + stallTimeout = 60 * time.Second +) + +// userAgent identifies mcrun to the download APIs. PaperMC asks for a +// descriptive agent with a contact URL and may block generic ones. +var userAgent = fmt.Sprintf("mcrun/dev (+%s)", projectURL) + +// SetVersion puts the mcrun version into the User-Agent of every request +func SetVersion(version string) { + if version == "" { + version = "dev" + } + userAgent = fmt.Sprintf("mcrun/%s (+%s)", version, projectURL) +} + // HTTPClient is a wrapper around http.Client with retry and progress support type HTTPClient struct { client *http.Client @@ -21,8 +48,11 @@ type HTTPClient struct { // NewHTTPClient creates a new HTTP client with retry support func NewHTTPClient(showProgress bool) *HTTPClient { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.ResponseHeaderTimeout = headerTimeout + return &HTTPClient{ - client: &http.Client{Timeout: 30 * time.Second}, + client: &http.Client{Transport: transport}, showProgress: showProgress, retryMax: 3, retryWaitMin: 1 * time.Second, @@ -30,68 +60,89 @@ func NewHTTPClient(showProgress bool) *HTTPClient { } } -// doWithRetry performs an HTTP request with retry logic -func (c *HTTPClient) doWithRetry(req *http.Request) (*http.Response, error) { +// StatusError is returned when a server answers with an unexpected HTTP status +type StatusError struct { + StatusCode int + URL string +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("unexpected status code %d for %s", e.StatusCode, e.URL) +} + +// doWithRetry performs a GET request, retrying network errors, 5xx and 429 +func (c *HTTPClient) doWithRetry(ctx context.Context, url string) (*http.Response, error) { var lastErr error + for attempt := 0; attempt <= c.retryMax; attempt++ { if attempt > 0 { - // Calculate backoff with exponential increase backoff := c.retryWaitMin * time.Duration(1< c.retryWaitMax { backoff = c.retryWaitMax } - time.Sleep(backoff) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(backoff): + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) } + req.Header.Set("User-Agent", userAgent) resp, err := c.client.Do(req) if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } lastErr = err continue } - // Retry on 5xx errors - if resp.StatusCode >= 500 { + if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests { resp.Body.Close() - lastErr = fmt.Errorf("server error: %d", resp.StatusCode) + lastErr = &StatusError{StatusCode: resp.StatusCode, URL: url} continue } return resp, nil } + return nil, fmt.Errorf("request failed after %d retries: %w", c.retryMax, lastErr) } // Get performs a GET request and returns the response body func (c *HTTPClient) Get(ctx context.Context, url string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("User-Agent", "mcrun/1.0") + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() - resp, err := c.doWithRetry(req) + resp, err := c.doWithRetry(ctx, url) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + return nil, &StatusError{StatusCode: resp.StatusCode, URL: url} } return io.ReadAll(resp.Body) } // DownloadFile downloads a file to the specified path with progress bar -func (c *HTTPClient) DownloadFile(ctx context.Context, url string, destPath string) error { - // Ensure destination directory exists +func (c *HTTPClient) DownloadFile(ctx context.Context, url string, destPath string) (err error) { dir := filepath.Dir(destPath) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } - // Create temporary file for download + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + tmpPath := destPath + ".tmp" file, err := os.Create(tmpPath) if err != nil { @@ -99,50 +150,61 @@ func (c *HTTPClient) DownloadFile(ctx context.Context, url string, destPath stri } defer func() { file.Close() - os.Remove(tmpPath) // Clean up on error + if err != nil { + os.Remove(tmpPath) + } }() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("User-Agent", "mcrun/1.0") - - resp, err := c.doWithRetry(req) + resp, err := c.doWithRetry(ctx, url) if err != nil { return fmt.Errorf("download failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + return &StatusError{StatusCode: resp.StatusCode, URL: url} } var writer io.Writer = file if c.showProgress && resp.ContentLength > 0 { - pw := &progressWriter{ + writer = &progressWriter{ total: resp.ContentLength, filename: filepath.Base(destPath), writer: file, } - writer = pw } - _, err = io.Copy(writer, resp.Body) + body := newStallReader(resp.Body, stallTimeout, func() { + cancel(fmt.Errorf("no data received for %s", stallTimeout)) + }) + defer body.stop() + + written, err := io.Copy(writer, body) if c.showProgress { - fmt.Println() // newline after progress + fmt.Println() } if err != nil { + if cause := context.Cause(ctx); cause != nil && !errors.Is(cause, context.Canceled) { + err = cause + } return fmt.Errorf("failed to write file: %w", err) } - // Close file before renaming - file.Close() + // Not every provider publishes a checksum, and a broken mirror may well + // answer 200 with an empty or cut-off body + if written == 0 { + return fmt.Errorf("the server sent an empty file for %s", url) + } + if resp.ContentLength > 0 && written != resp.ContentLength { + return fmt.Errorf("incomplete download of %s: got %d of %d bytes", url, written, resp.ContentLength) + } + + if err = file.Close(); err != nil { + return fmt.Errorf("failed to close file: %w", err) + } - // Rename temp file to final destination - if err := os.Rename(tmpPath, destPath); err != nil { + if err = os.Rename(tmpPath, destPath); err != nil { return fmt.Errorf("failed to rename file: %w", err) } @@ -155,6 +217,34 @@ func FileExists(path string) bool { return err == nil } +// stallReader calls onStall when the wrapped reader delivers nothing for the +// given timeout, which is how a dead connection is told from a slow one +type stallReader struct { + reader io.Reader + timeout time.Duration + timer *time.Timer +} + +func newStallReader(reader io.Reader, timeout time.Duration, onStall func()) *stallReader { + return &stallReader{ + reader: reader, + timeout: timeout, + timer: time.AfterFunc(timeout, onStall), + } +} + +func (r *stallReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if n > 0 { + r.timer.Reset(r.timeout) + } + return n, err +} + +func (r *stallReader) stop() { + r.timer.Stop() +} + // progressWriter is a simple progress indicator for downloads type progressWriter struct { total int64 diff --git a/internal/utils/download_test.go b/internal/utils/download_test.go new file mode 100644 index 0000000..0bac1bb --- /dev/null +++ b/internal/utils/download_test.go @@ -0,0 +1,193 @@ +package utils + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func newFastClient() *HTTPClient { + client := NewHTTPClient(false) + client.retryWaitMin = time.Millisecond + client.retryWaitMax = 5 * time.Millisecond + return client +} + +func TestGetSendsDescriptiveUserAgent(t *testing.T) { + var got string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("User-Agent") + fmt.Fprint(w, "ok") + })) + defer server.Close() + + SetVersion("1.2.3") + defer SetVersion("") + + if _, err := newFastClient().Get(context.Background(), server.URL); err != nil { + t.Fatalf("Get: %v", err) + } + + if want := "mcrun/1.2.3 (+" + projectURL + ")"; got != want { + t.Errorf("User-Agent = %q, want %q", got, want) + } +} + +func TestGetRetriesTransientStatuses(t *testing.T) { + for _, status := range []int{http.StatusBadGateway, http.StatusTooManyRequests} { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) < 3 { + w.WriteHeader(status) + return + } + fmt.Fprint(w, "ok") + })) + + data, err := newFastClient().Get(context.Background(), server.URL) + server.Close() + + if err != nil || string(data) != "ok" { + t.Errorf("status %d: Get() = %q, %v", status, data, err) + } + if calls.Load() != 3 { + t.Errorf("status %d: %d calls, want 3", status, calls.Load()) + } + } +} + +func TestGetDoesNotRetryClientErrors(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusGone) + })) + defer server.Close() + + _, err := newFastClient().Get(context.Background(), server.URL) + + var statusErr *StatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusGone { + t.Errorf("err = %v, want a 410 StatusError", err) + } + if calls.Load() != 1 { + t.Errorf("%d calls, want 1", calls.Load()) + } +} + +func TestRetryWaitHonoursContext(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer server.Close() + + client := NewHTTPClient(false) + client.retryWaitMin = time.Minute + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + started := time.Now() + _, err := client.Get(ctx, server.URL) + + if err == nil { + t.Fatal("expected an error") + } + if elapsed := time.Since(started); elapsed > 5*time.Second { + t.Errorf("Get returned after %s, the backoff ignored the context", elapsed) + } +} + +func TestDownloadFile(t *testing.T) { + t.Run("a complete file lands at its destination", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "jar-content") + })) + defer server.Close() + + dest := filepath.Join(t.TempDir(), "sub", "server.jar") + if err := newFastClient().DownloadFile(context.Background(), server.URL, dest); err != nil { + t.Fatalf("DownloadFile: %v", err) + } + + data, err := os.ReadFile(dest) + if err != nil || string(data) != "jar-content" { + t.Errorf("file = %q, %v", data, err) + } + if _, err := os.Stat(dest + ".tmp"); !os.IsNotExist(err) { + t.Error("the temporary file must be gone") + } + }) + + t.Run("an empty body is an error, not a server JAR", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/java-archive") + })) + defer server.Close() + + dest := filepath.Join(t.TempDir(), "server.jar") + err := newFastClient().DownloadFile(context.Background(), server.URL, dest) + + if err == nil || !strings.Contains(err.Error(), "empty file") { + t.Errorf("err = %v, want an empty file error", err) + } + for _, path := range []string{dest, dest + ".tmp"} { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Errorf("%s must not be left behind", filepath.Base(path)) + } + } + }) + + t.Run("an error status is reported", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + err := newFastClient().DownloadFile(context.Background(), server.URL, filepath.Join(t.TempDir(), "server.jar")) + + var statusErr *StatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusNotFound { + t.Errorf("err = %v, want a 404 StatusError", err) + } + }) +} + +func TestStallReader(t *testing.T) { + stalled := make(chan struct{}) + reader := newStallReader(strings.NewReader("data"), 20*time.Millisecond, func() { close(stalled) }) + defer reader.stop() + + buf := make([]byte, 4) + if n, err := reader.Read(buf); n != 4 || err != nil { + t.Fatalf("Read = %d, %v", n, err) + } + + select { + case <-stalled: + case <-time.After(2 * time.Second): + t.Error("a reader that delivers nothing more must be reported as stalled") + } +} + +func TestVerifyMD5(t *testing.T) { + path := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(path, []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + + if err := VerifyMD5(path, "5D41402ABC4B2A76B9719D911017C592"); err != nil { + t.Errorf("VerifyMD5: %v", err) + } + if err := VerifyMD5(path, "00000000000000000000000000000000"); err == nil { + t.Error("expected a mismatch") + } +} diff --git a/internal/utils/env.go b/internal/utils/env.go new file mode 100644 index 0000000..fbba383 --- /dev/null +++ b/internal/utils/env.go @@ -0,0 +1,62 @@ +package utils + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +// JavaEnvironment returns the environment for a process started with the given +// Java binary: that Java comes first on PATH and is JAVA_HOME. Servers spawn +// "java" themselves - hybrid servers restart that way after installing their +// libraries - and mcrun's own Java is normally not on PATH at all, so without +// this they fail or end up on whichever other Java the system has. +// A bare command name leaves the environment untouched. +func JavaEnvironment(javaBinary string) []string { + return javaEnvironment(os.Environ(), javaBinary) +} + +func javaEnvironment(environ []string, javaBinary string) []string { + if !strings.ContainsRune(javaBinary, filepath.Separator) { + return environ + } + + binDir := filepath.Dir(javaBinary) + + env := make([]string, 0, len(environ)+2) + path := "" + + for _, entry := range environ { + key, value, _ := strings.Cut(entry, "=") + + switch { + case envKeyEquals(key, "PATH"): + path = value + case envKeyEquals(key, "JAVA_HOME"): + default: + env = append(env, entry) + } + } + + if path == "" { + path = binDir + } else { + path = binDir + string(os.PathListSeparator) + path + } + env = append(env, "PATH="+path) + + if filepath.Base(binDir) == "bin" { + env = append(env, "JAVA_HOME="+filepath.Dir(binDir)) + } + + return env +} + +// envKeyEquals compares environment variable names, which Windows treats case-insensitively +func envKeyEquals(key, name string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(key, name) + } + return key == name +} diff --git a/internal/utils/env_test.go b/internal/utils/env_test.go new file mode 100644 index 0000000..8adb09f --- /dev/null +++ b/internal/utils/env_test.go @@ -0,0 +1,56 @@ +package utils + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func TestJavaEnvironment(t *testing.T) { + sep := string(os.PathListSeparator) + javaHome := filepath.Join(string(filepath.Separator)+"opt", "mcrun", "java", "jdk-17") + javaBinary := filepath.Join(javaHome, "bin", "java") + + t.Run("the chosen Java leads PATH and becomes JAVA_HOME", func(t *testing.T) { + got := javaEnvironment([]string{"HOME=/root", "PATH=/usr/bin", "JAVA_HOME=/usr/lib/jvm/old"}, javaBinary) + + want := []string{ + "HOME=/root", + "PATH=" + filepath.Join(javaHome, "bin") + sep + "/usr/bin", + "JAVA_HOME=" + javaHome, + } + + sort.Strings(got) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Errorf("env = %v, want %v", got, want) + } + }) + + t.Run("an environment without PATH gets one", func(t *testing.T) { + got := javaEnvironment([]string{"HOME=/root"}, javaBinary) + + want := []string{"HOME=/root", "PATH=" + filepath.Join(javaHome, "bin"), "JAVA_HOME=" + javaHome} + if !reflect.DeepEqual(got, want) { + t.Errorf("env = %v, want %v", got, want) + } + }) + + t.Run("a bare command name changes nothing", func(t *testing.T) { + environ := []string{"PATH=/usr/bin", "JAVA_HOME=/usr/lib/jvm/old"} + if got := javaEnvironment(environ, "java"); !reflect.DeepEqual(got, environ) { + t.Errorf("env = %v, want it untouched", got) + } + }) + + t.Run("a binary outside a bin directory sets no JAVA_HOME", func(t *testing.T) { + got := javaEnvironment([]string{"PATH=/usr/bin"}, filepath.Join(string(filepath.Separator)+"custom", "java")) + + want := []string{"PATH=" + string(filepath.Separator) + "custom" + sep + "/usr/bin"} + if !reflect.DeepEqual(got, want) { + t.Errorf("env = %v, want %v", got, want) + } + }) +} diff --git a/internal/utils/hash.go b/internal/utils/hash.go index f342e9b..b4d7cc0 100644 --- a/internal/utils/hash.go +++ b/internal/utils/hash.go @@ -1,10 +1,12 @@ package utils import ( + "crypto/md5" "crypto/sha1" "crypto/sha256" "encoding/hex" "fmt" + "hash" "io" "os" "strings" @@ -12,59 +14,50 @@ import ( // VerifySHA256 verifies that a file matches the expected SHA256 hash func VerifySHA256(filePath string, expectedHash string) error { - hash, err := CalculateSHA256(filePath) - if err != nil { - return err - } - - expectedHash = strings.ToLower(expectedHash) - if hash != expectedHash { - return fmt.Errorf("SHA256 mismatch: expected %s, got %s", expectedHash, hash) - } - - return nil + return verifyHash("SHA256", sha256.New(), filePath, expectedHash) } // CalculateSHA256 calculates the SHA256 hash of a file func CalculateSHA256(filePath string) (string, error) { - file, err := os.Open(filePath) - if err != nil { - return "", fmt.Errorf("failed to open file: %w", err) - } - defer file.Close() - - hasher := sha256.New() - if _, err := io.Copy(hasher, file); err != nil { - return "", fmt.Errorf("failed to hash file: %w", err) - } - - return hex.EncodeToString(hasher.Sum(nil)), nil + return calculateHash(sha256.New(), filePath) } // VerifySHA1 verifies that a file matches the expected SHA1 hash func VerifySHA1(filePath string, expectedHash string) error { - hash, err := CalculateSHA1(filePath) + return verifyHash("SHA1", sha1.New(), filePath, expectedHash) +} + +// CalculateSHA1 calculates the SHA1 hash of a file +func CalculateSHA1(filePath string) (string, error) { + return calculateHash(sha1.New(), filePath) +} + +// VerifyMD5 verifies that a file matches the expected MD5 hash +func VerifyMD5(filePath string, expectedHash string) error { + return verifyHash("MD5", md5.New(), filePath, expectedHash) +} + +func verifyHash(name string, hasher hash.Hash, filePath string, expectedHash string) error { + actual, err := calculateHash(hasher, filePath) if err != nil { return err } - expectedHash = strings.ToLower(expectedHash) - if hash != expectedHash { - return fmt.Errorf("SHA1 mismatch: expected %s, got %s", expectedHash, hash) + expectedHash = strings.ToLower(strings.TrimSpace(expectedHash)) + if actual != expectedHash { + return fmt.Errorf("%s mismatch: expected %s, got %s", name, expectedHash, actual) } return nil } -// CalculateSHA1 calculates the SHA1 hash of a file -func CalculateSHA1(filePath string) (string, error) { +func calculateHash(hasher hash.Hash, filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", fmt.Errorf("failed to open file: %w", err) } defer file.Close() - hasher := sha1.New() if _, err := io.Copy(hasher, file); err != nil { return "", fmt.Errorf("failed to hash file: %w", err) } diff --git a/pkg/api/adoptium/client.go b/pkg/api/adoptium/client.go index f2bea3f..0d23838 100644 --- a/pkg/api/adoptium/client.go +++ b/pkg/api/adoptium/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" "runtime" "github.com/gameap/minecraft-runner/internal/utils" @@ -132,9 +133,31 @@ func normalizeOS(goos string) string { return "mac" case "windows": return "windows" - default: - return "linux" } + + if isMusl() { + return "alpine-linux" + } + + return "linux" +} + +// isMusl reports whether the system C library is musl, as on Alpine, where the +// glibc builds of Temurin do not start. A glibc system that merely has the musl +// package installed keeps its glibc loader and is not mistaken for one. +func isMusl() bool { + musl, _ := filepath.Glob("/lib/ld-musl-*.so.1") + if len(musl) == 0 { + return false + } + + for _, pattern := range []string{"/lib/ld-linux*.so*", "/lib64/ld-linux*.so*"} { + if glibc, _ := filepath.Glob(pattern); len(glibc) > 0 { + return false + } + } + + return true } // normalizeArch converts Go's GOARCH to Adoptium API architecture names diff --git a/pkg/api/forge/client.go b/pkg/api/forge/client.go index a46f7b1..6c30f13 100644 --- a/pkg/api/forge/client.go +++ b/pkg/api/forge/client.go @@ -10,19 +10,29 @@ import ( ) const ( - promotionsURL = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json" - mavenBaseURL = "https://maven.minecraftforge.net/net/minecraftforge/forge" + promotionsURL = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json" + mavenMetadataURL = "https://files.minecraftforge.net/net/minecraftforge/forge/maven-metadata.json" + mavenBaseURL = "https://maven.minecraftforge.net/net/minecraftforge/forge" ) // Client is the Forge API client type Client struct { - http *utils.HTTPClient + http *utils.HTTPClient + promotionsURL string + mavenMetadataURL string } // NewClient creates a new Forge API client func NewClient() *Client { + return NewClientWithURLs(promotionsURL, mavenMetadataURL) +} + +// NewClientWithURLs creates a Forge API client with custom metadata locations +func NewClientWithURLs(promotions, mavenMetadata string) *Client { return &Client{ - http: utils.NewHTTPClient(false), + http: utils.NewHTTPClient(false), + promotionsURL: promotions, + mavenMetadataURL: mavenMetadata, } } @@ -42,7 +52,7 @@ type ForgeVersion struct { // GetPromotions fetches the Forge promotions data func (c *Client) GetPromotions(ctx context.Context) (*Promotions, error) { - data, err := c.http.Get(ctx, promotionsURL) + data, err := c.http.Get(ctx, c.promotionsURL) if err != nil { return nil, fmt.Errorf("failed to fetch promotions: %w", err) } @@ -112,7 +122,6 @@ func (c *Client) GetVersionForMC(ctx context.Context, mcVersion string, preferRe MinecraftVersion: mcVersion, } - // Try recommended first if preferRecommended { if forgeVer, ok := promotions.Promos[mcVersion+"-recommended"]; ok { v.ForgeVersion = forgeVer @@ -121,7 +130,6 @@ func (c *Client) GetVersionForMC(ctx context.Context, mcVersion string, preferRe } } - // Fall back to latest if forgeVer, ok := promotions.Promos[mcVersion+"-latest"]; ok { v.ForgeVersion = forgeVer v.IsLatest = true @@ -131,25 +139,73 @@ func (c *Client) GetVersionForMC(ctx context.Context, mcVersion string, preferRe return nil, fmt.Errorf("no Forge version found for Minecraft %s", mcVersion) } -// GetInstallerURL returns the installer JAR download URL -func (c *Client) GetInstallerURL(mcVersion, forgeVersion string) string { - fullVersion := fmt.Sprintf("%s-%s", mcVersion, forgeVersion) - return fmt.Sprintf("%s/%s/forge-%s-installer.jar", mavenBaseURL, fullVersion, fullVersion) +// GetMavenMetadata returns the artifact versions published for each Minecraft +// version, oldest first +func (c *Client) GetMavenMetadata(ctx context.Context) (map[string][]string, error) { + data, err := c.http.Get(ctx, c.mavenMetadataURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch maven metadata: %w", err) + } + + var metadata map[string][]string + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse maven metadata: %w", err) + } + + return metadata, nil +} + +// ResolveArtifactVersion returns the Maven artifact version of a Forge release. +// It is "-" for modern releases, but old ones carry one more suffix +// ("1.7.10-10.13.4.1614-1.7.10", "1.10-12.18.0.2000-1.10.0"), so it has to be +// looked up rather than assembled. +func (c *Client) ResolveArtifactVersion(ctx context.Context, mcVersion, forgeVersion string) (string, error) { + metadata, err := c.GetMavenMetadata(ctx) + if err != nil { + return "", err + } + + if artifact, ok := FindArtifactVersion(metadata[mcVersion], mcVersion, forgeVersion); ok { + return artifact, nil + } + + return "", fmt.Errorf("no Forge %s published for Minecraft %s", forgeVersion, mcVersion) +} + +// FindArtifactVersion picks the artifact version of a Forge release out of the +// versions published for its Minecraft version +func FindArtifactVersion(artifacts []string, mcVersion, forgeVersion string) (string, bool) { + want := ArtifactPrefix(mcVersion, forgeVersion) + + for _, artifact := range artifacts { + if artifact == want || strings.HasPrefix(artifact, want+"-") { + return artifact, true + } + } + + return "", false } -// GetServerJarName returns the expected server JAR name after installation -func (c *Client) GetServerJarName(mcVersion, forgeVersion string) string { - // For modern Forge (1.17+), the naming changed - major, minor, _ := parseVersion(mcVersion) - if major >= 1 && minor >= 17 { - // Modern Forge uses run.sh/run.bat or the libraries folder - return fmt.Sprintf("forge-%s-%s-server.jar", mcVersion, forgeVersion) +// ForgeVersionOf extracts the Forge version from an artifact version +func ForgeVersionOf(artifact, mcVersion string) string { + version := strings.TrimPrefix(artifact, mcVersion+"-") + if i := strings.Index(version, "-"); i >= 0 { + version = version[:i] } - // Legacy Forge - return fmt.Sprintf("forge-%s-%s.jar", mcVersion, forgeVersion) + return version +} + +// ArtifactPrefix is the part every artifact version of a release starts with +func ArtifactPrefix(mcVersion, forgeVersion string) string { + return mcVersion + "-" + forgeVersion +} + +// GetInstallerURL returns the installer JAR download URL of an artifact version +func (c *Client) GetInstallerURL(artifactVersion string) string { + return fmt.Sprintf("%s/%s/%s", mavenBaseURL, artifactVersion, InstallerName(artifactVersion)) } -func parseVersion(version string) (major, minor, patch int) { - fmt.Sscanf(version, "%d.%d.%d", &major, &minor, &patch) - return +// InstallerName returns the installer file name of an artifact version +func InstallerName(artifactVersion string) string { + return fmt.Sprintf("forge-%s-installer.jar", artifactVersion) } diff --git a/pkg/api/forge/client_test.go b/pkg/api/forge/client_test.go new file mode 100644 index 0000000..e7f6d07 --- /dev/null +++ b/pkg/api/forge/client_test.go @@ -0,0 +1,72 @@ +package forge + +import "testing" + +func TestFindArtifactVersion(t *testing.T) { + tests := []struct { + name string + artifacts []string + mc, forge string + want string + found bool + }{ + { + name: "modern releases are just mc-forge", + artifacts: []string{"1.20.1-47.4.9", "1.20.1-47.4.10"}, + mc: "1.20.1", forge: "47.4.10", + want: "1.20.1-47.4.10", found: true, + }, + { + name: "old releases repeat the Minecraft version", + artifacts: []string{"1.7.10-10.13.4.1566-1.7.10", "1.7.10-10.13.4.1614-1.7.10"}, + mc: "1.7.10", forge: "10.13.4.1614", + want: "1.7.10-10.13.4.1614-1.7.10", found: true, + }, + { + name: "the suffix may differ from the Minecraft version", + artifacts: []string{"1.10-12.18.0.2000-1.10.0"}, + mc: "1.10", forge: "12.18.0.2000", + want: "1.10-12.18.0.2000-1.10.0", found: true, + }, + { + name: "a version that merely shares a prefix does not match", + artifacts: []string{"1.20.1-47.4.100"}, + mc: "1.20.1", forge: "47.4.10", + }, + { + name: "nothing published", mc: "1.20.1", forge: "47.4.10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, found := FindArtifactVersion(tt.artifacts, tt.mc, tt.forge) + if got != tt.want || found != tt.found { + t.Errorf("FindArtifactVersion() = %q, %v; want %q, %v", got, found, tt.want, tt.found) + } + }) + } +} + +func TestForgeVersionOf(t *testing.T) { + tests := []struct{ artifact, mc, want string }{ + {"1.20.1-47.4.10", "1.20.1", "47.4.10"}, + {"1.7.10-10.13.4.1614-1.7.10", "1.7.10", "10.13.4.1614"}, + {"26.2-65.1.3", "26.2", "65.1.3"}, + } + + for _, tt := range tests { + if got := ForgeVersionOf(tt.artifact, tt.mc); got != tt.want { + t.Errorf("ForgeVersionOf(%q, %q) = %q, want %q", tt.artifact, tt.mc, got, tt.want) + } + } +} + +func TestInstallerURL(t *testing.T) { + want := "https://maven.minecraftforge.net/net/minecraftforge/forge/" + + "1.7.10-10.13.4.1614-1.7.10/forge-1.7.10-10.13.4.1614-1.7.10-installer.jar" + + if got := NewClient().GetInstallerURL("1.7.10-10.13.4.1614-1.7.10"); got != want { + t.Errorf("GetInstallerURL() = %q, want %q", got, want) + } +} diff --git a/pkg/api/jenkins/client.go b/pkg/api/jenkins/client.go index 72700d4..ef15080 100644 --- a/pkg/api/jenkins/client.go +++ b/pkg/api/jenkins/client.go @@ -9,7 +9,12 @@ import ( ) const ( - bungeecordBaseURL = "https://hub.spigotmc.org/jenkins/job/BungeeCord" + bungeecordJobURL = "https://hub.spigotmc.org/jenkins/job/BungeeCord" + + resultSuccess = "SUCCESS" + + // buildsTree asks Jenkins for the artifacts of every build in one request + buildsTree = "builds[number,result,artifacts[fileName,relativePath]]" ) // Client is a Jenkins API client @@ -18,14 +23,32 @@ type Client struct { baseURL string } +// NewClient creates a client for a Jenkins URL: a server root or a single job +func NewClient(baseURL string) *Client { + return &Client{ + http: utils.NewHTTPClient(false), + baseURL: baseURL, + } +} + // NewBungeecordClient creates a client for Bungeecord Jenkins func NewBungeecordClient() *Client { + return NewClient(bungeecordJobURL) +} + +// Job returns a client for a job of this Jenkins server +func (c *Client) Job(name string) *Client { return &Client{ - http: utils.NewHTTPClient(false), - baseURL: bungeecordBaseURL, + http: c.http, + baseURL: fmt.Sprintf("%s/job/%s", c.baseURL, name), } } +// JobRef is a reference to a job of a Jenkins server +type JobRef struct { + Name string `json:"name"` +} + // JobInfo represents Jenkins job information type JobInfo struct { DisplayName string `json:"displayName"` @@ -46,25 +69,34 @@ type BuildInfo struct { Artifacts []Artifact `json:"artifacts"` } +// IsSuccessful reports whether the build finished and produced its artifacts +func (b *BuildInfo) IsSuccessful() bool { + return b.Result == resultSuccess +} + // Artifact represents a build artifact type Artifact struct { FileName string `json:"fileName"` RelativePath string `json:"relativePath"` } -// GetJobInfo fetches job information -func (c *Client) GetJobInfo(ctx context.Context) (*JobInfo, error) { - url := fmt.Sprintf("%s/api/json", c.baseURL) - data, err := c.http.Get(ctx, url) - if err != nil { - return nil, fmt.Errorf("failed to fetch job info: %w", err) +// GetJobs fetches the jobs of a Jenkins server +func (c *Client) GetJobs(ctx context.Context) ([]JobRef, error) { + var response struct { + Jobs []JobRef `json:"jobs"` + } + if err := c.get(ctx, c.baseURL+"/api/json?tree=jobs[name]", &response); err != nil { + return nil, fmt.Errorf("failed to fetch jobs: %w", err) } + return response.Jobs, nil +} +// GetJobInfo fetches job information +func (c *Client) GetJobInfo(ctx context.Context) (*JobInfo, error) { var info JobInfo - if err := json.Unmarshal(data, &info); err != nil { - return nil, fmt.Errorf("failed to parse job info: %w", err) + if err := c.get(ctx, c.baseURL+"/api/json", &info); err != nil { + return nil, fmt.Errorf("failed to fetch job info: %w", err) } - return &info, nil } @@ -77,35 +109,32 @@ func (c *Client) GetBuilds(ctx context.Context) ([]BuildRef, error) { return info.Builds, nil } -// GetBuild fetches information about a specific build -func (c *Client) GetBuild(ctx context.Context, number int) (*BuildInfo, error) { - url := fmt.Sprintf("%s/%d/api/json", c.baseURL, number) - data, err := c.http.Get(ctx, url) - if err != nil { - return nil, fmt.Errorf("failed to fetch build info: %w", err) +// GetBuildsWithArtifacts fetches the builds of a job with their artifacts, newest first +func (c *Client) GetBuildsWithArtifacts(ctx context.Context) ([]BuildInfo, error) { + var response struct { + Builds []BuildInfo `json:"builds"` } + if err := c.get(ctx, c.baseURL+"/api/json?tree="+buildsTree, &response); err != nil { + return nil, fmt.Errorf("failed to fetch builds: %w", err) + } + return response.Builds, nil +} +// GetBuild fetches information about a specific build +func (c *Client) GetBuild(ctx context.Context, number int) (*BuildInfo, error) { var build BuildInfo - if err := json.Unmarshal(data, &build); err != nil { - return nil, fmt.Errorf("failed to parse build info: %w", err) + if err := c.get(ctx, fmt.Sprintf("%s/%d/api/json", c.baseURL, number), &build); err != nil { + return nil, fmt.Errorf("failed to fetch build info: %w", err) } - return &build, nil } // GetLastSuccessfulBuild fetches the last successful build func (c *Client) GetLastSuccessfulBuild(ctx context.Context) (*BuildInfo, error) { - url := fmt.Sprintf("%s/lastSuccessfulBuild/api/json", c.baseURL) - data, err := c.http.Get(ctx, url) - if err != nil { - return nil, fmt.Errorf("failed to fetch last successful build: %w", err) - } - var build BuildInfo - if err := json.Unmarshal(data, &build); err != nil { - return nil, fmt.Errorf("failed to parse build info: %w", err) + if err := c.get(ctx, c.baseURL+"/lastSuccessfulBuild/api/json", &build); err != nil { + return nil, fmt.Errorf("failed to fetch last successful build: %w", err) } - return &build, nil } @@ -113,3 +142,11 @@ func (c *Client) GetLastSuccessfulBuild(ctx context.Context) (*BuildInfo, error) func (c *Client) GetArtifactURL(buildNumber int, relativePath string) string { return fmt.Sprintf("%s/%d/artifact/%s", c.baseURL, buildNumber, relativePath) } + +func (c *Client) get(ctx context.Context, url string, target interface{}) error { + data, err := c.http.Get(ctx, url) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} diff --git a/pkg/api/leaf/client.go b/pkg/api/leaf/client.go new file mode 100644 index 0000000..412b4be --- /dev/null +++ b/pkg/api/leaf/client.go @@ -0,0 +1,94 @@ +package leaf + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/gameap/minecraft-runner/internal/utils" +) + +const ( + baseURL = "https://api.leafmc.one/v2/projects/leaf" + + // ChannelDefault marks the builds Leaf considers production ready + ChannelDefault = "default" +) + +// Client is the Leaf API client +type Client struct { + http *utils.HTTPClient + baseURL string +} + +// NewClient creates a new Leaf API client +func NewClient() *Client { + return NewClientWithBaseURL(baseURL) +} + +// NewClientWithBaseURL creates a Leaf API client for a custom API root +func NewClientWithBaseURL(url string) *Client { + return &Client{ + http: utils.NewHTTPClient(false), + baseURL: url, + } +} + +// Project lists the Minecraft versions Leaf was built for, in no particular order +type Project struct { + Versions []string `json:"versions"` +} + +// Build describes a single build +type Build struct { + Build int `json:"build"` + Channel string `json:"channel"` + Downloads struct { + Primary struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` + } `json:"primary"` + } `json:"downloads"` +} + +// IsStable reports whether the build was published to the default channel +func (b *Build) IsStable() bool { + return b.Channel == ChannelDefault +} + +// GetProject fetches the supported Minecraft versions +func (c *Client) GetProject(ctx context.Context) (*Project, error) { + data, err := c.http.Get(ctx, c.baseURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch project: %w", err) + } + + var project Project + if err := json.Unmarshal(data, &project); err != nil { + return nil, fmt.Errorf("failed to parse project: %w", err) + } + + return &project, nil +} + +// GetBuilds fetches the builds of a Minecraft version, oldest first +func (c *Client) GetBuilds(ctx context.Context, version string) ([]Build, error) { + data, err := c.http.Get(ctx, fmt.Sprintf("%s/versions/%s/builds", c.baseURL, version)) + if err != nil { + return nil, fmt.Errorf("failed to fetch builds: %w", err) + } + + var response struct { + Builds []Build `json:"builds"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse builds: %w", err) + } + + return response.Builds, nil +} + +// GetDownloadURL returns the JAR download URL of a build +func (c *Client) GetDownloadURL(version string, build int, filename string) string { + return fmt.Sprintf("%s/versions/%s/builds/%d/downloads/%s", c.baseURL, version, build, filename) +} diff --git a/pkg/api/mohist/client.go b/pkg/api/mohist/client.go new file mode 100644 index 0000000..48495a5 --- /dev/null +++ b/pkg/api/mohist/client.go @@ -0,0 +1,92 @@ +package mohist + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/gameap/minecraft-runner/internal/utils" +) + +const ( + // baseURL is the current MohistMC API. The older mohistmc.com/api/v2 still + // answers with metadata, but serves empty files for every download. + baseURL = "https://api.mohistmc.com/project" + + // ProjectMohist is the Forge + Bukkit hybrid + ProjectMohist = "mohist" + + // ProjectBanner is the Fabric + Bukkit hybrid + ProjectBanner = "banner" +) + +// Client is the MohistMC API client +type Client struct { + http *utils.HTTPClient + baseURL string +} + +// NewClient creates a new MohistMC API client +func NewClient() *Client { + return NewClientWithBaseURL(baseURL) +} + +// NewClientWithBaseURL creates a MohistMC API client for a custom API root +func NewClientWithBaseURL(url string) *Client { + return &Client{ + http: utils.NewHTTPClient(false), + baseURL: url, + } +} + +// Build describes a single build +type Build struct { + ID int `json:"id"` + FileSHA256 string `json:"file_sha256"` + BuildDate string `json:"build_date"` + Loader struct { + ForgeVersion string `json:"forge_version"` + } `json:"loader"` +} + +// GetVersions fetches the Minecraft versions of a project, in no particular order +func (c *Client) GetVersions(ctx context.Context, project string) ([]string, error) { + data, err := c.http.Get(ctx, fmt.Sprintf("%s/%s/versions", c.baseURL, project)) + if err != nil { + return nil, fmt.Errorf("failed to fetch versions: %w", err) + } + + var entries []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("failed to parse versions: %w", err) + } + + versions := make([]string, 0, len(entries)) + for _, entry := range entries { + versions = append(versions, entry.Name) + } + + return versions, nil +} + +// GetBuilds fetches the builds of a Minecraft version, newest first +func (c *Client) GetBuilds(ctx context.Context, project, version string) ([]Build, error) { + data, err := c.http.Get(ctx, fmt.Sprintf("%s/%s/%s/builds", c.baseURL, project, version)) + if err != nil { + return nil, fmt.Errorf("failed to fetch builds: %w", err) + } + + var builds []Build + if err := json.Unmarshal(data, &builds); err != nil { + return nil, fmt.Errorf("failed to parse builds: %w", err) + } + + return builds, nil +} + +// GetDownloadURL returns the JAR download URL of a build +func (c *Client) GetDownloadURL(project, version string, build int) string { + return fmt.Sprintf("%s/%s/%s/builds/%d/download", c.baseURL, project, version, build) +} diff --git a/pkg/api/neoforge/client.go b/pkg/api/neoforge/client.go new file mode 100644 index 0000000..2dbcef9 --- /dev/null +++ b/pkg/api/neoforge/client.go @@ -0,0 +1,181 @@ +package neoforge + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/gameap/minecraft-runner/internal/utils" +) + +const ( + apiBaseURL = "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged" + mavenBaseURL = "https://maven.neoforged.net/releases/net/neoforged" + + // ArtifactNeoForge is the artifact of every release since Minecraft 1.20.2 + ArtifactNeoForge = "neoforge" + + // ArtifactLegacyForge is the artifact of the 1.20.1 releases, which still + // used the Forge naming: "1.20.1-47.1.106" + ArtifactLegacyForge = "forge" + + legacyMinecraftVersion = "1.20.1" + + // NeoForge started with Minecraft 1.20, and Minecraft left the "1.x" + // numbering for year-based versions with 26.1 + firstMinorVersion = 20 + firstYearVersion = 26 +) + +// Client is the NeoForge Maven API client +type Client struct { + http *utils.HTTPClient + apiBaseURL string +} + +// NewClient creates a new NeoForge API client +func NewClient() *Client { + return NewClientWithBaseURL(apiBaseURL) +} + +// NewClientWithBaseURL creates a NeoForge API client for a custom API root +func NewClientWithBaseURL(url string) *Client { + return &Client{ + http: utils.NewHTTPClient(false), + apiBaseURL: url, + } +} + +// Release identifies an installable NeoForge release +type Release struct { + Artifact string // Maven artifact: "neoforge", or "forge" for 1.20.1 + ArtifactVersion string // Maven version, the directory name under libraries/ + Version string // Version as users know it: "21.1.251", "47.1.106" + MinecraftVersion string + Stable bool +} + +// InstallerName returns the installer file name of the release +func (r Release) InstallerName() string { + return fmt.Sprintf("%s-%s-installer.jar", r.Artifact, r.ArtifactVersion) +} + +// InstallerURL returns the installer download URL of the release +func (r Release) InstallerURL() string { + return fmt.Sprintf("%s/%s/%s/%s", mavenBaseURL, r.Artifact, r.ArtifactVersion, r.InstallerName()) +} + +// GetReleases returns every release, oldest first +func (c *Client) GetReleases(ctx context.Context) ([]Release, error) { + legacy, err := c.getVersions(ctx, ArtifactLegacyForge) + if err != nil { + return nil, err + } + + modern, err := c.getVersions(ctx, ArtifactNeoForge) + if err != nil { + return nil, err + } + + releases := make([]Release, 0, len(legacy)+len(modern)) + + for _, version := range legacy { + if release, ok := parseLegacyRelease(version); ok { + releases = append(releases, release) + } + } + + for _, version := range modern { + if release, ok := ParseRelease(version); ok { + releases = append(releases, release) + } + } + + return releases, nil +} + +// GetInstallerSHA256 fetches the checksum Maven publishes next to the installer +func (c *Client) GetInstallerSHA256(ctx context.Context, release Release) (string, error) { + data, err := c.http.Get(ctx, release.InstallerURL()+".sha256") + if err != nil { + return "", fmt.Errorf("failed to fetch installer checksum: %w", err) + } + return strings.TrimSpace(string(data)), nil +} + +func (c *Client) getVersions(ctx context.Context, artifact string) ([]string, error) { + data, err := c.http.Get(ctx, fmt.Sprintf("%s/%s", c.apiBaseURL, artifact)) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s versions: %w", artifact, err) + } + + var response struct { + Versions []string `json:"versions"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse %s versions: %w", artifact, err) + } + + return response.Versions, nil +} + +// ParseRelease derives the Minecraft version from a NeoForge version. NeoForge +// drops the leading "1." of the Minecraft version and appends its own build +// number: 1.21.1 -> 21.1.N, 1.21 -> 21.0.N. Year-based Minecraft versions keep +// all their components: 26.1.2 -> 26.1.2.N, 26.2 -> 26.2.0.N. +func ParseRelease(version string) (Release, bool) { + base, suffix, _ := strings.Cut(version, "-") + + parts := strings.Split(base, ".") + numbers := make([]int, 0, len(parts)) + for _, part := range parts { + n, err := strconv.Atoi(part) + if err != nil { + return Release{}, false + } + numbers = append(numbers, n) + } + + var minecraft string + switch { + case len(numbers) == 3 && numbers[0] >= firstMinorVersion && numbers[0] < firstYearVersion: + minecraft = joinMinecraftVersion(1, numbers[0], numbers[1]) + case len(numbers) == 4 && numbers[0] >= firstYearVersion: + minecraft = joinMinecraftVersion(numbers[0], numbers[1], numbers[2]) + default: + return Release{}, false + } + + return Release{ + Artifact: ArtifactNeoForge, + ArtifactVersion: version, + Version: version, + MinecraftVersion: minecraft, + Stable: suffix == "", + }, true +} + +// joinMinecraftVersion renders a Minecraft version without a trailing ".0" +func joinMinecraftVersion(major, minor, patch int) string { + if patch == 0 { + return fmt.Sprintf("%d.%d", major, minor) + } + return fmt.Sprintf("%d.%d.%d", major, minor, patch) +} + +func parseLegacyRelease(artifactVersion string) (Release, bool) { + version, ok := strings.CutPrefix(artifactVersion, legacyMinecraftVersion+"-") + if !ok { + return Release{}, false + } + + return Release{ + Artifact: ArtifactLegacyForge, + ArtifactVersion: artifactVersion, + Version: version, + MinecraftVersion: legacyMinecraftVersion, + Stable: !strings.Contains(version, "-"), + }, true +} diff --git a/pkg/api/neoforge/client_test.go b/pkg/api/neoforge/client_test.go new file mode 100644 index 0000000..e97e77f --- /dev/null +++ b/pkg/api/neoforge/client_test.go @@ -0,0 +1,66 @@ +package neoforge + +import "testing" + +func TestParseRelease(t *testing.T) { + tests := []struct { + version string + minecraft string + stable bool + ok bool + }{ + {"21.1.251", "1.21.1", true, true}, + {"21.0.167", "1.21", true, true}, + {"20.4.251", "1.20.4", true, true}, + {"21.11.45", "1.21.11", true, true}, + {"20.2.3-beta", "1.20.2", false, true}, + {"26.2.0.88", "26.2", true, true}, + {"26.1.2.109", "26.1.2", true, true}, + {"26.3.0.7-beta", "26.3", false, true}, + {"26.1.0.0-alpha.1+snapshot-1", "26.1", false, true}, + {"0.25w14craftmine.3-beta", "", false, false}, + {"47.1.82", "", false, false}, + {"garbage", "", false, false}, + } + + for _, tt := range tests { + release, ok := ParseRelease(tt.version) + if ok != tt.ok { + t.Errorf("ParseRelease(%q) ok = %v, want %v", tt.version, ok, tt.ok) + continue + } + if !ok { + continue + } + if release.MinecraftVersion != tt.minecraft || release.Stable != tt.stable { + t.Errorf("ParseRelease(%q) = %s stable=%v, want %s stable=%v", + tt.version, release.MinecraftVersion, release.Stable, tt.minecraft, tt.stable) + } + } +} + +func TestReleaseInstaller(t *testing.T) { + release, _ := ParseRelease("21.1.251") + + wantURL := "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.251/neoforge-21.1.251-installer.jar" + if got := release.InstallerURL(); got != wantURL { + t.Errorf("InstallerURL() = %q, want %q", got, wantURL) + } + + legacy, ok := parseLegacyRelease("1.20.1-47.1.106") + if !ok { + t.Fatal("parseLegacyRelease failed") + } + if legacy.Version != "47.1.106" || legacy.MinecraftVersion != "1.20.1" || legacy.Artifact != ArtifactLegacyForge { + t.Errorf("legacy = %+v", legacy) + } + + wantLegacyURL := "https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar" + if got := legacy.InstallerURL(); got != wantLegacyURL { + t.Errorf("legacy InstallerURL() = %q, want %q", got, wantLegacyURL) + } + + if _, ok := parseLegacyRelease("47.1.82"); ok { + t.Error("an entry without the Minecraft prefix must be skipped") + } +} diff --git a/pkg/api/paper/client.go b/pkg/api/paper/client.go index 40acdae..be12956 100644 --- a/pkg/api/paper/client.go +++ b/pkg/api/paper/client.go @@ -1,6 +1,7 @@ package paper import ( + "bytes" "context" "encoding/json" "fmt" @@ -8,79 +9,145 @@ import ( "github.com/gameap/minecraft-runner/internal/utils" ) -const baseURL = "https://fill.papermc.io/v3" +const ( + baseURL = "https://fill.papermc.io/v3" + // ChannelStable marks builds PaperMC considers production ready + ChannelStable = "STABLE" + + serverDownloadKey = "server:default" + + // latestVersionProbes limits how many versions are inspected when looking + // for the newest one that already has a stable build + latestVersionProbes = 6 +) + +// Client is the PaperMC Fill API client type Client struct { - http *utils.HTTPClient + http *utils.HTTPClient + baseURL string } +// NewClient creates a new PaperMC API client func NewClient() *Client { + return NewClientWithBaseURL(baseURL) +} + +// NewClientWithBaseURL creates a PaperMC API client for a custom API root +func NewClientWithBaseURL(url string) *Client { return &Client{ - http: utils.NewHTTPClient(false), + http: utils.NewHTTPClient(false), + baseURL: url, } } +// Project describes a PaperMC project and the versions it was built for type Project struct { - ProjectID string `json:"project_id"` - ProjectName string `json:"project_name"` - Versions []string `json:"versions"` + ID string + Name string + Versions []string // Newest first, in the order the API returns them } func (p *Project) UnmarshalJSON(data []byte) error { var raw struct { - ProjectID string `json:"project_id"` - ProjectName string `json:"project_name"` - Versions json.RawMessage `json:"versions"` + Project struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"project"` + Versions json.RawMessage `json:"versions"` } if err := json.Unmarshal(data, &raw); err != nil { return err } - p.ProjectID = raw.ProjectID - p.ProjectName = raw.ProjectName + versions, err := flattenVersionGroups(raw.Versions) + if err != nil { + return fmt.Errorf("invalid versions format: %w", err) + } - // Paper returns versions as a simple array. - var versions []string - if err := json.Unmarshal(raw.Versions, &versions); err == nil { - p.Versions = versions - return nil + p.ID = raw.Project.ID + p.Name = raw.Project.Name + p.Versions = versions + + return nil +} + +// flattenVersionGroups reads `{"1.21": ["1.21.11", ...], "1.20": [...]}` in +// document order. The API lists the newest group and version first, an order +// that decoding into a Go map would lose. +func flattenVersionGroups(raw json.RawMessage) ([]string, error) { + if len(raw) == 0 { + return nil, nil } - // Velocity returns versions grouped into objects. - var groups map[string][]string - if err := json.Unmarshal(raw.Versions, &groups); err != nil { - return fmt.Errorf("invalid versions format: %w", err) + dec := json.NewDecoder(bytes.NewReader(raw)) + + tok, err := dec.Token() + if err != nil { + return nil, err } - for _, group := range groups { - p.Versions = append(p.Versions, group...) + if tok == json.Delim('[') { + var versions []string + if err := json.Unmarshal(raw, &versions); err != nil { + return nil, err + } + return versions, nil } - return nil + if tok != json.Delim('{') { + return nil, fmt.Errorf("unexpected token %v", tok) + } + + var versions []string + for dec.More() { + if _, err := dec.Token(); err != nil { + return nil, err + } + + var group []string + if err := dec.Decode(&group); err != nil { + return nil, err + } + versions = append(versions, group...) + } + + return versions, nil } -type VersionBuilds []BuildInfo +// Download is a downloadable file of a build +type Download struct { + Name string `json:"name"` + Checksums struct { + SHA256 string `json:"sha256"` + } `json:"checksums"` + Size int64 `json:"size"` + URL string `json:"url"` +} +// BuildInfo describes a single build of a project version type BuildInfo struct { - ProjectID string `json:"project_id"` - Version string `json:"version"` - Build int `json:"build"` - ID int `json:"id"` - Time string `json:"time"` - Channel string `json:"channel"` - Downloads struct { - Application struct { - Name string `json:"name"` - SHA256 string `json:"sha256"` - URL string `json:"url"` - Size int64 `json:"size"` - } `json:"server:default"` - } `json:"downloads"` + ID int `json:"id"` + Time string `json:"time"` + Channel string `json:"channel"` + Downloads map[string]Download `json:"downloads"` } +// IsStable reports whether the build was published to the stable channel +func (b *BuildInfo) IsStable() bool { + return b.Channel == ChannelStable +} + +// ServerDownload returns the server JAR of the build +func (b *BuildInfo) ServerDownload() (Download, bool) { + d, ok := b.Downloads[serverDownloadKey] + return d, ok && d.URL != "" +} + +// GetProject fetches a project with its versions func (c *Client) GetProject(ctx context.Context, project string) (*Project, error) { - url := fmt.Sprintf("%s/projects/%s", baseURL, project) + url := fmt.Sprintf("%s/projects/%s", c.baseURL, project) data, err := c.http.Get(ctx, url) if err != nil { @@ -95,58 +162,105 @@ func (c *Client) GetProject(ctx context.Context, project string) (*Project, erro return &p, nil } -func (c *Client) GetVersionBuilds(ctx context.Context, project, version string) (*VersionBuilds, error) { - url := fmt.Sprintf("%s/projects/%s/versions/%s/builds", baseURL, project, version) +// GetVersionBuilds fetches the builds of a version, newest first +func (c *Client) GetVersionBuilds(ctx context.Context, project, version string) ([]BuildInfo, error) { + url := fmt.Sprintf("%s/projects/%s/versions/%s/builds", c.baseURL, project, version) data, err := c.http.Get(ctx, url) if err != nil { return nil, fmt.Errorf("failed to fetch version builds: %w", err) } - var vb VersionBuilds - if err := json.Unmarshal(data, &vb); err != nil { + var builds []BuildInfo + if err := json.Unmarshal(data, &builds); err != nil { return nil, fmt.Errorf("failed to parse version builds: %w", err) } - return &vb, nil + return builds, nil } +// GetBuildInfo returns a specific build of a version func (c *Client) GetBuildInfo(ctx context.Context, project, version string, build int) (*BuildInfo, error) { - vb, err := c.GetVersionBuilds(ctx, project, version) + builds, err := c.GetVersionBuilds(ctx, project, version) if err != nil { return nil, err } - for _, b := range *vb { - if b.Build == build || b.ID == build { - return &b, nil + for i := range builds { + if builds[i].ID == build { + return &builds[i], nil } } return nil, fmt.Errorf("build %d not found for version %s", build, version) } -func (c *Client) GetDownloadURL(project, version string, build int, filename string) string { - return fmt.Sprintf( - "https://fill.papermc.io/v3/projects/%s/versions/%s/builds/%d/downloads/%s", - project, - version, - build, - filename, - ) -} - +// GetLatestBuild returns the newest stable build of a version. A version that +// has no stable build yet yields its newest build of any channel, which the +// caller can tell apart with IsStable. func (c *Client) GetLatestBuild(ctx context.Context, project, version string) (*BuildInfo, error) { - vb, err := c.GetVersionBuilds(ctx, project, version) + builds, err := c.GetVersionBuilds(ctx, project, version) if err != nil { return nil, err } - for _, build := range *vb { - if build.Channel == "STABLE" { - return &build, nil + if build := newestBuild(builds, true); build != nil { + return build, nil + } + + if build := newestBuild(builds, false); build != nil { + return build, nil + } + + return nil, fmt.Errorf("no builds available for version %s", version) +} + +// GetLatestStableVersion returns the newest version that has a stable build. +// A freshly released Minecraft version only has ALPHA or BETA builds for a +// while, so the newest listed version is not necessarily a usable default. +func (c *Client) GetLatestStableVersion(ctx context.Context, project string) (string, error) { + p, err := c.GetProject(ctx, project) + if err != nil { + return "", err + } + + if len(p.Versions) == 0 { + return "", fmt.Errorf("no versions available for %s", project) + } + + probes := p.Versions + if len(probes) > latestVersionProbes { + probes = probes[:latestVersionProbes] + } + + var lastErr error + for _, version := range probes { + builds, err := c.GetVersionBuilds(ctx, project, version) + if err != nil { + lastErr = err + continue } + + if newestBuild(builds, true) != nil { + return version, nil + } + } + + if lastErr != nil { + return "", lastErr } - return nil, fmt.Errorf("no stable builds available for version %s", version) + return p.Versions[0], nil +} + +func newestBuild(builds []BuildInfo, stableOnly bool) *BuildInfo { + for i := range builds { + if stableOnly && !builds[i].IsStable() { + continue + } + if _, ok := builds[i].ServerDownload(); ok { + return &builds[i] + } + } + return nil } diff --git a/pkg/api/paper/client_test.go b/pkg/api/paper/client_test.go new file mode 100644 index 0000000..37512d5 --- /dev/null +++ b/pkg/api/paper/client_test.go @@ -0,0 +1,177 @@ +package paper + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +const projectJSON = `{ + "project": {"id": "paper", "name": "Paper"}, + "versions": { + "26.3": ["26.3", "26.3-rc-3"], + "26.2": ["26.2"], + "1.21": ["1.21.11", "1.21.10", "1.21.4"], + "1.9": ["1.9.4"] + } +}` + +func buildJSON(id int, channel string) string { + return fmt.Sprintf(`{"id": %d, "time": "2026-09-19T19:49:19Z", "channel": %q, "downloads": { + "module:cmd_kick": {"name": "cmd_kick-%d.jar", "checksums": {"sha256": "aaaa"}, "size": 1, "url": "https://example.com/cmd_kick.jar"}, + "server:default": {"name": "paper-%d.jar", "checksums": {"sha256": "sha-%d"}, "size": 2, "url": "https://example.com/paper-%d.jar"} + }}`, id, channel, id, id, id, id) +} + +func newTestServer(t *testing.T, builds map[string]string) *httptest.Server { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/projects/paper" { + fmt.Fprint(w, projectJSON) + return + } + + version := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/projects/paper/versions/"), "/builds") + body, ok := builds[version] + if !ok { + http.NotFound(w, r) + return + } + fmt.Fprint(w, body) + })) + t.Cleanup(server.Close) + + return server +} + +func TestProjectVersionsKeepAPIOrder(t *testing.T) { + server := newTestServer(t, nil) + + // Decoding the groups into a map would shuffle them, so one run proves little + for i := 0; i < 20; i++ { + project, err := NewClientWithBaseURL(server.URL).GetProject(context.Background(), "paper") + if err != nil { + t.Fatalf("GetProject: %v", err) + } + + want := []string{"26.3", "26.3-rc-3", "26.2", "1.21.11", "1.21.10", "1.21.4", "1.9.4"} + if !reflect.DeepEqual(project.Versions, want) { + t.Fatalf("Versions = %v, want %v", project.Versions, want) + } + if project.ID != "paper" || project.Name != "Paper" { + t.Fatalf("project = %q/%q", project.ID, project.Name) + } + } +} + +func TestFlattenVersionGroupsAcceptsPlainArray(t *testing.T) { + got, err := flattenVersionGroups([]byte(`["1.21", "1.20"]`)) + if err != nil { + t.Fatalf("flattenVersionGroups: %v", err) + } + if want := []string{"1.21", "1.20"}; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestGetLatestBuild(t *testing.T) { + server := newTestServer(t, map[string]string{ + "1.21.11": "[" + buildJSON(12, "BETA") + "," + buildJSON(11, "STABLE") + "," + buildJSON(10, "STABLE") + "]", + "26.3": "[" + buildJSON(25, "ALPHA") + "," + buildJSON(24, "ALPHA") + "]", + "26.2": "[]", + }) + client := NewClientWithBaseURL(server.URL) + + t.Run("the newest stable build wins over a newer beta", func(t *testing.T) { + build, err := client.GetLatestBuild(context.Background(), "paper", "1.21.11") + if err != nil { + t.Fatalf("GetLatestBuild: %v", err) + } + if build.ID != 11 || !build.IsStable() { + t.Errorf("build = %d (%s), want 11 (STABLE)", build.ID, build.Channel) + } + + download, ok := build.ServerDownload() + if !ok { + t.Fatal("no server download") + } + if download.Checksums.SHA256 != "sha-11" || download.Name != "paper-11.jar" { + t.Errorf("download = %+v", download) + } + }) + + t.Run("a version without stable builds falls back to its newest build", func(t *testing.T) { + build, err := client.GetLatestBuild(context.Background(), "paper", "26.3") + if err != nil { + t.Fatalf("GetLatestBuild: %v", err) + } + if build.ID != 25 || build.IsStable() { + t.Errorf("build = %d (%s), want 25 (ALPHA)", build.ID, build.Channel) + } + }) + + t.Run("a version without builds is an error", func(t *testing.T) { + if _, err := client.GetLatestBuild(context.Background(), "paper", "26.2"); err == nil { + t.Error("expected an error") + } + }) +} + +func TestGetBuildInfo(t *testing.T) { + server := newTestServer(t, map[string]string{ + "1.21.11": "[" + buildJSON(12, "BETA") + "," + buildJSON(11, "STABLE") + "]", + }) + client := NewClientWithBaseURL(server.URL) + + build, err := client.GetBuildInfo(context.Background(), "paper", "1.21.11", 12) + if err != nil { + t.Fatalf("GetBuildInfo: %v", err) + } + if build.ID != 12 { + t.Errorf("build = %d, want 12", build.ID) + } + + if _, err := client.GetBuildInfo(context.Background(), "paper", "1.21.11", 99); err == nil { + t.Error("expected an error for an unknown build") + } +} + +func TestGetLatestStableVersion(t *testing.T) { + t.Run("versions that only have unstable builds are skipped", func(t *testing.T) { + server := newTestServer(t, map[string]string{ + "26.3": "[" + buildJSON(25, "ALPHA") + "]", + "26.3-rc-3": "[" + buildJSON(3, "ALPHA") + "]", + "26.2": "[" + buildJSON(125, "STABLE") + "]", + "1.21.11": "[" + buildJSON(11, "STABLE") + "]", + }) + + version, err := NewClientWithBaseURL(server.URL).GetLatestStableVersion(context.Background(), "paper") + if err != nil { + t.Fatalf("GetLatestStableVersion: %v", err) + } + if version != "26.2" { + t.Errorf("version = %q, want 26.2", version) + } + }) + + t.Run("without any stable build the newest version is used", func(t *testing.T) { + builds := map[string]string{} + for _, v := range []string{"26.3", "26.3-rc-3", "26.2", "1.21.11", "1.21.10", "1.21.4", "1.9.4"} { + builds[v] = "[" + buildJSON(1, "BETA") + "]" + } + server := newTestServer(t, builds) + + version, err := NewClientWithBaseURL(server.URL).GetLatestStableVersion(context.Background(), "paper") + if err != nil { + t.Fatalf("GetLatestStableVersion: %v", err) + } + if version != "26.3" { + t.Errorf("version = %q, want 26.3", version) + } + }) +} diff --git a/pkg/api/purpur/client.go b/pkg/api/purpur/client.go new file mode 100644 index 0000000..049b7cb --- /dev/null +++ b/pkg/api/purpur/client.go @@ -0,0 +1,103 @@ +package purpur + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/gameap/minecraft-runner/internal/utils" +) + +const ( + baseURL = "https://api.purpurmc.org/v2/purpur" + + resultSuccess = "SUCCESS" +) + +// Client is the Purpur API client +type Client struct { + http *utils.HTTPClient + baseURL string +} + +// NewClient creates a new Purpur API client +func NewClient() *Client { + return NewClientWithBaseURL(baseURL) +} + +// NewClientWithBaseURL creates a Purpur API client for a custom API root +func NewClientWithBaseURL(url string) *Client { + return &Client{ + http: utils.NewHTTPClient(false), + baseURL: url, + } +} + +// Project lists the Minecraft versions Purpur was built for, oldest first +type Project struct { + Versions []string `json:"versions"` + Metadata struct { + Current string `json:"current"` + } `json:"metadata"` +} + +// Version lists the builds of a Minecraft version, oldest first +type Version struct { + Version string `json:"version"` + Builds struct { + Latest string `json:"latest"` + All []string `json:"all"` + } `json:"builds"` +} + +// Build describes a single build +type Build struct { + Build string `json:"build"` + Result string `json:"result"` + MD5 string `json:"md5"` +} + +// IsSuccessful reports whether the build produced a JAR +func (b *Build) IsSuccessful() bool { + return b.Result == resultSuccess +} + +// GetProject fetches the supported Minecraft versions +func (c *Client) GetProject(ctx context.Context) (*Project, error) { + var project Project + if err := c.get(ctx, c.baseURL, &project); err != nil { + return nil, fmt.Errorf("failed to fetch project: %w", err) + } + return &project, nil +} + +// GetVersion fetches the builds of a Minecraft version +func (c *Client) GetVersion(ctx context.Context, version string) (*Version, error) { + var v Version + if err := c.get(ctx, fmt.Sprintf("%s/%s", c.baseURL, version), &v); err != nil { + return nil, fmt.Errorf("failed to fetch version: %w", err) + } + return &v, nil +} + +// GetBuild fetches a build; "latest" is accepted as a build number +func (c *Client) GetBuild(ctx context.Context, version, build string) (*Build, error) { + var b Build + if err := c.get(ctx, fmt.Sprintf("%s/%s/%s", c.baseURL, version, build), &b); err != nil { + return nil, fmt.Errorf("failed to fetch build: %w", err) + } + return &b, nil +} + +// GetDownloadURL returns the JAR download URL of a build +func (c *Client) GetDownloadURL(version, build string) string { + return fmt.Sprintf("%s/%s/%s/download", c.baseURL, version, build) +} + +func (c *Client) get(ctx context.Context, url string, target interface{}) error { + data, err := c.http.Get(ctx, url) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} diff --git a/pkg/api/quilt/client.go b/pkg/api/quilt/client.go new file mode 100644 index 0000000..9f00b7f --- /dev/null +++ b/pkg/api/quilt/client.go @@ -0,0 +1,123 @@ +package quilt + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/gameap/minecraft-runner/internal/utils" +) + +const baseURL = "https://meta.quiltmc.org/v3" + +// Client is the Quilt meta API client +type Client struct { + http *utils.HTTPClient + baseURL string +} + +// NewClient creates a new Quilt API client +func NewClient() *Client { + return NewClientWithBaseURL(baseURL) +} + +// NewClientWithBaseURL creates a Quilt API client for a custom API root +func NewClientWithBaseURL(url string) *Client { + return &Client{ + http: utils.NewHTTPClient(false), + baseURL: url, + } +} + +// GameVersion represents a Minecraft version supported by Quilt +type GameVersion struct { + Version string `json:"version"` + Stable bool `json:"stable"` +} + +// LoaderVersion represents a Quilt loader version +type LoaderVersion struct { + Version string `json:"version"` +} + +// IsStable reports whether the loader version is a release rather than a beta +func (v LoaderVersion) IsStable() bool { + return !strings.Contains(v.Version, "-") +} + +// InstallerVersion represents a Quilt installer version. The hashes the meta +// API lists are not used: they can lag behind the file Maven actually serves. +type InstallerVersion struct { + Version string `json:"version"` + URL string `json:"url"` +} + +// GetGameVersions fetches the supported Minecraft versions, newest first +func (c *Client) GetGameVersions(ctx context.Context) ([]GameVersion, error) { + var versions []GameVersion + if err := c.get(ctx, "/versions/game", &versions); err != nil { + return nil, fmt.Errorf("failed to fetch game versions: %w", err) + } + return versions, nil +} + +// GetLoaderVersions fetches the loader versions, newest first +func (c *Client) GetLoaderVersions(ctx context.Context) ([]LoaderVersion, error) { + var versions []LoaderVersion + if err := c.get(ctx, "/versions/loader", &versions); err != nil { + return nil, fmt.Errorf("failed to fetch loader versions: %w", err) + } + return versions, nil +} + +// GetLatestInstaller fetches the newest installer +func (c *Client) GetLatestInstaller(ctx context.Context) (*InstallerVersion, error) { + var versions []InstallerVersion + if err := c.get(ctx, "/versions/installer", &versions); err != nil { + return nil, fmt.Errorf("failed to fetch installer versions: %w", err) + } + + if len(versions) == 0 { + return nil, fmt.Errorf("no installer versions available") + } + + return &versions[0], nil +} + +// GetInstallerSHA256 fetches the checksum Maven publishes next to the installer +func (c *Client) GetInstallerSHA256(ctx context.Context, installer *InstallerVersion) (string, error) { + data, err := c.http.Get(ctx, installer.URL+".sha256") + if err != nil { + return "", fmt.Errorf("failed to fetch installer checksum: %w", err) + } + return strings.TrimSpace(string(data)), nil +} + +// GetLatestStableLoader returns the newest loader release, or the newest beta if there is none +func (c *Client) GetLatestStableLoader(ctx context.Context) (*LoaderVersion, error) { + versions, err := c.GetLoaderVersions(ctx) + if err != nil { + return nil, err + } + + for i := range versions { + if versions[i].IsStable() { + return &versions[i], nil + } + } + + if len(versions) > 0 { + return &versions[0], nil + } + + return nil, fmt.Errorf("no loader versions available") +} + +func (c *Client) get(ctx context.Context, path string, target interface{}) error { + data, err := c.http.Get(ctx, c.baseURL+path) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} diff --git a/pkg/api/sponge/client.go b/pkg/api/sponge/client.go new file mode 100644 index 0000000..85a5f89 --- /dev/null +++ b/pkg/api/sponge/client.go @@ -0,0 +1,172 @@ +package sponge + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/gameap/minecraft-runner/internal/utils" +) + +const ( + baseURL = "https://dl-api.spongepowered.org/v2/groups/org.spongepowered/artifacts" + + // ArtifactVanilla is the standalone Sponge server + ArtifactVanilla = "spongevanilla" + + // universalClassifier marks the runnable server JAR among the assets of a version + universalClassifier = "universal" + + versionsPageSize = 25 +) + +// Client is the Sponge downloads API client +type Client struct { + http *utils.HTTPClient + baseURL string +} + +// NewClient creates a new Sponge API client +func NewClient() *Client { + return NewClientWithBaseURL(baseURL) +} + +// NewClientWithBaseURL creates a Sponge API client for a custom API root +func NewClientWithBaseURL(url string) *Client { + return &Client{ + http: utils.NewHTTPClient(false), + baseURL: url, + } +} + +// Version is a published build of an artifact +type Version struct { + Version string + Recommended bool +} + +// Asset is a downloadable file of a version +type Asset struct { + Classifier string `json:"classifier"` + Extension string `json:"extension"` + DownloadURL string `json:"downloadUrl"` + SHA1 string `json:"sha1"` + MD5 string `json:"md5"` +} + +// GetMinecraftVersions fetches the Minecraft versions an artifact was built for, newest first +func (c *Client) GetMinecraftVersions(ctx context.Context, artifact string) ([]string, error) { + data, err := c.http.Get(ctx, fmt.Sprintf("%s/%s", c.baseURL, artifact)) + if err != nil { + return nil, fmt.Errorf("failed to fetch artifact: %w", err) + } + + var response struct { + Tags struct { + Minecraft []string `json:"minecraft"` + } `json:"tags"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse artifact: %w", err) + } + + return response.Tags.Minecraft, nil +} + +// GetVersions fetches the builds for a Minecraft version, newest first +func (c *Client) GetVersions(ctx context.Context, artifact, mcVersion string, recommendedOnly bool) ([]Version, error) { + query := url.Values{} + query.Set("tags", "minecraft:"+mcVersion) + query.Set("limit", fmt.Sprint(versionsPageSize)) + if recommendedOnly { + query.Set("recommended", "true") + } + + data, err := c.http.Get(ctx, fmt.Sprintf("%s/%s/versions?%s", c.baseURL, artifact, query.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to fetch versions: %w", err) + } + + var response struct { + Artifacts json.RawMessage `json:"artifacts"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse versions: %w", err) + } + + versions, err := parseOrderedVersions(response.Artifacts) + if err != nil { + return nil, fmt.Errorf("failed to parse versions: %w", err) + } + + return versions, nil +} + +// parseOrderedVersions reads `{"": {"recommended": bool}, ...}` in +// document order. The API lists the newest build first, an order that decoding +// into a Go map would lose. +func parseOrderedVersions(raw json.RawMessage) ([]Version, error) { + if len(raw) == 0 { + return nil, nil + } + + dec := json.NewDecoder(bytes.NewReader(raw)) + + tok, err := dec.Token() + if err != nil { + return nil, err + } + if tok != json.Delim('{') { + return nil, fmt.Errorf("unexpected token %v", tok) + } + + var versions []Version + for dec.More() { + key, err := dec.Token() + if err != nil { + return nil, err + } + + name, ok := key.(string) + if !ok { + return nil, fmt.Errorf("unexpected key %v", key) + } + + var entry struct { + Recommended bool `json:"recommended"` + } + if err := dec.Decode(&entry); err != nil { + return nil, err + } + + versions = append(versions, Version{Version: name, Recommended: entry.Recommended}) + } + + return versions, nil +} + +// GetServerAsset fetches the runnable server JAR of a version +func (c *Client) GetServerAsset(ctx context.Context, artifact, version string) (*Asset, error) { + data, err := c.http.Get(ctx, fmt.Sprintf("%s/%s/versions/%s", c.baseURL, artifact, version)) + if err != nil { + return nil, fmt.Errorf("failed to fetch version: %w", err) + } + + var response struct { + Assets []Asset `json:"assets"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse version: %w", err) + } + + for i := range response.Assets { + asset := &response.Assets[i] + if asset.Classifier == universalClassifier && asset.Extension == "jar" { + return asset, nil + } + } + + return nil, fmt.Errorf("%s %s has no %s JAR", artifact, version, universalClassifier) +} diff --git a/pkg/api/sponge/client_test.go b/pkg/api/sponge/client_test.go new file mode 100644 index 0000000..1cdc796 --- /dev/null +++ b/pkg/api/sponge/client_test.go @@ -0,0 +1,42 @@ +package sponge + +import ( + "reflect" + "testing" +) + +func TestParseOrderedVersions(t *testing.T) { + raw := []byte(`{ + "1.21.1-12.0.4-RC2706": {"recommended": false, "tagValues": {"api": "12.0.4"}}, + "1.21.1-12.0.3": {"recommended": true}, + "1.21.1-12.0.2": {"recommended": true}, + "1.21.1-12.0.10-RC1": {"recommended": false} + }`) + + want := []Version{ + {Version: "1.21.1-12.0.4-RC2706"}, + {Version: "1.21.1-12.0.3", Recommended: true}, + {Version: "1.21.1-12.0.2", Recommended: true}, + {Version: "1.21.1-12.0.10-RC1"}, + } + + // Decoding into a map would shuffle the builds, so one run proves little + for i := 0; i < 20; i++ { + got, err := parseOrderedVersions(raw) + if err != nil { + t.Fatalf("parseOrderedVersions: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestParseOrderedVersionsEmpty(t *testing.T) { + for _, raw := range []string{"", "{}"} { + got, err := parseOrderedVersions([]byte(raw)) + if err != nil || len(got) != 0 { + t.Errorf("parseOrderedVersions(%q) = %v, %v", raw, got, err) + } + } +} From e5e7ecdfbaa628f39d1d7d4538e575ac997683cc Mon Sep 17 00:00:00 2001 From: et-nik Date: Sun, 20 Sep 2026 04:06:44 +0200 Subject: [PATCH 2/2] review --- .gitignore | 4 +++- cmd/mcrun/root.go | 2 +- cmd/mcrun/run.go | 6 ++--- internal/config/config.go | 7 +++--- internal/config/config_test.go | 42 +++++++++++++++++++++++++++++++--- internal/java/installer.go | 5 +++- internal/server/runner.go | 4 +++- internal/utils/env.go | 23 +++++++++++++++++-- internal/utils/env_test.go | 33 ++++++++++++++++++++++++++ 9 files changed, 111 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 7a1a612..ad165a9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,7 @@ /versions/ /whitelist.json /world/ -/.mcrun-state.json + +# Names no source file shares, ignored wherever a test server was run +.mcrun-state.json *.bak diff --git a/cmd/mcrun/root.go b/cmd/mcrun/root.go index 568ec3c..4af46f9 100644 --- a/cmd/mcrun/root.go +++ b/cmd/mcrun/root.go @@ -94,7 +94,7 @@ func applyConfigDefaults(cmd *cobra.Command) { if !flags.Changed("mod-version") && cfg.Defaults.ModVersion != "" { modVersion = cfg.Defaults.ModVersion } - if !flags.Changed("java") && cfg.Java.Version != 0 { + if !flags.Changed("java") && cfg.Java.Version > 0 { javaVersion = cfg.Java.Version } if !flags.Changed("java-path") && cfg.Java.Path != "" { diff --git a/cmd/mcrun/run.go b/cmd/mcrun/run.go index e5ab52f..7a2a547 100644 --- a/cmd/mcrun/run.go +++ b/cmd/mcrun/run.go @@ -76,13 +76,13 @@ func applyNetworkDefaults(cmd *cobra.Command) { if !flags.Changed("ip") && network.IP != "" { runIP = network.IP } - if !flags.Changed("port") && network.Port != 0 { + if !flags.Changed("port") && network.Port > 0 { runPort = network.Port } - if !flags.Changed("query-port") && network.QueryPort != 0 { + if !flags.Changed("query-port") && network.QueryPort > 0 { runQueryPort = network.QueryPort } - if !flags.Changed("rcon-port") && network.RconPort != 0 { + if !flags.Changed("rcon-port") && network.RconPort > 0 { runRconPort = network.RconPort } if !flags.Changed("rcon-password") && network.RconPassword != "" { diff --git a/internal/config/config.go b/internal/config/config.go index 4c3c8b1..a6f76b4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -178,15 +178,16 @@ func mergeLocalConfig(cfg *Config, local *ServerLocalConfig) { } // A path next to a version pins the binary of that Java version; a path on - // its own is the binary to run the server with + // its own is the binary to run the server with. A version that is not + // positive counts as not set. switch { - case local.Java.Version != 0 && local.Java.Path != "": + case local.Java.Version > 0 && local.Java.Path != "": if cfg.Java.Paths == nil { cfg.Java.Paths = make(map[int]string) } cfg.Java.Paths[local.Java.Version] = local.Java.Path cfg.Java.Version = local.Java.Version - case local.Java.Version != 0: + case local.Java.Version > 0: cfg.Java.Version = local.Java.Version case local.Java.Path != "": cfg.Java.Path = local.Java.Path diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ccb2348..35caee7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -59,7 +59,7 @@ server: func TestLoadJavaPathVariants(t *testing.T) { t.Run("a path with a version pins that version", func(t *testing.T) { dir := t.TempDir() - os.WriteFile(filepath.Join(dir, ".mcrun.yaml"), []byte("java:\n version: 21\n path: /opt/jdk21/bin/java\n"), 0644) + writeLocalConfig(t, dir, "java:\n version: 21\n path: /opt/jdk21/bin/java\n") cfg, err := Load(filepath.Join(dir, "none.yaml"), dir) if err != nil { @@ -72,7 +72,7 @@ func TestLoadJavaPathVariants(t *testing.T) { t.Run("a path on its own is the binary to use", func(t *testing.T) { dir := t.TempDir() - os.WriteFile(filepath.Join(dir, ".mcrun.yaml"), []byte("java:\n path: /opt/java/bin/java\n"), 0644) + writeLocalConfig(t, dir, "java:\n path: /opt/java/bin/java\n") cfg, err := Load(filepath.Join(dir, "none.yaml"), dir) if err != nil { @@ -84,9 +84,45 @@ func TestLoadJavaPathVariants(t *testing.T) { }) } +func TestLoadIgnoresNegativeJavaVersion(t *testing.T) { + t.Run("on its own it counts as not set", func(t *testing.T) { + dir := t.TempDir() + writeLocalConfig(t, dir, "java:\n version: -5\n") + + cfg, err := Load(filepath.Join(dir, "none.yaml"), dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Java.Version != 0 || len(cfg.Java.Paths) != 0 { + t.Errorf("java = %+v, a negative version must not be applied", cfg.Java) + } + }) + + t.Run("next to a path the path is still used", func(t *testing.T) { + dir := t.TempDir() + writeLocalConfig(t, dir, "java:\n version: -5\n path: /opt/java/bin/java\n") + + cfg, err := Load(filepath.Join(dir, "none.yaml"), dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Java.Path != "/opt/java/bin/java" || cfg.Java.Version != 0 || len(cfg.Java.Paths) != 0 { + t.Errorf("java = %+v", cfg.Java) + } + }) +} + +func writeLocalConfig(t *testing.T, dir, content string) { + t.Helper() + + if err := os.WriteFile(filepath.Join(dir, ".mcrun.yaml"), []byte(content), 0644); err != nil { + t.Fatalf("failed to write the .mcrun.yaml fixture: %v", err) + } +} + func TestLoadRejectsBrokenLocalConfig(t *testing.T) { dir := t.TempDir() - os.WriteFile(filepath.Join(dir, ".mcrun.yaml"), []byte("java: [not, a, mapping\n"), 0644) + writeLocalConfig(t, dir, "java: [not, a, mapping\n") if _, err := Load(filepath.Join(dir, "none.yaml"), dir); err == nil { t.Error("a malformed .mcrun.yaml must be reported, not silently ignored") diff --git a/internal/java/installer.go b/internal/java/installer.go index a2699fe..7ce1667 100644 --- a/internal/java/installer.go +++ b/internal/java/installer.go @@ -65,9 +65,12 @@ func (i *Installer) Install(ctx context.Context, version int, opts InstallOption return nil, fmt.Errorf("failed to create temporary archive: %w", err) } archivePath := archive.Name() - archive.Close() defer os.Remove(archivePath) + if err := archive.Close(); err != nil { + return nil, fmt.Errorf("failed to close temporary archive %s: %w", archivePath, err) + } + if err := i.http.DownloadFile(ctx, asset.Binary.Package.Link, archivePath); err != nil { return nil, fmt.Errorf("failed to download Java: %w", err) } diff --git a/internal/server/runner.go b/internal/server/runner.go index decbef4..115e747 100644 --- a/internal/server/runner.go +++ b/internal/server/runner.go @@ -96,7 +96,9 @@ func (r *Runner) Run(ctx context.Context, opts RunOptions) error { inst, javaInst, err := r.prepare(ctx, provider, opts) if err != nil { if ctx.Err() != nil { - return nil + // The error is the interrupted download or installer: a stop that was + // requested before the server came up is not a failure to report + return nil //nolint:nilerr // intentional, see above } return err } diff --git a/internal/utils/env.go b/internal/utils/env.go index fbba383..b872b0f 100644 --- a/internal/utils/env.go +++ b/internal/utils/env.go @@ -46,13 +46,32 @@ func javaEnvironment(environ []string, javaBinary string) []string { } env = append(env, "PATH="+path) - if filepath.Base(binDir) == "bin" { - env = append(env, "JAVA_HOME="+filepath.Dir(binDir)) + if javaHome := javaHomeOf(javaBinary); javaHome != "" { + env = append(env, "JAVA_HOME="+javaHome) } return env } +// javaHomeOf returns the Java home a binary belongs to, or "" when it cannot be +// told. A path given by the user is often a symlink - /usr/bin/java points +// through /etc/alternatives into the real JDK - and taking its directory at face +// value would announce /usr as the Java home. PATH keeps the selected path, so +// "java" still resolves to the very binary that was chosen. +func javaHomeOf(javaBinary string) string { + resolved, err := filepath.EvalSymlinks(javaBinary) + if err != nil { + resolved = javaBinary + } + + binDir := filepath.Dir(resolved) + if filepath.Base(binDir) != "bin" { + return "" + } + + return filepath.Dir(binDir) +} + // envKeyEquals compares environment variable names, which Windows treats case-insensitively func envKeyEquals(key, name string) bool { if runtime.GOOS == "windows" { diff --git a/internal/utils/env_test.go b/internal/utils/env_test.go index 8adb09f..2e959a8 100644 --- a/internal/utils/env_test.go +++ b/internal/utils/env_test.go @@ -45,6 +45,39 @@ func TestJavaEnvironment(t *testing.T) { } }) + t.Run("JAVA_HOME follows a symlinked binary to the real installation", func(t *testing.T) { + root := t.TempDir() + + realBin := filepath.Join(root, "lib", "jvm", "jdk-17", "bin") + linkBin := filepath.Join(root, "usr", "bin") + for _, dir := range []string{realBin, linkBin} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(realBin, "java"), nil, 0755); err != nil { + t.Fatal(err) + } + + link := filepath.Join(linkBin, "java") + if err := os.Symlink(filepath.Join(realBin, "java"), link); err != nil { + t.Skipf("symlinks are not available: %v", err) + } + + realHome, err := filepath.EvalSymlinks(filepath.Dir(realBin)) + if err != nil { + t.Fatal(err) + } + + got := javaEnvironment([]string{"PATH=/usr/bin"}, link) + + // The selected binary stays the one found through PATH; only the home is resolved + want := []string{"PATH=" + linkBin + sep + "/usr/bin", "JAVA_HOME=" + realHome} + if !reflect.DeepEqual(got, want) { + t.Errorf("env = %v, want %v", got, want) + } + }) + t.Run("a binary outside a bin directory sets no JAVA_HOME", func(t *testing.T) { got := javaEnvironment([]string{"PATH=/usr/bin"}, filepath.Join(string(filepath.Separator)+"custom", "java"))