Skip to content
Closed
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
11 changes: 8 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ on:
paths-ignore:
- "**.md"
- "renovate.json"
workflow_dispatch:

jobs:
build:
if: ${{ github.event_name != 'pull_request' || github.repository != github.event.pull_request.head.repo.full_name }}
name: Build with Java 17
name: Build with Java 21
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
Expand All @@ -21,9 +22,13 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "adopt"
java-version: "21"
distribution: "temurin"
cache: "maven"
- run: java -version
- run: mvn --version
- run: mvn -B package
- uses: actions/upload-artifact@v4
with:
name: Kuvel
path: target/Kuvel.jar
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,6 @@ buildNumber.properties
# JDT-specific (Eclipse Java Development Tools)
.classpath

# End of https://www.toptal.com/developers/gitignore/api/maven,intellij+all
# End of https://www.toptal.com/developers/gitignore/api/maven,intellij+all

.vscode/
17 changes: 11 additions & 6 deletions README-JP.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,18 @@ labelはconfigで指定します。デフォルトでは、`kuvel.azisaba.net/en

また、一部の機能を使用するために以下のlabelを使用します。

| Label名 | 値 |
|:---------------------------------------:|:-------------------:|
| kuvel.azisaba.net/preferred-server-name | Velocityに登録したいサーバー名 |
| kuvel.azisaba.net/initial-server | true / false |
| kuvel.azisaba.net/disable-name-suffix | true / false |
| kuvel.azisaba.net/disable-load-balancer | true / false |
| Label名 | 値 |
|:---------------------------------------:|:----------------------------:|
| kuvel.azisaba.net/preferred-server-name | Velocityに登録したいサーバー名 |
| kuvel.azisaba.net/initial-server | true / false |
| kuvel.azisaba.net/disable-name-suffix | true / false |
| kuvel.azisaba.net/disable-load-balancer | true / false |
| kuvel.azisaba.net/forced-host | このサーバーにルーティングするホスト名 (forced hosts) |

サーバー名が63文字を超える場合は、ラベルではなく`kuvel.azisaba.net/preferred-server-name`のアノテーションを使用してください。
Podに`kuvel.azisaba.net/disable-name-suffix=true`を設定すると、`preferred-server-name`をそのまま登録します (`-1`などのsuffixを付与しません)。複数レプリカが検出された場合はエラーを出して追加Podを登録しません。単一レプリカのPod向けの設定です。
ReplicaSetまたはDeploymentのmetadataに`kuvel.azisaba.net/disable-load-balancer=true`を設定すると、そのReplicaSet用の仮想LoadBalancerサーバーは作成されません。配下のPod自体は通常どおり登録されます。
Pod、Deployment、またはReplicaSetに`kuvel.azisaba.net/forced-host`を設定すると、そのホスト名で接続したプレイヤーは対応するサーバーにルーティングされます。これはVelocityの[forced hosts](https://docs.papermc.io/velocity/configuration/#forced-hosts-section)機能をKubernetesのラベル/アノテーションで動的に設定するものです。

### Podの場合

Expand All @@ -108,6 +110,7 @@ metadata:
kuvel.azisaba.net/preferred-server-name: "test-server" # Kuvelがサーバーの命名をするために必要
# kuvel.azisaba.net/initial-server: "true" # 初期サーバーにする場合はコメントアウトを外す
# kuvel.azisaba.net/disable-name-suffix: "true" # preferred-server-nameをそのまま登録する (単一レプリカのみ)
# kuvel.azisaba.net/forced-host: "pvp.example.com" # pvp.example.comで接続したプレイヤーをこのサーバーに送る
spec:
containers:
- name: test-server
Expand All @@ -134,6 +137,7 @@ spec:
kuvel.azisaba.net/enable-server-discovery: "true" # KuvelがMinecraftサーバーを見つけるために必要 (Configに依存)
kuvel.azisaba.net/preferred-server-name: "test-server" # Kuvelがサーバーの命名をするために必要
# kuvel.azisaba.net/initial-server: "true" # 初期サーバーにする場合はコメントアウトを外す
# kuvel.azisaba.net/forced-host: "pvp.example.com" # pvp.example.comで接続したプレイヤーをこのサーバーに送る
spec:
containers:
- name: test-server
Expand All @@ -159,6 +163,7 @@ metadata:
kuvel.azisaba.net/preferred-server-name: "lobby"
# kuvel.azisaba.net/initial-server: "true" # このロードバランサーを初期サーバーにする場合はコメントアウトを外す
# kuvel.azisaba.net/disable-load-balancer: "true" # LoadBalancerエントリだけ無効にする場合はコメントアウトを外す
# kuvel.azisaba.net/forced-host: "lobby.example.com" # lobby.example.comで接続したプレイヤーをこのロードバランサーに送る
spec:
replicas: 3
selector:
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,12 @@ The following labels are also used for some other features.
| kuvel.azisaba.net/initial-server | true / false |
| kuvel.azisaba.net/disable-name-suffix | true / false |
| kuvel.azisaba.net/disable-load-balancer | true / false |
| kuvel.azisaba.net/forced-host | Hostname to route to this server (forced hosts) |

If server names longer than 63 characters are desired, the `kuvel.azisaba.net/preferred-server-name` annotation can be used instead of the label.
If `kuvel.azisaba.net/disable-name-suffix=true` is set on a Pod, Kuvel will register the server name exactly as `preferred-server-name` (no `-1` suffix). If multiple replicas are detected, Kuvel logs an error and skips the extra pods. This label is intended for single-replica Pods only.
If `kuvel.azisaba.net/disable-load-balancer=true` is set on a ReplicaSet or Deployment metadata, Kuvel will skip creating the virtual load balancer server for that ReplicaSet. The backing Pods are still registered normally.
If `kuvel.azisaba.net/forced-host` is set on a Pod, Deployment, or ReplicaSet, players connecting via that hostname will be routed to the corresponding server. This mirrors Velocity's [forced hosts](https://docs.papermc.io/velocity/configuration/#forced-hosts-section) feature but is configured dynamically through Kubernetes labels/annotations.

### Pod

Expand All @@ -106,6 +108,7 @@ metadata:
kuvel.azisaba.net/preferred-server-name: "test-server" # Required for Kuvel to name the server
# kuvel.azisaba.net/initial-server: "true" # Uncomment this line if you want to make this server the initial server.
# kuvel.azisaba.net/disable-name-suffix: "true" # Use the exact preferred name (no -1 suffix); single replica only.
# kuvel.azisaba.net/forced-host: "pvp.example.com" # Players connecting via pvp.example.com will be sent to this server.
spec:
containers:
- name: test-server
Expand All @@ -132,6 +135,7 @@ spec:
kuvel.azisaba.net/enable-server-discovery: "true" # Required for Kuvel to detect Minecraft servers. Depends on your config.
kuvel.azisaba.net/preferred-server-name: "test-server" # Required for Kuvel to name the server
# kuvel.azisaba.net/initial-server: "true" # Uncomment this line if you want to make this server the initial server.
# kuvel.azisaba.net/forced-host: "pvp.example.com" # Players connecting via pvp.example.com will be sent to this server.
spec:
containers:
- name: test-server
Expand All @@ -158,6 +162,7 @@ metadata:
kuvel.azisaba.net/preferred-server-name: "lobby"
# kuvel.azisaba.net/initial-server: "true" # Uncomment this line if you want to make this load balancer server the initial server.
# kuvel.azisaba.net/disable-load-balancer: "true" # Uncomment this line to disable only the load balancer entry.
# kuvel.azisaba.net/forced-host: "lobby.example.com" # Players connecting via lobby.example.com will be sent to this load balancer.
spec:
replicas: 3
selector:
Expand Down
14 changes: 13 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
</organization>

<properties>
<java.version>17</java.version>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

Expand Down Expand Up @@ -117,5 +117,17 @@
<version>1.18.42</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
114 changes: 106 additions & 8 deletions src/main/java/net/azisaba/kuvel/KuvelServiceHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicReference;
import javax.annotation.Nullable;

Expand All @@ -38,6 +41,7 @@ public class KuvelServiceHandler {
private final UidAndServerNameMap replicaSetUidAndServerNameMap = new UidAndServerNameMap();

private final List<String> initialServerNames = new ArrayList<>();
private final Map<String, List<String>> forcedHosts = new ConcurrentHashMap<>();

private final AtomicReference<ServerDiscovery> serverDiscovery = new AtomicReference<>();
private final AtomicReference<LoadBalancerDiscovery> loadBalancerDiscovery =
Expand All @@ -59,6 +63,10 @@ public void registerLoadBalancer(LoadBalancer loadBalancer) {
initialServerNames.add(serverName);
}

if (loadBalancer.getForcedHost() != null) {
addForcedHost(loadBalancer.getForcedHost(), serverName);
}

plugin
.getLogger()
.info(
Expand Down Expand Up @@ -101,6 +109,7 @@ public void unregisterLoadBalancer(LoadBalancer loadBalancer) {
replicaSetUidAndServerNameMap.unregister(loadBalancer.getReplicaSetUid());

initialServerNames.remove(serverName);
removeForcedHost(serverName);

plugin
.getLogger()
Expand Down Expand Up @@ -150,6 +159,44 @@ private void updateLoadBalancerEndpoints(LoadBalancer loadBalancer) {
loadBalancer.setEndpoints(endpoints);
}

/**
* Rebuild every registered load balancer's endpoint membership from the current live pod list.
*
* <p>Used by the periodic reconcile so that a backend whose pod has rolled away is dropped from
* the load-balancer rotation even if an incremental update was missed, preventing players from
* being routed to a dead endpoint.
*/
public void reconcileLoadBalancerEndpoints() {
for (LoadBalancer loadBalancer : loadBalancerServerMap.values()) {
updateLoadBalancerEndpoints(loadBalancer);
}
}

/**
* Look up a live (Running) pod by its UID within the configured namespace and label selectors.
*
* @param podUid The pod UID to look up.
* @return The matching Running pod, or null if no such live pod exists.
*/
@Nullable
private Pod getLivePodByUid(String podUid) {
FilterWatchListDeletable<Pod, PodList, PodResource> request =
client.pods().inNamespace(namespace);

for (Entry<String, String> e : plugin.getKuvelConfig().getLabelSelectors().entrySet()) {
request = request.withLabel(e.getKey(), e.getValue());
}

return request
.withField("status.phase", "Running")
.list()
.getItems()
.stream()
.filter(p -> p.getMetadata().getUid().equals(podUid))
.findFirst()
.orElse(null);
}

/**
* Replace new server discovery instance and unregister old one. Specify null for shutdown current
* discovery instance.
Expand Down Expand Up @@ -244,13 +291,26 @@ public boolean registerPod(Pod pod, String serverName) {
}
}

String labelKeyPrefix = plugin.getKuvelConfig().getLabelKeyPrefix();
String initialServerStr =
pod.getMetadata().getLabels().getOrDefault(
LabelKeys.INITIAL_SERVER.getKey(plugin.getKuvelConfig().getLabelKeyPrefix()), "false");
LabelKeys.INITIAL_SERVER.getKey(labelKeyPrefix), "false");
if (Boolean.parseBoolean(initialServerStr)) {
initialServerNames.add(serverName);
}

String forcedHost =
pod.getMetadata().getLabels().getOrDefault(
LabelKeys.FORCED_HOST.getKey(labelKeyPrefix), null);
if (forcedHost == null) {
forcedHost =
pod.getMetadata().getAnnotations().getOrDefault(
LabelKeys.FORCED_HOST.getKey(labelKeyPrefix), null);
}
if (forcedHost != null) {
addForcedHost(forcedHost, serverName);
}

plugin
.getLogger()
.info("Registered server: " + serverName + " (" + pod.getMetadata().getUid() + ")");
Expand Down Expand Up @@ -292,16 +352,40 @@ public void unregisterPod(String podUid) {
}

String serverName = podUidAndServerNameMap.unregister(podUid);
if (podUidAndServerNameMap.getUidFromServerName(serverName) != null) {

String survivingUid = podUidAndServerNameMap.getUidFromServerName(serverName);
if (survivingUid != null) {
// The server name is still claimed by another pod UID. Keep the registration
// only when that UID belongs to a genuinely live (Running) pod, and in that
// case rebind the Velocity ServerInfo / load balancer endpoint to the live
// pod's IP. Otherwise fall through to teardown so the name is never left
// pointing at a departed pod (the "ghost backend").
Pod survivingPod = getLivePodByUid(survivingUid);
if (survivingPod != null && registerPod(survivingPod, serverName)) {
plugin
.getLogger()
.info(
"Unregistered pod: "
+ podUid
+ " (server name "
+ serverName
+ " rebound to live pod "
+ survivingUid
+ ")");
return;
}

// Surviving UID is not a live pod (or could not be registered); drop the stale
// mapping before tearing down so the name is not left bound to a departed UID.
podUidAndServerNameMap.unregister(survivingUid);
plugin
.getLogger()
.info(
"Unregistered pod: "
+ podUid
+ " (server name "
.warn(
"Server name "
+ serverName
+ " is still in use)");
return;
+ " was still mapped to non-live pod "
+ survivingUid
+ "; tearing down stale registration.");
}
plugin
.getProxy()
Expand All @@ -315,6 +399,7 @@ public void unregisterPod(String podUid) {
}

initialServerNames.remove(serverName);
removeForcedHost(serverName);

plugin.getLogger().info("Unregistered server: " + serverName + " (" + podUid + ")");
}
Expand All @@ -337,4 +422,17 @@ public void unregisterPod(Pod pod) {
public boolean isPodRegistered(String podId) {
return podUidAndServerNameMap.getServerNameFromUid(podId) != null;
}

private void addForcedHost(String hostname, String serverName) {
forcedHosts.computeIfAbsent(hostname, k -> new CopyOnWriteArrayList<>()).add(serverName);
}

private void removeForcedHost(String serverName) {
forcedHosts.values().forEach(list -> list.remove(serverName));
forcedHosts.entrySet().removeIf(entry -> entry.getValue().isEmpty());
}

public Map<String, List<String>> getForcedHosts() {
return forcedHosts;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ private void registerOrIgnore(ReplicaSet replicaSet, boolean isFetchedFromRedis)
.getLabels()
.getOrDefault(LabelKeys.INITIAL_SERVER.getKey(labelKeyPrefix), "false")
.equalsIgnoreCase("true");
String forcedHost =
metadata
.getLabels()
.getOrDefault(LabelKeys.FORCED_HOST.getKey(labelKeyPrefix), null);
if (forcedHost == null) {
forcedHost =
metadata
.getAnnotations()
.getOrDefault(LabelKeys.FORCED_HOST.getKey(labelKeyPrefix), null);
}
if (isDisableLoadBalancer(metadata)) {
return;
}
Expand Down Expand Up @@ -169,7 +179,7 @@ private void registerOrIgnore(ReplicaSet replicaSet, boolean isFetchedFromRedis)
kuvelServiceHandler.getReplicaSetUidAndServerNameMap().register(uid, serverName);
jedis.hset(RedisKeys.LOAD_BALANCERS_PREFIX.getKey() + groupName, uid, serverName);

redisConnectionLeader.publishNewLoadBalancer(uid, serverName, initialServer);
redisConnectionLeader.publishNewLoadBalancer(uid, serverName, initialServer, forcedHost);

RegisteredServer server =
plugin
Expand All @@ -182,7 +192,8 @@ private void registerOrIgnore(ReplicaSet replicaSet, boolean isFetchedFromRedis)
server,
new RoundRobinLoadBalancingStrategy(),
uid,
initialServer));
initialServer,
forcedHost));
}
}

Expand Down
Loading