Skip to content

Commit d631a35

Browse files
committed
Forge & mods support
1 parent 8b96f49 commit d631a35

5 files changed

Lines changed: 331 additions & 2 deletions

File tree

‎api/v1alpha1/minecraftserver.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ const (
2525
)
2626

2727
type ForgeSpec struct {
28-
Version string `json:"version,omitempty"`
29-
ModpackURL string `json:"modpackURL,omitempty"`
28+
ForgeVersion string `json:"forgeVersion"`
29+
ForgeInstallerSHA256Sum string `json:"forgeInstallerSha256Sum"`
30+
ModpackZipURL string `json:"modpackZipUrl"`
31+
ModpackZipSHA256Sum string `json:"modpackZipSha256Sum"`
3032
}
3133

3234
// Player is a Minecraft player defined by a username or a UUID
@@ -36,6 +38,7 @@ type Player struct {
3638
}
3739

3840
type WorldSpec struct {
41+
Seed string `json:"seed,omitempty"`
3942
Overworld *corev1.PersistentVolumeClaimVolumeSource `json:"overworld,omitempty"`
4043
Nether *corev1.PersistentVolumeClaimVolumeSource `json:"nether,omitempty"`
4144
TheEnd *corev1.PersistentVolumeClaimVolumeSource `json:"theEnd,omitempty"`

‎pkg/controller/minecraftserver/config_map.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ func configMapData(server minecraftv1alpha1.MinecraftServer) (map[string]string,
9090
props["enforce-whitelist"] = "true"
9191
props["white-list"] = "true"
9292
}
93+
if server.Spec.World != nil && server.Spec.World.Seed != "" {
94+
props["level-seed"] = server.Spec.World.Seed
95+
}
9396
config["server.properties"] = propertiesfile.Write(props)
9497

9598
// We always write a eula.txt file, but we *only* put "true" in it if the MinecraftServer object has had the EULA

‎pkg/controller/minecraftserver/replica_set.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ func rsForServer(ctx context.Context, server *v1alpha1.MinecraftServer) (appsv1.
165165
switch server.Spec.Type {
166166
case minecraftv1alpha1.ServerTypePaper:
167167
return rsForServerTypePaper(ctx, server)
168+
case minecraftv1alpha1.ServerTypeForge:
169+
return rsForServerTypeForge(ctx, server)
168170
default:
169171
return appsv1.ReplicaSet{}, errors.New("Unrecognised server type")
170172
}
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
package minecraftserver
2+
3+
import (
4+
"context"
5+
"net/url"
6+
7+
appsv1 "k8s.io/api/apps/v1"
8+
corev1 "k8s.io/api/core/v1"
9+
"k8s.io/apimachinery/pkg/api/resource"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
12+
"github.com/jameslaverack/kubernetes-minecraft-operator/api/v1alpha1"
13+
)
14+
15+
func forgeDownloadUrl(server *v1alpha1.MinecraftServer) (*url.URL, error) {
16+
// https://maven.minecraftforge.net/net/minecraftforge/forge/1.18.2-40.1.80/forge-1.18.2-40.1.80-installer.jar
17+
versionIdntifier := server.Spec.MinecraftVersion + "-" + server.Spec.Forge.ForgeVersion
18+
path, err := url.JoinPath("net",
19+
"minecraftforge",
20+
"forge",
21+
url.PathEscape(versionIdntifier),
22+
url.PathEscape("forge-"+versionIdntifier+"-installer.jar"))
23+
if err != nil {
24+
return nil, err
25+
}
26+
return &url.URL{
27+
Scheme: "https",
28+
Host: "maven.minecraftforge.net",
29+
Path: path,
30+
}, nil
31+
}
32+
33+
func rsForServerTypeForge(ctx context.Context, server *v1alpha1.MinecraftServer) (appsv1.ReplicaSet, error) {
34+
const forgeInstallerVolumeName = "forge-installer-jar"
35+
const modpackZipVolumeName = "modpack-zip-volume"
36+
const forgeWorkingDirVolumeName = "forge-workingdir"
37+
const configVolumeMountName = "config"
38+
const worldMountName = "world-overworld"
39+
const dataPacksMountName = "data-packs"
40+
41+
url, err := forgeDownloadUrl(server)
42+
if err != nil {
43+
return appsv1.ReplicaSet{}, err
44+
}
45+
46+
forgeDownloadContainer := downloadContainer(url.String(), server.Spec.Forge.ForgeInstallerSHA256Sum, "forge-installer.jar", forgeInstallerVolumeName)
47+
copyConfigContainer := copyConfigContainer(configVolumeMountName, forgeWorkingDirVolumeName)
48+
forgeInstallerContainer := corev1.Container{
49+
Name: "forge-installer",
50+
// TODO Configure Java Version
51+
Image: "eclipse-temurin:17",
52+
Args: []string{
53+
"java",
54+
///////////////////////////////
55+
// Flags here are flags to Java
56+
///////////////////////////////
57+
"-jar",
58+
"/usr/local/minecraft/forge-installer.jar",
59+
////////////////////////////////////////////////////////////
60+
// Flags after this point are flags to the Forge installer, and not Java
61+
////////////////////////////////////////////////////////////
62+
// Disable the GUI, no need in a container
63+
"--installServer /run/minecraft"},
64+
WorkingDir: "/usr/local/minecraft",
65+
// TODO Make resources configurable
66+
Resources: corev1.ResourceRequirements{
67+
Limits: corev1.ResourceList{
68+
corev1.ResourceMemory: resource.MustParse("6Gi"),
69+
// No CPU limit to avoid CPU throttling
70+
},
71+
Requests: corev1.ResourceList{
72+
corev1.ResourceMemory: resource.MustParse("2.5Gi"),
73+
corev1.ResourceCPU: resource.MustParse("2"),
74+
},
75+
},
76+
VolumeMounts: []corev1.VolumeMount{
77+
{
78+
Name: forgeInstallerVolumeName,
79+
MountPath: "/usr/local/minecraft",
80+
},
81+
{
82+
Name: forgeWorkingDirVolumeName,
83+
MountPath: "/run/minecraft",
84+
},
85+
},
86+
}
87+
modpackDownloadContainer := downloadContainer(
88+
server.Spec.Forge.ModpackZipURL,
89+
server.Spec.Forge.ModpackZipSHA256Sum,
90+
"modpack.zip",
91+
modpackZipVolumeName)
92+
modpackUnzipContainer := corev1.Container{
93+
Name: "modpack-unzip",
94+
// TODO Configure Java Version
95+
Image: "busybox",
96+
Args: []string{
97+
"unzip /usr/local/modpack/modpack.zip -d /run/minecraft"},
98+
// TODO Make resources configurable
99+
Resources: corev1.ResourceRequirements{
100+
Limits: corev1.ResourceList{
101+
corev1.ResourceMemory: resource.MustParse("1Gi"),
102+
// No CPU limit to avoid CPU throttling
103+
},
104+
Requests: corev1.ResourceList{
105+
corev1.ResourceMemory: resource.MustParse("1Gi"),
106+
corev1.ResourceCPU: resource.MustParse("2"),
107+
},
108+
},
109+
VolumeMounts: []corev1.VolumeMount{
110+
{
111+
Name: modpackZipVolumeName,
112+
MountPath: "/usr/local/modpack",
113+
},
114+
{
115+
Name: forgeWorkingDirVolumeName,
116+
MountPath: "/run/minecraft",
117+
},
118+
},
119+
}
120+
121+
initContainers := []corev1.Container{
122+
forgeDownloadContainer,
123+
forgeInstallerContainer,
124+
modpackDownloadContainer,
125+
modpackUnzipContainer,
126+
copyConfigContainer}
127+
128+
mainJavaContainer := corev1.Container{
129+
Name: "minecraft",
130+
// TODO Configure Java Version
131+
Image: "eclipse-temurin:17",
132+
Args: []string{
133+
"run.sh",
134+
// Set the world directory to be /var/minecraft
135+
//"--world-container=/var/minecraft",
136+
// Set the plugin directory to be /usr/local/minecraft/plugins
137+
//"--plugins=/usr/local/minecraft/plugins",
138+
// Disable the on-disk logging, we'll use STDOUT logging always
139+
//"--log-append=false",
140+
// Disable the GUI, no need in a container
141+
"--nogui"},
142+
// Paper expects to be able to write all kinds of stuff to it's working directory, so we give it a dedicated
143+
// scratch dir for it's use under /run/minecraft.
144+
WorkingDir: "/run/minecraft",
145+
// TODO Make resources configurable
146+
Resources: corev1.ResourceRequirements{
147+
Limits: corev1.ResourceList{
148+
corev1.ResourceMemory: resource.MustParse("6Gi"),
149+
// No CPU limit to avoid CPU throttling
150+
},
151+
Requests: corev1.ResourceList{
152+
corev1.ResourceMemory: resource.MustParse("2.5Gi"),
153+
corev1.ResourceCPU: resource.MustParse("2"),
154+
},
155+
},
156+
Ports: []corev1.ContainerPort{
157+
{
158+
Name: "minecraft",
159+
ContainerPort: 25565,
160+
Protocol: corev1.ProtocolTCP,
161+
},
162+
},
163+
VolumeMounts: []corev1.VolumeMount{
164+
// This will mount config files, like server.properties, under /etc/minecraft/server.properties
165+
{
166+
Name: configVolumeMountName,
167+
MountPath: "/etc/minecraft",
168+
},
169+
// This gives forge a writeable runtime directory, this is used as the working directory.
170+
{
171+
Name: forgeWorkingDirVolumeName,
172+
MountPath: "/run/minecraft",
173+
},
174+
// Mount the various world directories under /var/minecraft
175+
{
176+
Name: worldMountName,
177+
MountPath: "/var/minecraft/world",
178+
},
179+
{
180+
Name: dataPacksMountName,
181+
MountPath: "/var/minecraft/world/datapacks",
182+
},
183+
},
184+
}
185+
186+
var replicas int32 = 1
187+
rs := appsv1.ReplicaSet{
188+
ObjectMeta: metav1.ObjectMeta{
189+
Name: server.Name,
190+
Namespace: server.Namespace,
191+
OwnerReferences: []metav1.OwnerReference{serverOwnerReference(server)},
192+
},
193+
Spec: appsv1.ReplicaSetSpec{
194+
Replicas: &replicas,
195+
Selector: &metav1.LabelSelector{
196+
MatchLabels: podLabels(server),
197+
},
198+
Template: corev1.PodTemplateSpec{
199+
ObjectMeta: metav1.ObjectMeta{
200+
Labels: podLabels(server),
201+
},
202+
Spec: corev1.PodSpec{
203+
Volumes: []corev1.Volume{
204+
{
205+
Name: configVolumeMountName,
206+
VolumeSource: corev1.VolumeSource{
207+
ConfigMap: &corev1.ConfigMapVolumeSource{
208+
LocalObjectReference: corev1.LocalObjectReference{
209+
Name: configMapNameForServer(server),
210+
},
211+
},
212+
},
213+
},
214+
{
215+
Name: forgeInstallerVolumeName,
216+
VolumeSource: corev1.VolumeSource{
217+
EmptyDir: &corev1.EmptyDirVolumeSource{},
218+
},
219+
},
220+
{
221+
Name: forgeWorkingDirVolumeName,
222+
VolumeSource: corev1.VolumeSource{
223+
EmptyDir: &corev1.EmptyDirVolumeSource{},
224+
},
225+
},
226+
{
227+
Name: dataPacksMountName,
228+
VolumeSource: corev1.VolumeSource{
229+
EmptyDir: &corev1.EmptyDirVolumeSource{},
230+
},
231+
},
232+
},
233+
},
234+
},
235+
},
236+
}
237+
238+
if server.Spec.World != nil {
239+
// Persistent world, so mount so PVCs
240+
rs.Spec.Template.Spec.Volumes = append(rs.Spec.Template.Spec.Volumes,
241+
corev1.Volume{
242+
Name: worldMountName,
243+
VolumeSource: corev1.VolumeSource{
244+
PersistentVolumeClaim: server.Spec.World.Overworld,
245+
},
246+
})
247+
} else {
248+
// No world to persist, so mount EmptyDir volumes.
249+
rs.Spec.Template.Spec.Volumes = append(rs.Spec.Template.Spec.Volumes,
250+
corev1.Volume{
251+
Name: worldMountName,
252+
VolumeSource: corev1.VolumeSource{
253+
EmptyDir: &corev1.EmptyDirVolumeSource{},
254+
},
255+
})
256+
}
257+
258+
if server.Spec.VanillaTweaks != nil {
259+
vtDownloadContainer, err := vanillaTweaksDatapackContainer(ctx, dataPacksMountName, server.Spec.MinecraftVersion, server.Spec.VanillaTweaks)
260+
if err != nil {
261+
return appsv1.ReplicaSet{}, err
262+
}
263+
initContainers = append(initContainers, vtDownloadContainer)
264+
}
265+
266+
rs.Spec.Template.Spec.InitContainers = initContainers
267+
rs.Spec.Template.Spec.Containers = append(rs.Spec.Template.Spec.Containers, mainJavaContainer)
268+
269+
// Put the security context on *everything*
270+
for i := range rs.Spec.Template.Spec.InitContainers {
271+
rs.Spec.Template.Spec.InitContainers[i].SecurityContext = SecurityContext()
272+
}
273+
for i := range rs.Spec.Template.Spec.Containers {
274+
rs.Spec.Template.Spec.Containers[i].SecurityContext = SecurityContext()
275+
}
276+
277+
return rs, nil
278+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package minecraftserver
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/jameslaverack/kubernetes-minecraft-operator/api/v1alpha1"
10+
)
11+
12+
func TestForgeDownloadUrl(t *testing.T) {
13+
t.Run("valid URL", func(t *testing.T) {
14+
u, err := forgeDownloadUrl(&v1alpha1.MinecraftServer{
15+
Spec: v1alpha1.MinecraftServerSpec{
16+
MinecraftVersion: "1.18.2",
17+
Forge: &v1alpha1.ForgeSpec{
18+
ForgeVersion: "40.1.80",
19+
},
20+
},
21+
})
22+
require.NoError(t, err)
23+
assert.Equal(t,
24+
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.18.2-40.1.80/forge-1.18.2-40.1.80-installer.jar",
25+
u.String(),
26+
)
27+
})
28+
t.Run("attempted path escape", func(t *testing.T) {
29+
u, err := forgeDownloadUrl(&v1alpha1.MinecraftServer{
30+
Spec: v1alpha1.MinecraftServerSpec{
31+
MinecraftVersion: "1.18.2",
32+
Forge: &v1alpha1.ForgeSpec{
33+
ForgeVersion: "40.1.80/..",
34+
},
35+
},
36+
})
37+
require.NoError(t, err)
38+
assert.NotEqual(t,
39+
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.18.2-40.1.80/../forge-1.18.2-40.1.80/..-installer.jar",
40+
u.String(),
41+
)
42+
})
43+
}

0 commit comments

Comments
 (0)