Skip to content

Commit 62568a0

Browse files
Merge pull request #6 from robocode-dev/agent/runtime-distributions
Add native and Docker runtime distributions
2 parents 52396cc + c1c7d32 commit 62568a0

17 files changed

Lines changed: 470 additions & 8 deletions

.dockerignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.git
2+
.github
3+
.gradle
4+
.idea
5+
build
6+
cache
7+
evidence
8+
journal
9+
out
10+
rumble-client.json

.github/workflows/build.yml

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,36 @@ permissions:
1010

1111
jobs:
1212
build:
13-
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
os: [ubuntu-latest, windows-latest, macos-latest]
16+
runs-on: ${{ matrix.os }}
1417
steps:
1518
- uses: actions/checkout@v4
1619
- uses: actions/setup-java@v5
1720
with:
1821
distribution: temurin
1922
java-version: 17
2023
cache: gradle
21-
- run: ./gradlew build
24+
- if: runner.os != 'Windows'
25+
run: ./gradlew build
26+
- if: runner.os == 'Windows'
27+
run: .\gradlew.bat build
28+
- if: runner.os == 'Linux'
29+
uses: actions/upload-artifact@v4
30+
with:
31+
name: native-distributions
32+
path: build/distributions/*
33+
34+
docker:
35+
runs-on: ubuntu-latest
36+
steps:
37+
- uses: actions/checkout@v4
38+
- uses: docker/setup-buildx-action@v3
39+
- uses: docker/build-push-action@v6
40+
with:
41+
context: .
42+
load: true
43+
tags: rumble-client:test
44+
- 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
45+
- run: test "$(docker run --rm --entrypoint id rumble-client:test -u)" != "0"

Dockerfile

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# syntax=docker/dockerfile:1
2+
3+
FROM gradle:8.14.3-jdk17 AS build
4+
WORKDIR /workspace
5+
COPY gradle gradle
6+
COPY gradlew gradlew.bat build.gradle.kts settings.gradle.kts gradle.properties ./
7+
COPY src src
8+
RUN ./gradlew --no-daemon installDist
9+
10+
FROM ubuntu:24.04
11+
ARG TARGETARCH
12+
COPY src/main/resources/runtime-versions.properties /tmp/runtime-versions.properties
13+
14+
RUN apt-get update \
15+
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
16+
ca-certificates curl dotnet-sdk-8.0 git openjdk-17-jdk-headless python3.12 xz-utils \
17+
&& NODE_VERSION="$(sed -n 's/^nodeInstaller=//p' /tmp/runtime-versions.properties)" \
18+
&& test -n "$NODE_VERSION" \
19+
&& case "$TARGETARCH" in amd64) node_arch=x64 ;; arm64) node_arch=arm64 ;; *) exit 1 ;; esac \
20+
&& node_archive="node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" \
21+
&& curl --fail --location --proto '=https' --tlsv1.2 \
22+
"https://nodejs.org/dist/v${NODE_VERSION}/${node_archive}" --output "/tmp/${node_archive}" \
23+
&& curl --fail --location --proto '=https' --tlsv1.2 \
24+
"https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" --output /tmp/SHASUMS256.txt \
25+
&& grep " ${node_archive}$" /tmp/SHASUMS256.txt | (cd /tmp && sha256sum --check --strict -) \
26+
&& tar --extract --xz --file "/tmp/${node_archive}" --directory /usr/local --strip-components=1 \
27+
&& rm -rf /var/lib/apt/lists/* /tmp/* \
28+
&& groupadd --gid 10001 rumble \
29+
&& useradd --uid 10001 --gid rumble --no-create-home --home-dir /tmp --shell /usr/sbin/nologin rumble
30+
31+
COPY --from=build --chown=10001:10001 /workspace/build/install/rumble-client /opt/rumble-client
32+
33+
ENV HOME=/tmp
34+
WORKDIR /work
35+
USER 10001:10001
36+
ENTRYPOINT ["/opt/rumble-client/bin/rumble-client"]
37+
CMD ["--help"]

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ The Rumble Client runs local Tank Royale battles against the published Rumble ca
44

55
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).
66

7+
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.
8+
79
## Build
810

911
Install JDK 17, then run:
@@ -12,12 +14,20 @@ Install JDK 17, then run:
1214
./gradlew build
1315
```
1416

17+
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.
18+
1519
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.
1620

1721
## Configuration
1822

1923
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.
2024

25+
## Docker development image
26+
27+
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.
28+
29+
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.
30+
2131
## Contributing
2232

2333
Read [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and [GOVERNANCE.md](GOVERNANCE.md) before opening a pull request.

SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
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.
44

5-
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.
5+
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.

docker/rumble.ps1

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
param(
2+
[Parameter(Mandatory = $true, Position = 0)]
3+
[ValidateSet('validate', 'runtimes', 'sync')]
4+
[string] $Command,
5+
6+
[Parameter(Position = 1)]
7+
[string] $Configuration = 'rumble-client.json',
8+
9+
[Parameter(Position = 2)]
10+
[string] $Image = 'rumble-client:dev'
11+
)
12+
13+
$clientArguments = switch ($Command) {
14+
'validate' { @('--validate-config', '/work/rumble-client.json') }
15+
'runtimes' { @('--check-runtimes') }
16+
'sync' { @('--sync', '/work/rumble-client.json') }
17+
}
18+
19+
$dockerArguments = @(
20+
'run', '--rm', '--read-only', '--tmpfs', '/tmp:rw,nosuid,nodev,size=1g',
21+
'--cpus', '4', '--memory', '8g', '--pids-limit', '512',
22+
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges'
23+
)
24+
if ($IsLinux -or $IsMacOS) {
25+
$userId = (& id -u).Trim()
26+
$groupId = (& id -g).Trim()
27+
$dockerArguments += @('--user', "${userId}:${groupId}")
28+
}
29+
if ($Command -eq 'runtimes') {
30+
$dockerArguments += @('--network', 'none')
31+
} else {
32+
$configurationPath = (Resolve-Path -LiteralPath $Configuration).Path
33+
$configurationDirectory = Split-Path -Parent $configurationPath
34+
$stateDirectory = Join-Path $configurationDirectory '.rumble-client'
35+
New-Item -ItemType Directory -Force -Path $stateDirectory | Out-Null
36+
$dockerArguments += @(
37+
'--mount', "type=bind,source=$configurationPath,target=/work/rumble-client.json,readonly",
38+
'--mount', "type=bind,source=$stateDirectory,target=/work/.rumble-client"
39+
)
40+
}
41+
$dockerArguments += $Image
42+
$dockerArguments += $clientArguments
43+
44+
& docker @dockerArguments
45+
exit $LASTEXITCODE

docker/rumble.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env sh
2+
set -eu
3+
4+
usage() {
5+
echo "Usage: docker/rumble.sh <validate|runtimes|sync> [config-path] [image]" >&2
6+
exit 2
7+
}
8+
9+
command_name="${1:-}"
10+
config_path="${2:-rumble-client.json}"
11+
image="${3:-rumble-client:dev}"
12+
13+
case "$command_name" in
14+
validate) client_arguments="--validate-config /work/rumble-client.json" ;;
15+
runtimes) client_arguments="--check-runtimes" ;;
16+
sync) client_arguments="--sync /work/rumble-client.json" ;;
17+
*) usage ;;
18+
esac
19+
20+
if [ "$command_name" = "runtimes" ]; then
21+
exec docker run --rm --read-only --network none --tmpfs /tmp:rw,nosuid,nodev,size=1g \
22+
--user "$(id -u):$(id -g)" \
23+
--cpus 4 --memory 8g --pids-limit 512 --cap-drop ALL --security-opt no-new-privileges \
24+
"$image" --check-runtimes
25+
fi
26+
27+
config_directory=$(CDPATH= cd -- "$(dirname -- "$config_path")" && pwd)
28+
config_name=$(basename -- "$config_path")
29+
absolute_config="$config_directory/$config_name"
30+
state_directory="$config_directory/.rumble-client"
31+
mkdir -p "$state_directory"
32+
33+
exec docker run --rm --read-only --tmpfs /tmp:rw,nosuid,nodev,size=1g \
34+
--user "$(id -u):$(id -g)" \
35+
--cpus 4 --memory 8g --pids-limit 512 --cap-drop ALL --security-opt no-new-privileges \
36+
--mount "type=bind,source=$absolute_config,target=/work/rumble-client.json,readonly" \
37+
--mount "type=bind,source=$state_directory,target=/work/.rumble-client" \
38+
"$image" $client_arguments

src/main/java/dev/robocode/rumble/client/RumbleClient.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
public final class RumbleClient {
1111
private static final String HELP_OPTION = "--help";
1212
private static final String VALIDATE_CONFIG_OPTION = "--validate-config";
13+
private static final String CHECK_RUNTIMES_OPTION = "--check-runtimes";
1314
private static final String SYNCHRONIZE_OPTION = "--sync";
1415
private static final Path DEFAULT_CONFIGURATION_PATH = Path.of("rumble-client.json");
1516

@@ -32,14 +33,25 @@ public static void main(final String[] arguments) {
3233
}
3334

3435
static void run(final String[] arguments, final PrintStream output) throws IOException {
36+
run(arguments, output, new RuntimePrerequisiteChecker()::check);
37+
}
38+
39+
static void run(final String[] arguments, final PrintStream output, final RuntimeCheck runtimeCheck)
40+
throws IOException {
3541
if (arguments.length == 0 || hasOnlyArgument(arguments, HELP_OPTION)) {
3642
printHelp(output);
3743
return;
3844
}
3945

46+
if (hasOnlyArgument(arguments, CHECK_RUNTIMES_OPTION)) {
47+
printRuntimeReport(runtimeCheck.check(), output);
48+
return;
49+
}
50+
4051
if (arguments.length > 2
4152
|| (!arguments[0].equals(VALIDATE_CONFIG_OPTION) && !arguments[0].equals(SYNCHRONIZE_OPTION))) {
42-
throw new IllegalArgumentException("Expected --validate-config [path], --sync [path], or --help");
53+
throw new IllegalArgumentException(
54+
"Expected --validate-config [path], --check-runtimes, --sync [path], or --help");
4355
}
4456

4557
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
6577
private static void printHelp(final PrintStream output) {
6678
output.println("Tank Royale Rumble Client");
6779
output.println("Usage: rumble-client --validate-config [path]");
80+
output.println(" rumble-client --check-runtimes");
6881
output.println(" rumble-client --sync [path]");
6982
output.println(" rumble-client --help");
7083
output.println();
7184
output.println("Use --validate-config to check a local ranked or practice configuration.");
85+
output.println("Use --check-runtimes to verify native Java, .NET, Python, and Node.js prerequisites.");
7286
output.println("Use --sync to validate the current ranked snapshot and prepare its immutable bot cache.");
7387
}
88+
89+
private static void printRuntimeReport(final RuntimeReport report, final PrintStream output) {
90+
for (final RuntimeStatus status : report.statuses()) {
91+
output.printf("%s %s (required %s): %s%n", status.available() ? "OK" : "MISSING",
92+
status.name(), status.required().display(), status.detail());
93+
}
94+
if (!report.ready()) {
95+
throw new IllegalArgumentException(
96+
"Install the missing native prerequisites or use the recommended Docker distribution");
97+
}
98+
}
99+
100+
@FunctionalInterface
101+
interface RuntimeCheck {
102+
RuntimeReport check() throws IOException;
103+
}
74104
}

src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.net.URI;
44
import java.util.List;
55
import java.util.Map;
6+
import java.util.Optional;
67

78
/**
89
* Immutable ranked input snapshot accepted from one Rumble data revision.
@@ -14,9 +15,10 @@ record RumbleSnapshot(URI canonicalDataRepository, String dataRevision, EnginePi
1415
}
1516
}
1617

17-
record EnginePin(int behaviorVersion, String tankRoyaleVersion, String image,
18+
record EnginePin(int behaviorVersion, String tankRoyaleVersion, String image, Optional<String> clientImage,
1819
Map<GameType, GameTypeSettings> gameTypes) {
1920
EnginePin {
21+
clientImage = java.util.Objects.requireNonNull(clientImage, "clientImage");
2022
gameTypes = Map.copyOf(gameTypes);
2123
}
2224
}

src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.HashSet;
1111
import java.util.List;
1212
import java.util.Map;
13+
import java.util.Optional;
1314
import java.util.Set;
1415
import java.util.regex.Pattern;
1516

@@ -19,6 +20,8 @@
1920
final class RumbleSnapshotParser {
2021
private static final Pattern COMMIT = Pattern.compile("[0-9a-f]{40}");
2122
private static final Pattern SHA_256 = Pattern.compile("sha256:[0-9a-f]{64}");
23+
private static final Pattern CLIENT_IMAGE = Pattern.compile(
24+
"ghcr\\.io/[a-z0-9._/-]+@sha256:[0-9a-f]{64}");
2225
private static final Pattern PROJECTION_ID = Pattern.compile("[0-9a-f]{64}");
2326
private static final Set<String> ADVICE_REASONS = Set.of("new-bot", "under-sampled");
2427

@@ -41,6 +44,9 @@ private static EnginePin parseEngine(final String json, final Set<GameType> sele
4144
final int behaviorVersion = contract.integer("behaviorVersion", 1);
4245
final String tankRoyaleVersion = contract.string("tankRoyaleVersion");
4346
final String image = contract.string("image");
47+
final Optional<String> clientImage = Optional.ofNullable(contract.nullableString("clientImage"))
48+
.map(value -> matching(value, CLIENT_IMAGE,
49+
"engine.json.clientImage must be an immutable GHCR SHA-256 reference"));
4450
final JsonObject gameTypesObject = contract.object("gameTypes");
4551
final Map<GameType, GameTypeSettings> gameTypes = new HashMap<>();
4652
for (final GameType gameType : selectedGameTypes) {
@@ -60,7 +66,7 @@ private static EnginePin parseEngine(final String json, final Set<GameType> sele
6066
final int height = arrayInteger(battlefield, 1, "engine.json battlefield height", 1);
6167
gameTypes.put(gameType, new GameTypeSettings(rounds, width, height, participants));
6268
}
63-
return new EnginePin(behaviorVersion, tankRoyaleVersion, image, gameTypes);
69+
return new EnginePin(behaviorVersion, tankRoyaleVersion, image, clientImage, gameTypes);
6470
}
6571

6672
private static BotCatalog parseCatalog(final String json, final URI expectedBotsRepository) {

0 commit comments

Comments
 (0)