Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.github
.gradle
.idea
build
cache
evidence
journal
out
rumble-client.json
28 changes: 26 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,36 @@ 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
with:
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"
37 changes: 37 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
45 changes: 45 additions & 0 deletions docker/rumble.ps1
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions docker/rumble.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env sh
set -eu

usage() {
echo "Usage: docker/rumble.sh <validate|runtimes|sync> [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
32 changes: 31 additions & 1 deletion src/main/java/dev/robocode/rumble/client/RumbleClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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;
Expand All @@ -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;
}
}
4 changes: 3 additions & 1 deletion src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String> clientImage,
Map<GameType, GameTypeSettings> gameTypes) {
EnginePin {
clientImage = java.util.Objects.requireNonNull(clientImage, "clientImage");
gameTypes = Map.copyOf(gameTypes);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String> ADVICE_REASONS = Set.of("new-bot", "under-sampled");

Expand All @@ -41,6 +44,9 @@ private static EnginePin parseEngine(final String json, final Set<GameType> sele
final int behaviorVersion = contract.integer("behaviorVersion", 1);
final String tankRoyaleVersion = contract.string("tankRoyaleVersion");
final String image = contract.string("image");
final Optional<String> 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<GameType, GameTypeSettings> gameTypes = new HashMap<>();
for (final GameType gameType : selectedGameTypes) {
Expand All @@ -60,7 +66,7 @@ private static EnginePin parseEngine(final String json, final Set<GameType> 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) {
Expand Down
Loading
Loading