From c1c7d320d7831daedcb14bdb3512e4a57ec654fa Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 4 Aug 2026 21:55:47 +0200 Subject: [PATCH] feat: add native and Docker runtime distributions --- .dockerignore | 10 ++ .github/workflows/build.yml | 28 +++- Dockerfile | 37 +++++ README.md | 10 ++ SECURITY.md | 2 +- docker/rumble.ps1 | 45 ++++++ docker/rumble.sh | 38 +++++ .../robocode/rumble/client/RumbleClient.java | 32 +++- .../rumble/client/RumbleSnapshot.java | 4 +- .../rumble/client/RumbleSnapshotParser.java | 8 +- .../client/RuntimePrerequisiteChecker.java | 151 ++++++++++++++++++ .../resources/runtime-versions.properties | 5 + .../rumble/client/BotCachePreparerTest.java | 2 +- .../client/RankedBattleSelectorTest.java | 2 +- .../rumble/client/RumbleClientTest.java | 25 +++ .../rumble/client/RumbleSynchronizerTest.java | 30 ++++ .../RuntimePrerequisiteCheckerTest.java | 49 ++++++ 17 files changed, 470 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker/rumble.ps1 create mode 100755 docker/rumble.sh create mode 100644 src/main/java/dev/robocode/rumble/client/RuntimePrerequisiteChecker.java create mode 100644 src/main/resources/runtime-versions.properties create mode 100644 src/test/java/dev/robocode/rumble/client/RuntimePrerequisiteCheckerTest.java diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d0d5332 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.gradle +.idea +build +cache +evidence +journal +out +rumble-client.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8859f71..5742535 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,10 @@ permissions: jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v5 @@ -18,4 +21,25 @@ jobs: distribution: temurin java-version: 17 cache: gradle - - run: ./gradlew build + - if: runner.os != 'Windows' + run: ./gradlew build + - if: runner.os == 'Windows' + run: .\gradlew.bat build + - if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: native-distributions + path: build/distributions/* + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: rumble-client:test + - run: docker run --rm --read-only --network none --tmpfs /tmp:rw,nosuid,nodev,size=1g --cap-drop ALL --security-opt no-new-privileges rumble-client:test --check-runtimes + - run: test "$(docker run --rm --entrypoint id rumble-client:test -u)" != "0" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..35510cf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +FROM gradle:8.14.3-jdk17 AS build +WORKDIR /workspace +COPY gradle gradle +COPY gradlew gradlew.bat build.gradle.kts settings.gradle.kts gradle.properties ./ +COPY src src +RUN ./gradlew --no-daemon installDist + +FROM ubuntu:24.04 +ARG TARGETARCH +COPY src/main/resources/runtime-versions.properties /tmp/runtime-versions.properties + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \ + ca-certificates curl dotnet-sdk-8.0 git openjdk-17-jdk-headless python3.12 xz-utils \ + && NODE_VERSION="$(sed -n 's/^nodeInstaller=//p' /tmp/runtime-versions.properties)" \ + && test -n "$NODE_VERSION" \ + && case "$TARGETARCH" in amd64) node_arch=x64 ;; arm64) node_arch=arm64 ;; *) exit 1 ;; esac \ + && node_archive="node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" \ + && curl --fail --location --proto '=https' --tlsv1.2 \ + "https://nodejs.org/dist/v${NODE_VERSION}/${node_archive}" --output "/tmp/${node_archive}" \ + && curl --fail --location --proto '=https' --tlsv1.2 \ + "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" --output /tmp/SHASUMS256.txt \ + && grep " ${node_archive}$" /tmp/SHASUMS256.txt | (cd /tmp && sha256sum --check --strict -) \ + && tar --extract --xz --file "/tmp/${node_archive}" --directory /usr/local --strip-components=1 \ + && rm -rf /var/lib/apt/lists/* /tmp/* \ + && groupadd --gid 10001 rumble \ + && useradd --uid 10001 --gid rumble --no-create-home --home-dir /tmp --shell /usr/sbin/nologin rumble + +COPY --from=build --chown=10001:10001 /workspace/build/install/rumble-client /opt/rumble-client + +ENV HOME=/tmp +WORKDIR /work +USER 10001:10001 +ENTRYPOINT ["/opt/rumble-client/bin/rumble-client"] +CMD ["--help"] diff --git a/README.md b/README.md index cd98ff6..3a3380d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ The Rumble Client runs local Tank Royale battles against the published Rumble ca The project is currently being built under [Tank Royale change CH-012](https://github.com/robocode-dev/tank-royale/tree/main/changes/CH-012-create-rumble-client). The public contracts are owned by [CAP-016](https://github.com/robocode-dev/tank-royale/tree/main/docs/capabilities/CAP-016-rumble-client). +Contributors may use the supported native distribution or the recommended Docker image. Docker supplies the complete Java, .NET, Python, and Node.js environment and is the isolation boundary for reviewed bot code; direct execution uses the same client contracts but runs bots with the contributor's host permissions. Production images are published only after Tank Royale releases the engine contracts required by ranked Rumble battles. + ## Build Install JDK 17, then run: @@ -12,12 +14,20 @@ Install JDK 17, then run: ./gradlew build ``` +The build produces native ZIP and TAR archives under `build/distributions/`. Run `./gradlew run --args="--check-runtimes"` to verify the required Java 17, .NET 8 SDK, Python 3.12, and Node.js 22 installations; the check never installs or changes them. + The client validates configuration and can synchronize the current ranked input snapshot. Run `./gradlew run --args="--validate-config"` to check local settings, then run `./gradlew run --args="--sync"` to resolve the canonical data repository, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. Ranked battle selection uses a recorded random seed, prioritizes under-sampled pairings involving `myBots`, and falls back to distinct active catalog bots when no advice is available. Battle Runner execution, persistence, issue-ops transport, and the runtime container are added in subsequent CH-012 tasks. ## Configuration Copy `rumble-client.example.json` to `rumble-client.json`. Ranked mode requires a registered `clientId`; practice mode may omit it. The optional `workDirectory` selects the local cache, journal, and replay-evidence root and defaults to `.rumble-client` beside the configuration file. Do not commit the resulting file or any token. A submission token is supplied at runtime only when issue-ops support is available. +## Docker development image + +Docker Engine or Docker Desktop is required. Build the current non-published development image with `docker build --tag rumble-client:dev .`, then use `docker/rumble.sh` or `docker/rumble.ps1` to validate configuration, check the bundled runtimes, or synchronize the ranked snapshot. Docker execution uses the default `.rumble-client` work directory beside the configuration file. The launchers expose only that configuration file and state directory to the container and apply a read-only root filesystem, dropped capabilities, finite resource limits, and no external network for the runtime check. + +Battle and submission commands remain unavailable until their later CH-012 implementation tasks land. Their Docker launcher phases will run battles offline without a submission credential and submission online without starting bot code. + ## Contributing Read [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and [GOVERNANCE.md](GOVERNANCE.md) before opening a pull request. diff --git a/SECURITY.md b/SECURITY.md index 47ea2b6..d363800 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,4 +2,4 @@ Report a vulnerability privately to the Tank Royale maintainers rather than opening a public issue. Do not include credentials, replay evidence, journal contents, or unpublished bot sources in the report. -The client treats all remote catalog, projection, and submission data as untrusted input. Tokens are supplied only at runtime and must have no repository-content write permission. +The client treats all remote catalog, projection, and submission data as untrusted input. Tokens are supplied only to the submission phase and must have no repository-content write permission. The Docker battle phase receives neither external network access nor a submission token. Native execution is supported but runs reviewed bot code with the contributor's host permissions and does not provide Docker isolation. diff --git a/docker/rumble.ps1 b/docker/rumble.ps1 new file mode 100644 index 0000000..4e35e3c --- /dev/null +++ b/docker/rumble.ps1 @@ -0,0 +1,45 @@ +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateSet('validate', 'runtimes', 'sync')] + [string] $Command, + + [Parameter(Position = 1)] + [string] $Configuration = 'rumble-client.json', + + [Parameter(Position = 2)] + [string] $Image = 'rumble-client:dev' +) + +$clientArguments = switch ($Command) { + 'validate' { @('--validate-config', '/work/rumble-client.json') } + 'runtimes' { @('--check-runtimes') } + 'sync' { @('--sync', '/work/rumble-client.json') } +} + +$dockerArguments = @( + 'run', '--rm', '--read-only', '--tmpfs', '/tmp:rw,nosuid,nodev,size=1g', + '--cpus', '4', '--memory', '8g', '--pids-limit', '512', + '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges' +) +if ($IsLinux -or $IsMacOS) { + $userId = (& id -u).Trim() + $groupId = (& id -g).Trim() + $dockerArguments += @('--user', "${userId}:${groupId}") +} +if ($Command -eq 'runtimes') { + $dockerArguments += @('--network', 'none') +} else { + $configurationPath = (Resolve-Path -LiteralPath $Configuration).Path + $configurationDirectory = Split-Path -Parent $configurationPath + $stateDirectory = Join-Path $configurationDirectory '.rumble-client' + New-Item -ItemType Directory -Force -Path $stateDirectory | Out-Null + $dockerArguments += @( + '--mount', "type=bind,source=$configurationPath,target=/work/rumble-client.json,readonly", + '--mount', "type=bind,source=$stateDirectory,target=/work/.rumble-client" + ) +} +$dockerArguments += $Image +$dockerArguments += $clientArguments + +& docker @dockerArguments +exit $LASTEXITCODE diff --git a/docker/rumble.sh b/docker/rumble.sh new file mode 100755 index 0000000..d314f30 --- /dev/null +++ b/docker/rumble.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env sh +set -eu + +usage() { + echo "Usage: docker/rumble.sh [config-path] [image]" >&2 + exit 2 +} + +command_name="${1:-}" +config_path="${2:-rumble-client.json}" +image="${3:-rumble-client:dev}" + +case "$command_name" in + validate) client_arguments="--validate-config /work/rumble-client.json" ;; + runtimes) client_arguments="--check-runtimes" ;; + sync) client_arguments="--sync /work/rumble-client.json" ;; + *) usage ;; +esac + +if [ "$command_name" = "runtimes" ]; then + exec docker run --rm --read-only --network none --tmpfs /tmp:rw,nosuid,nodev,size=1g \ + --user "$(id -u):$(id -g)" \ + --cpus 4 --memory 8g --pids-limit 512 --cap-drop ALL --security-opt no-new-privileges \ + "$image" --check-runtimes +fi + +config_directory=$(CDPATH= cd -- "$(dirname -- "$config_path")" && pwd) +config_name=$(basename -- "$config_path") +absolute_config="$config_directory/$config_name" +state_directory="$config_directory/.rumble-client" +mkdir -p "$state_directory" + +exec docker run --rm --read-only --tmpfs /tmp:rw,nosuid,nodev,size=1g \ + --user "$(id -u):$(id -g)" \ + --cpus 4 --memory 8g --pids-limit 512 --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=$absolute_config,target=/work/rumble-client.json,readonly" \ + --mount "type=bind,source=$state_directory,target=/work/.rumble-client" \ + "$image" $client_arguments diff --git a/src/main/java/dev/robocode/rumble/client/RumbleClient.java b/src/main/java/dev/robocode/rumble/client/RumbleClient.java index d5371c0..c6c6c5f 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleClient.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleClient.java @@ -10,6 +10,7 @@ public final class RumbleClient { private static final String HELP_OPTION = "--help"; private static final String VALIDATE_CONFIG_OPTION = "--validate-config"; + private static final String CHECK_RUNTIMES_OPTION = "--check-runtimes"; private static final String SYNCHRONIZE_OPTION = "--sync"; private static final Path DEFAULT_CONFIGURATION_PATH = Path.of("rumble-client.json"); @@ -32,14 +33,25 @@ public static void main(final String[] arguments) { } static void run(final String[] arguments, final PrintStream output) throws IOException { + run(arguments, output, new RuntimePrerequisiteChecker()::check); + } + + static void run(final String[] arguments, final PrintStream output, final RuntimeCheck runtimeCheck) + throws IOException { if (arguments.length == 0 || hasOnlyArgument(arguments, HELP_OPTION)) { printHelp(output); return; } + if (hasOnlyArgument(arguments, CHECK_RUNTIMES_OPTION)) { + printRuntimeReport(runtimeCheck.check(), output); + return; + } + if (arguments.length > 2 || (!arguments[0].equals(VALIDATE_CONFIG_OPTION) && !arguments[0].equals(SYNCHRONIZE_OPTION))) { - throw new IllegalArgumentException("Expected --validate-config [path], --sync [path], or --help"); + throw new IllegalArgumentException( + "Expected --validate-config [path], --check-runtimes, --sync [path], or --help"); } final Path configurationPath = arguments.length == 2 ? Path.of(arguments[1]) : DEFAULT_CONFIGURATION_PATH; @@ -65,10 +77,28 @@ private static boolean hasOnlyArgument(final String[] arguments, final String op private static void printHelp(final PrintStream output) { output.println("Tank Royale Rumble Client"); output.println("Usage: rumble-client --validate-config [path]"); + output.println(" rumble-client --check-runtimes"); output.println(" rumble-client --sync [path]"); output.println(" rumble-client --help"); output.println(); output.println("Use --validate-config to check a local ranked or practice configuration."); + output.println("Use --check-runtimes to verify native Java, .NET, Python, and Node.js prerequisites."); output.println("Use --sync to validate the current ranked snapshot and prepare its immutable bot cache."); } + + private static void printRuntimeReport(final RuntimeReport report, final PrintStream output) { + for (final RuntimeStatus status : report.statuses()) { + output.printf("%s %s (required %s): %s%n", status.available() ? "OK" : "MISSING", + status.name(), status.required().display(), status.detail()); + } + if (!report.ready()) { + throw new IllegalArgumentException( + "Install the missing native prerequisites or use the recommended Docker distribution"); + } + } + + @FunctionalInterface + interface RuntimeCheck { + RuntimeReport check() throws IOException; + } } diff --git a/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java b/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java index fddbeb8..5634c97 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java @@ -3,6 +3,7 @@ import java.net.URI; import java.util.List; import java.util.Map; +import java.util.Optional; /** * Immutable ranked input snapshot accepted from one Rumble data revision. @@ -14,9 +15,10 @@ record RumbleSnapshot(URI canonicalDataRepository, String dataRevision, EnginePi } } -record EnginePin(int behaviorVersion, String tankRoyaleVersion, String image, +record EnginePin(int behaviorVersion, String tankRoyaleVersion, String image, Optional clientImage, Map gameTypes) { EnginePin { + clientImage = java.util.Objects.requireNonNull(clientImage, "clientImage"); gameTypes = Map.copyOf(gameTypes); } } diff --git a/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java b/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java index 52fdb0c..6445afd 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java @@ -10,6 +10,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.regex.Pattern; @@ -19,6 +20,8 @@ final class RumbleSnapshotParser { private static final Pattern COMMIT = Pattern.compile("[0-9a-f]{40}"); private static final Pattern SHA_256 = Pattern.compile("sha256:[0-9a-f]{64}"); + private static final Pattern CLIENT_IMAGE = Pattern.compile( + "ghcr\\.io/[a-z0-9._/-]+@sha256:[0-9a-f]{64}"); private static final Pattern PROJECTION_ID = Pattern.compile("[0-9a-f]{64}"); private static final Set ADVICE_REASONS = Set.of("new-bot", "under-sampled"); @@ -41,6 +44,9 @@ private static EnginePin parseEngine(final String json, final Set sele final int behaviorVersion = contract.integer("behaviorVersion", 1); final String tankRoyaleVersion = contract.string("tankRoyaleVersion"); final String image = contract.string("image"); + final Optional clientImage = Optional.ofNullable(contract.nullableString("clientImage")) + .map(value -> matching(value, CLIENT_IMAGE, + "engine.json.clientImage must be an immutable GHCR SHA-256 reference")); final JsonObject gameTypesObject = contract.object("gameTypes"); final Map gameTypes = new HashMap<>(); for (final GameType gameType : selectedGameTypes) { @@ -60,7 +66,7 @@ private static EnginePin parseEngine(final String json, final Set sele final int height = arrayInteger(battlefield, 1, "engine.json battlefield height", 1); gameTypes.put(gameType, new GameTypeSettings(rounds, width, height, participants)); } - return new EnginePin(behaviorVersion, tankRoyaleVersion, image, gameTypes); + return new EnginePin(behaviorVersion, tankRoyaleVersion, image, clientImage, gameTypes); } private static BotCatalog parseCatalog(final String json, final URI expectedBotsRepository) { diff --git a/src/main/java/dev/robocode/rumble/client/RuntimePrerequisiteChecker.java b/src/main/java/dev/robocode/rumble/client/RuntimePrerequisiteChecker.java new file mode 100644 index 0000000..8814889 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/RuntimePrerequisiteChecker.java @@ -0,0 +1,151 @@ +package dev.robocode.rumble.client; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Verifies the host runtimes needed to execute every supported bot platform. + */ +final class RuntimePrerequisiteChecker { + private static final String VERSION_RESOURCE = "/runtime-versions.properties"; + private static final Pattern VERSION = Pattern.compile("(? required = loadRequiredVersions(); + final List statuses = new ArrayList<>(); + statuses.add(checkRuntime("Java", List.of(List.of("java", "-version")), required.get("java"))); + statuses.add(checkRuntime(".NET SDK", List.of(List.of("dotnet", "--version")), required.get("dotnet"))); + statuses.add(checkRuntime("Python", List.of(List.of("python3.12", "--version"), + List.of("py", "-3.12", "--version"), List.of("python3", "--version"), + List.of("python", "--version")), required.get("python"))); + statuses.add(checkRuntime("Node.js", List.of(List.of("node", "--version")), required.get("node"))); + return new RuntimeReport(statuses); + } + + private RuntimeStatus checkRuntime(final String name, final List> commands, + final RequiredVersion required) { + IOException lastFailure = null; + for (final List command : commands) { + try { + final CommandResult result = commandRunner.run(command); + if (result.exitCode() != 0) { + lastFailure = new IOException(String.join(" ", command) + " exited with " + result.exitCode()); + continue; + } + final RequiredVersion actual = parseVersion(result.output(), name); + if (!actual.satisfies(required)) { + return RuntimeStatus.failure(name, required, "found " + actual.display()); + } + return RuntimeStatus.success(name, required, actual.display()); + } catch (IOException exception) { + lastFailure = exception; + } + } + final String detail = lastFailure == null ? "command unavailable" : lastFailure.getMessage(); + return RuntimeStatus.failure(name, required, detail); + } + + private static RequiredVersion parseVersion(final String output, final String name) throws IOException { + final Matcher matcher = VERSION.matcher(output); + if (!matcher.find()) { + throw new IOException(name + " did not report a recognizable version"); + } + final int major = Integer.parseInt(matcher.group(1)); + final int minor = matcher.group(2) == null ? 0 : Integer.parseInt(matcher.group(2)); + return new RequiredVersion(major, minor, matcher.group(2) != null); + } + + private static Map loadRequiredVersions() throws IOException { + final Properties properties = new Properties(); + try (InputStream input = RuntimePrerequisiteChecker.class.getResourceAsStream(VERSION_RESOURCE)) { + if (input == null) { + throw new IOException("Missing " + VERSION_RESOURCE); + } + properties.load(input); + } + final Map versions = new LinkedHashMap<>(); + for (final String runtime : List.of("java", "dotnet", "python", "node")) { + versions.put(runtime, RequiredVersion.parse(properties.getProperty(runtime), runtime)); + } + return versions; + } + + private static CommandResult runCommand(final List command) throws IOException { + final Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + final String output; + try (InputStream input = process.getInputStream()) { + output = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + try { + return new CommandResult(process.waitFor(), output.trim()); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while checking " + command.get(0), exception); + } + } + + @FunctionalInterface + interface CommandRunner { + CommandResult run(List command) throws IOException; + } +} + +record CommandResult(int exitCode, String output) { +} + +record RequiredVersion(int major, int minor, boolean checksMinor) { + static RequiredVersion parse(final String value, final String runtime) throws IOException { + if (value == null || !value.matches("[0-9]+(?:\\.[0-9]+)?")) { + throw new IOException("Invalid required " + runtime + " version: " + value); + } + final String[] parts = value.split("\\."); + return new RequiredVersion(Integer.parseInt(parts[0]), parts.length == 2 ? Integer.parseInt(parts[1]) : 0, + parts.length == 2); + } + + boolean satisfies(final RequiredVersion required) { + return major == required.major && (!required.checksMinor || minor == required.minor); + } + + String display() { + return checksMinor ? major + "." + minor : Integer.toString(major); + } +} + +record RuntimeStatus(String name, RequiredVersion required, boolean available, String detail) { + static RuntimeStatus success(final String name, final RequiredVersion required, final String actual) { + return new RuntimeStatus(name, required, true, actual); + } + + static RuntimeStatus failure(final String name, final RequiredVersion required, final String detail) { + return new RuntimeStatus(name, required, false, detail); + } +} + +record RuntimeReport(List statuses) { + RuntimeReport { + statuses = List.copyOf(statuses); + } + + boolean ready() { + return statuses.stream().allMatch(RuntimeStatus::available); + } +} diff --git a/src/main/resources/runtime-versions.properties b/src/main/resources/runtime-versions.properties new file mode 100644 index 0000000..f371092 --- /dev/null +++ b/src/main/resources/runtime-versions.properties @@ -0,0 +1,5 @@ +java=17 +dotnet=8 +python=3.12 +node=22 +nodeInstaller=22.18.0 diff --git a/src/test/java/dev/robocode/rumble/client/BotCachePreparerTest.java b/src/test/java/dev/robocode/rumble/client/BotCachePreparerTest.java index 5d2e17e..d29fbdf 100644 --- a/src/test/java/dev/robocode/rumble/client/BotCachePreparerTest.java +++ b/src/test/java/dev/robocode/rumble/client/BotCachePreparerTest.java @@ -87,7 +87,7 @@ private static RumbleSnapshot snapshot(final String sourceHash) { SOURCE_COMMIT, Map.of(bot.displayName(), bot)); return new RumbleSnapshot(URI.create("https://github.com/example/rumble-data"), "dddddddddddddddddddddddddddddddddddddddd", - new EnginePin(1, "unreleased", "example", Map.of()), catalog, + new EnginePin(1, "unreleased", "example", java.util.Optional.empty(), Map.of()), catalog, new ClientRegistration("alice", "alice-desktop"), Map.of()); } diff --git a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java index 2a9b5da..b633a10 100644 --- a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java +++ b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java @@ -110,7 +110,7 @@ GameType.TWIN_DUEL, new GameTypeSettings(75, 800, 800, 4), advice.put(gameType, new MatchAdvice(gameType, "a".repeat(64), 6, pairs)); } return new RumbleSnapshot(URI.create("https://github.com/example/rumble-data"), "b".repeat(40), - new EnginePin(1, "unreleased", "example/image", settings), + new EnginePin(1, "unreleased", "example/image", java.util.Optional.empty(), settings), new BotCatalog(URI.create("https://github.com/example/rumble-bots"), "c".repeat(40), bots), new ClientRegistration("alice", "alice-desktop"), advice); } diff --git a/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java b/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java index 0d1e29d..81e8707 100644 --- a/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java +++ b/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java @@ -9,6 +9,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.PrintStream; +import java.util.List; class RumbleClientTest { @Test @@ -19,6 +20,7 @@ void testUnitPositive_printsHelpWithoutConfiguration() throws IOException { RumbleClient.run(new String[] {"--help"}, new PrintStream(bytes)); assertTrue(bytes.toString().contains("rumble-client --validate-config [path]")); + assertTrue(bytes.toString().contains("rumble-client --check-runtimes")); assertTrue(bytes.toString().contains("rumble-client --sync [path]")); } @@ -27,4 +29,27 @@ void testUnitPositive_printsHelpWithoutConfiguration() throws IOException { void testUnitNegative_rejectsUnknownCommand() { assertThrows(IllegalArgumentException.class, () -> RumbleClient.run(new String[] {"--submit"}, System.out)); } + + @Test + @Tag("Unit") + void testUnitPositive_printsSuccessfulRuntimePreflight() throws IOException { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final RequiredVersion version = new RequiredVersion(17, 0, false); + + RumbleClient.run(new String[] {"--check-runtimes"}, new PrintStream(bytes), () -> + new RuntimeReport(List.of(RuntimeStatus.success("Java", version, "17.0.16")))); + + assertTrue(bytes.toString().contains("OK Java (required 17): 17.0.16")); + } + + @Test + @Tag("Unit") + void testUnitNegative_failsRuntimePreflightWhenAnyRuntimeIsUnavailable() { + final RequiredVersion version = new RequiredVersion(8, 0, false); + + assertThrows(IllegalArgumentException.class, () -> + RumbleClient.run(new String[] {"--check-runtimes"}, System.out, () -> + new RuntimeReport(List.of(RuntimeStatus.failure(".NET SDK", version, + "command unavailable"))))); + } } diff --git a/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java b/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java index b5ab417..88e2321 100644 --- a/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java +++ b/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java @@ -31,6 +31,8 @@ void testRCL002_IntegrationPositive_followsCanonicalPointerAndValidatesSnapshot( assertEquals(CANONICAL_REPOSITORY, snapshot.canonicalDataRepository()); assertEquals("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", snapshot.dataRevision()); assertEquals(1, snapshot.engine().behaviorVersion()); + assertEquals(Optional.of("ghcr.io/example/rumble-client@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + snapshot.engine().clientImage()); assertEquals("alice", snapshot.registration().account()); assertEquals(List.of(PREVIOUS_REPOSITORY, CANONICAL_REPOSITORY), repositories.requestedRepositories()); assertEquals(1, snapshot.advice().get(GameType.ONE_VS_ONE).priorityPairs().size()); @@ -47,6 +49,33 @@ void testRCL002_IntegrationNegative_rejectsUnknownAdviceSchemaBeforeReturningSna () -> new RumbleSynchronizer(repositories).synchronize(configuration())); } + @Test + @Tag("RCL-002") + void testRCL002_IntegrationPositive_acceptsEnginePinWithoutOptionalClientImage() throws IOException { + final InMemoryRepositoryReader repositories = validRepositories(); + repositories.replace(CANONICAL_REPOSITORY, "engine.json", + repositories.read(CANONICAL_REPOSITORY, "engine.json").replace( + " \"clientImage\": \"ghcr.io/example/rumble-client@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n", + "")); + + final RumbleSnapshot snapshot = new RumbleSynchronizer(repositories).synchronize(configuration()); + + assertEquals(Optional.empty(), snapshot.engine().clientImage()); + } + + @Test + @Tag("RCL-002") + void testRCL002_IntegrationNegative_rejectsMutableClientImageReference() { + final InMemoryRepositoryReader repositories = validRepositories(); + repositories.replace(CANONICAL_REPOSITORY, "engine.json", + repositories.read(CANONICAL_REPOSITORY, "engine.json").replace( + "ghcr.io/example/rumble-client@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "ghcr.io/example/rumble-client:latest")); + + assertThrows(IllegalArgumentException.class, + () -> new RumbleSynchronizer(repositories).synchronize(configuration())); + } + @Test @Tag("RCL-002") void testRCL002_IntegrationNegative_rejectsUnregisteredClientIdentity() { @@ -132,6 +161,7 @@ private static InMemoryRepositoryReader validRepositories() { "behaviorVersion": 1, "tankRoyaleVersion": "unreleased", "image": "ghcr.io/example/tank-royale:unreleased", + "clientImage": "ghcr.io/example/rumble-client@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "gameTypes": {"1v1": {"rounds": 35, "battlefield": [800, 600], "participants": 2}} } """); diff --git a/src/test/java/dev/robocode/rumble/client/RuntimePrerequisiteCheckerTest.java b/src/test/java/dev/robocode/rumble/client/RuntimePrerequisiteCheckerTest.java new file mode 100644 index 0000000..d56a6d7 --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/RuntimePrerequisiteCheckerTest.java @@ -0,0 +1,49 @@ +package dev.robocode.rumble.client; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RuntimePrerequisiteCheckerTest { + @Test + @Tag("Unit") + void testUnitPositive_acceptsEveryPinnedRuntimeLane() throws IOException { + final Map versions = Map.of( + "java", "openjdk version \"17.0.16\"", + "dotnet", "8.0.419", + "python3.12", "Python 3.12.11", + "node", "v22.18.0"); + + final RuntimeReport report = new RuntimePrerequisiteChecker(command -> + new CommandResult(0, versions.get(command.get(0)))).check(); + + assertTrue(report.ready()); + } + + @Test + @Tag("Unit") + void testUnitNegative_reportsMissingOrMismatchedRuntimesWithoutInstallingThem() throws IOException { + final List> invoked = new java.util.ArrayList<>(); + final RuntimeReport report = new RuntimePrerequisiteChecker(command -> { + invoked.add(List.copyOf(command)); + return switch (command.get(0)) { + case "java" -> new CommandResult(0, "openjdk version \"21.0.8\""); + case "dotnet" -> throw new IOException("command unavailable"); + case "python3.12" -> new CommandResult(0, "Python 3.12.11"); + case "node" -> new CommandResult(0, "v22.18.0"); + default -> throw new IOException("command unavailable"); + }; + }).check(); + + assertFalse(report.ready()); + assertTrue(report.statuses().stream().anyMatch(status -> status.name().equals("Java") && !status.available())); + assertTrue(report.statuses().stream().anyMatch(status -> status.name().equals(".NET SDK") && !status.available())); + assertFalse(invoked.stream().flatMap(List::stream).anyMatch(argument -> argument.equals("install"))); + } +}