ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
18 changed files with 145336 additions and 0 deletions
92
services/minecraft/bootstrap-tests.fsx
Normal file
92
services/minecraft/bootstrap-tests.fsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#load "bootstrap.fsx"
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.Runtime.InteropServices
|
||||
open System.Security.Cryptography
|
||||
open System.Text
|
||||
open Bootstrap
|
||||
|
||||
let check condition message = if not condition then failwith message
|
||||
let root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))
|
||||
let paths = { Data = root + "/data"; State = root + "/state"; Backups = root + "/backups"; Files = root + "/files" }
|
||||
let salt = Encoding.UTF8.GetBytes "delivered saltatory"
|
||||
let saltHash = SHA256.HashData salt |> Convert.ToHexString |> _.ToLowerInvariant()
|
||||
let config = { Paths = paths; PasswordPath = root + "/password"; Eula = "TRUE"; Args = "libraries/loader/unix_args.txt"; Saltatory = saltHash }
|
||||
let mutable downloads = 0
|
||||
let data = System.Collections.Generic.Dictionary<string, byte[]>()
|
||||
let put key (value: string) = data[key] <- Encoding.UTF8.GetBytes value
|
||||
let hash (value: byte[]) = SHA256.HashData value |> Convert.ToHexString |> _.ToLowerInvariant()
|
||||
|
||||
let manifests =
|
||||
[ "mods", "mods.sha256"; "config", "config.sha256"; "defaultconfigs", "defaultconfigs.sha256"
|
||||
"kubejs", "kubejs.sha256"; "libraries", "libraries.sha256"; "libraries-integratedscripting", "libraries-integratedscripting.sha256"
|
||||
"settings", "settings.sha256"; "tools", "tools.sha256"
|
||||
"world/datapacks", "world-datapacks.sha256"; "world/serverconfig", "world-serverconfig.sha256" ]
|
||||
|> Map.ofList
|
||||
|
||||
let manifest name =
|
||||
let prefix = manifests |> Map.toSeq |> Seq.find (fun (_, inventory) -> inventory = name) |> fst
|
||||
data
|
||||
|> Seq.filter (fun item -> item.Key.StartsWith(prefix + "/"))
|
||||
|> Seq.map (fun item -> hash item.Value + " " + item.Key.Substring(prefix.Length + 1))
|
||||
|> String.concat "\n"
|
||||
|
||||
let fetch key expected path =
|
||||
if not (File.Exists path) || digest path <> expected then
|
||||
downloads <- downloads + 1
|
||||
File.WriteAllBytes(path, data[key])
|
||||
check (digest path = expected) "fixture hash"
|
||||
|
||||
let fetchSaltatory expected path =
|
||||
if not (File.Exists path) || digest path <> expected then
|
||||
downloads <- downloads + 1
|
||||
File.WriteAllBytes(path, salt)
|
||||
check (digest path = expected) "saltatory hash"
|
||||
|
||||
let load () = run config Architecture.X64 manifest fetch fetchSaltatory
|
||||
|
||||
try
|
||||
Directory.CreateDirectory paths.Files |> ignore
|
||||
text (paths.Files + "/server.properties") "level-seed=123"
|
||||
text config.PasswordPath "synthetic-password"
|
||||
put "mods/old.jar" "old"
|
||||
put "mods/saltatory-0.42.1.jar" "pack saltatory"
|
||||
put "config/sample.toml" "config"
|
||||
put "config/chunky/tasks/minecraft/overworld.properties" "artifact task state"
|
||||
put "libraries/loader/unix_args.txt" "loader args"
|
||||
put "settings/user_jvm_args.txt" "-Xmx1G"
|
||||
put "tools/amd64/mc-server-runner" "runner"
|
||||
put "tools/amd64/mc-monitor" "monitor"
|
||||
put "world/serverconfig/ftbranks/ranks.snbt" "ranks"
|
||||
load ()
|
||||
let first = downloads
|
||||
check (first = 8) "initial downloads"
|
||||
check (not (File.Exists(paths.Data + "/config/chunky/tasks/minecraft/overworld.properties"))) "artifact task state copied"
|
||||
check (Directory.Exists(paths.State + "/world/data/chunky")) "mutable task directory missing"
|
||||
check (digest (paths.Data + "/mods/saltatory.jar") = saltHash) "delivered saltatory missing"
|
||||
check (not (File.Exists(paths.Data + "/mods/saltatory-0.42.1.jar"))) "pack saltatory retained"
|
||||
check (File.ReadAllText(paths.Data + "/server.properties") = "level-seed=123\nrcon.password=synthetic-password\n") "properties"
|
||||
check (File.ReadAllText(paths.Data + "/server_args.txt") = "loader args") "loader args"
|
||||
text (paths.State + "/ops.json") "operators"
|
||||
text (paths.State + "/banned-players.json") "banned"
|
||||
text (paths.State + "/world/serverconfig/ftbranks/players.snbt") "players"
|
||||
text (paths.State + "/world/data/chunky/checkpoint") "task progress"
|
||||
Directory.Delete(paths.Data, true)
|
||||
load ()
|
||||
check (downloads = first) "cached files re-downloaded"
|
||||
check (File.ReadAllText(paths.State + "/ops.json") = "operators") "operators changed"
|
||||
check (File.ReadAllText(paths.State + "/banned-players.json") = "banned") "bans changed"
|
||||
check (File.ReadAllText(paths.State + "/world/serverconfig/ftbranks/players.snbt") = "players") "player state changed"
|
||||
check (File.ReadAllText(paths.State + "/world/data/chunky/checkpoint") = "task progress") "task progress changed"
|
||||
data.Remove "mods/old.jar" |> ignore
|
||||
put "mods/new.jar" "new"
|
||||
text (paths.State + "/cache/config/sample.toml") "corrupt"
|
||||
load ()
|
||||
check (downloads = first + 2) "changed or corrupt files not fetched"
|
||||
check (not (File.Exists(paths.State + "/cache/mods/old.jar"))) "stale cache file"
|
||||
check (not (File.Exists(paths.Data + "/mods/old.jar"))) "stale runtime mod"
|
||||
check (File.ReadAllText(paths.State + "/cache/mods/new.jar") = "new") "new mod missing"
|
||||
printfn "bootstrap tests passed"
|
||||
finally
|
||||
if Directory.Exists root then Directory.Delete(root, true)
|
||||
131
services/minecraft/bootstrap.fsx
Normal file
131
services/minecraft/bootstrap.fsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
module Bootstrap
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.Runtime.InteropServices
|
||||
open System.Security.Cryptography
|
||||
open System.Text
|
||||
|
||||
let digest path =
|
||||
use stream = File.OpenRead path
|
||||
SHA256.HashData stream |> Convert.ToHexString |> _.ToLowerInvariant()
|
||||
|
||||
let write (path: string) (bytes: byte[]) =
|
||||
Directory.CreateDirectory(Path.GetDirectoryName path) |> ignore
|
||||
File.WriteAllBytes(path, bytes)
|
||||
|
||||
let text path value = write path (Encoding.UTF8.GetBytes(value: string))
|
||||
|
||||
let trust source runtime =
|
||||
write (Path.Combine(runtime, "cacerts")) (File.ReadAllBytes source)
|
||||
|
||||
type Paths =
|
||||
{ Data: string
|
||||
State: string
|
||||
Backups: string
|
||||
Files: string }
|
||||
|
||||
type Config =
|
||||
{ Paths: Paths
|
||||
PasswordPath: string
|
||||
Eula: string
|
||||
Args: string
|
||||
Saltatory: string }
|
||||
|
||||
let prefixes =
|
||||
[ "mods", "mods"
|
||||
"config", "config"
|
||||
"defaultconfigs", "defaultconfigs"
|
||||
"kubejs", "kubejs"
|
||||
"libraries", "libraries"
|
||||
"libraries-integratedscripting", "libraries-integratedscripting"
|
||||
"settings", "settings"
|
||||
"tools", "tools" ]
|
||||
|
||||
let parse (value: string) =
|
||||
value.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
|> Array.map (fun line ->
|
||||
let line = line.TrimEnd '\r'
|
||||
let hash, path = line.Substring(0, 64), line.Substring(66)
|
||||
hash, path)
|
||||
|
||||
let sync cache prefix inventory includeFile fetchManifest fetch =
|
||||
let root = Path.Combine(cache, prefix)
|
||||
Directory.CreateDirectory root |> ignore
|
||||
let files = fetchManifest inventory |> parse |> Array.filter (snd >> includeFile)
|
||||
|
||||
for hash, name in files do
|
||||
let path = Path.Combine(root, name)
|
||||
Directory.CreateDirectory(Path.GetDirectoryName path) |> ignore
|
||||
fetch (prefix + "/" + name) hash path
|
||||
|
||||
let names = files |> Array.map (snd >> fun name -> Path.GetFullPath(Path.Combine(root, name))) |> Set.ofArray
|
||||
|
||||
for path in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) do
|
||||
if not (names.Contains(Path.GetFullPath path)) then
|
||||
File.Delete path
|
||||
|
||||
files
|
||||
|
||||
let copyFiles source target files =
|
||||
for _, name in files do
|
||||
let path = Path.Combine(target, name)
|
||||
Directory.CreateDirectory(Path.GetDirectoryName path) |> ignore
|
||||
File.Copy(Path.Combine(source, name), path, true)
|
||||
|
||||
let run config arch fetchManifest fetch fetchSaltatory =
|
||||
let p = config.Paths
|
||||
let cache = Path.Combine(p.State, "cache")
|
||||
Directory.CreateDirectory p.Data |> ignore
|
||||
Directory.CreateDirectory p.Backups |> ignore
|
||||
Directory.CreateDirectory(Path.Combine(p.State, "world")) |> ignore
|
||||
Directory.CreateDirectory(Path.Combine(p.State, "world/data/chunky")) |> ignore
|
||||
|
||||
for prefix, target in prefixes do
|
||||
let includeFile (name: string) =
|
||||
(prefix <> "mods" || not (name.StartsWith("saltatory-", StringComparison.OrdinalIgnoreCase)))
|
||||
&& (prefix <> "config" || not (name.StartsWith("chunky/tasks/", StringComparison.Ordinal)))
|
||||
let files = sync cache prefix (prefix + ".sha256") includeFile fetchManifest fetch
|
||||
let destination = Path.Combine(p.Data, target)
|
||||
if Directory.Exists destination then Directory.Delete(destination, true)
|
||||
copyFiles (Path.Combine(cache, prefix)) destination files
|
||||
|
||||
let saltatory = Path.Combine(cache, "saltatory.jar")
|
||||
fetchSaltatory config.Saltatory saltatory
|
||||
File.Copy(saltatory, Path.Combine(p.Data, "mods", "saltatory.jar"), true)
|
||||
|
||||
for name in [ "ops.json"; "whitelist.json"; "banned-ips.json"; "banned-players.json"; "usercache.json" ] do
|
||||
let path = Path.Combine(p.State, name)
|
||||
if not (File.Exists path) then text path "[]"
|
||||
|
||||
let names = Path.Combine(p.State, "usernamecache.json")
|
||||
if not (File.Exists names) then text names "{}"
|
||||
for name in [ "local"; "journeymap" ] do
|
||||
Directory.CreateDirectory(Path.Combine(p.State, name)) |> ignore
|
||||
|
||||
let world = Path.Combine(p.State, "world")
|
||||
for prefix in [ "datapacks"; "serverconfig" ] do
|
||||
let name = "world/" + prefix
|
||||
let files = sync cache name ("world-" + prefix + ".sha256") (fun _ -> true) fetchManifest fetch
|
||||
copyFiles (Path.Combine(cache, name)) (Path.Combine(world, prefix)) files
|
||||
|
||||
let tools =
|
||||
match arch with
|
||||
| Architecture.X64 -> "amd64"
|
||||
| Architecture.Arm64 -> "arm64"
|
||||
| _ -> invalidOp "unsupported architecture"
|
||||
|
||||
for name in [ "mc-server-runner"; "mc-monitor" ] do
|
||||
let source = Path.Combine(p.Data, "tools", tools, name)
|
||||
let path = Path.Combine(p.Data, name)
|
||||
File.Copy(source, path, true)
|
||||
File.SetUnixFileMode(path, enum<UnixFileMode> 0o755)
|
||||
|
||||
let properties = File.ReadAllText(Path.Combine(p.Files, "server.properties")).TrimEnd()
|
||||
let password = File.ReadAllText(config.PasswordPath).TrimEnd()
|
||||
text (Path.Combine(p.Data, "server.properties")) (properties + "\nrcon.password=" + password + "\n")
|
||||
File.Copy(Path.Combine(p.Data, config.Args), Path.Combine(p.Data, "server_args.txt"), true)
|
||||
let args = File.ReadAllText(Path.Combine(p.Data, "settings", "user_jvm_args.txt")).TrimEnd()
|
||||
text (Path.Combine(p.Data, "user_jvm_args.txt")) (args + "\n-Djavax.net.ssl.trustStore=/data/cacerts\n")
|
||||
text (Path.Combine(p.Data, "eula.txt")) ("eula=" + config.Eula.ToLowerInvariant() + "\n")
|
||||
printfn "Minecraft bootstrap ready"
|
||||
199
services/minecraft/k8s/deployment.yaml
Normal file
199
services/minecraft/k8s/deployment.yaml
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: minecraft
|
||||
namespace: minecraft
|
||||
labels:
|
||||
app.kubernetes.io/name: minecraft
|
||||
app.kubernetes.io/instance: skies
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: minecraft
|
||||
app.kubernetes.io/instance: skies
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: minecraft
|
||||
app.kubernetes.io/instance: skies
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
dnsConfig:
|
||||
options:
|
||||
- name: ndots
|
||||
value: "1"
|
||||
imagePullSecrets:
|
||||
- name: minecraft-pull
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: "********-nas"
|
||||
terminationGracePeriodSeconds: 180
|
||||
securityContext:
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
runAsNonRoot: true
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
- name: bootstrap
|
||||
image: registry.quorate.internal/quorate/fsharp-runner@sha256:d7b904f4f0cae9fdba7c7345cdf08c91162245834a8a13658d93396272aeec83
|
||||
command: [/usr/share/dotnet/dotnet, /opt/fsi/fsi.dll, --exec]
|
||||
args: [/bootstrap/run.fsx]
|
||||
env:
|
||||
- name: SSL_CERT_FILE
|
||||
value: /artifacts/ca.crt
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
- name: DOTNET_NOLOGO
|
||||
value: "true"
|
||||
- name: DOTNET_CLI_TELEMETRY_OPTOUT
|
||||
value: "1"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: minecraft
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
volumeMounts:
|
||||
- name: runtime
|
||||
mountPath: /data
|
||||
- name: state
|
||||
mountPath: /state
|
||||
- name: backups
|
||||
mountPath: /backups
|
||||
- name: files
|
||||
mountPath: /files
|
||||
readOnly: true
|
||||
- name: bootstrap
|
||||
mountPath: /bootstrap
|
||||
readOnly: true
|
||||
- name: rcon
|
||||
mountPath: /rcon
|
||||
readOnly: true
|
||||
- name: artifacts
|
||||
mountPath: /artifacts
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: minecraft
|
||||
image: ghcr.io/graalvm/jdk-community:25i4@sha256:3508f7c5ad1e211f353e3a90f5de263574c4b09e40635fdf5a6e5952241f605b
|
||||
workingDir: /data
|
||||
command: [/data/mc-server-runner]
|
||||
args:
|
||||
- --stop-duration
|
||||
- 150s
|
||||
- --named-pipe
|
||||
- /data/console.in
|
||||
- --
|
||||
- java
|
||||
- "@user_jvm_args.txt"
|
||||
- "@server_args.txt"
|
||||
- nogui
|
||||
ports:
|
||||
- name: minecraft
|
||||
containerPort: 25565
|
||||
protocol: TCP
|
||||
- name: metrics
|
||||
containerPort: 19565
|
||||
protocol: TCP
|
||||
- name: rcon
|
||||
containerPort: 25575
|
||||
protocol: TCP
|
||||
resources:
|
||||
requests:
|
||||
cpu: "2"
|
||||
memory: 20Gi
|
||||
limits:
|
||||
memory: 24Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
startupProbe:
|
||||
exec:
|
||||
command: [/data/mc-monitor, status, --host, 127.0.0.1, --port, "25565"]
|
||||
periodSeconds: 10
|
||||
failureThreshold: 90
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: [/data/mc-monitor, status, --host, 127.0.0.1, --port, "25565"]
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
volumeMounts:
|
||||
- name: runtime
|
||||
mountPath: /data
|
||||
- name: state
|
||||
mountPath: /data/ops.json
|
||||
subPath: ops.json
|
||||
- name: state
|
||||
mountPath: /data/whitelist.json
|
||||
subPath: whitelist.json
|
||||
- name: state
|
||||
mountPath: /data/banned-ips.json
|
||||
subPath: banned-ips.json
|
||||
- name: state
|
||||
mountPath: /data/banned-players.json
|
||||
subPath: banned-players.json
|
||||
- name: state
|
||||
mountPath: /data/usercache.json
|
||||
subPath: usercache.json
|
||||
- name: state
|
||||
mountPath: /data/usernamecache.json
|
||||
subPath: usernamecache.json
|
||||
- name: state
|
||||
mountPath: /data/world
|
||||
subPath: world
|
||||
- name: state
|
||||
mountPath: /data/config/chunky/tasks
|
||||
subPath: world/data/chunky
|
||||
- name: state
|
||||
mountPath: /data/local
|
||||
subPath: local
|
||||
- name: state
|
||||
mountPath: /data/journeymap
|
||||
subPath: journeymap
|
||||
- name: backups
|
||||
mountPath: /data/backups
|
||||
volumes:
|
||||
- name: artifacts
|
||||
projected:
|
||||
defaultMode: 0440
|
||||
sources:
|
||||
- secret:
|
||||
name: minecraft-artifacts
|
||||
- secret:
|
||||
name: minecraft-ca
|
||||
items:
|
||||
- key: certificate
|
||||
path: ca.crt
|
||||
- name: rcon
|
||||
secret:
|
||||
secretName: minecraft-rcon
|
||||
defaultMode: 0440
|
||||
- name: bootstrap
|
||||
configMap:
|
||||
name: minecraft-boot
|
||||
- name: state
|
||||
persistentVolumeClaim:
|
||||
claimName: minecraft
|
||||
- name: backups
|
||||
persistentVolumeClaim:
|
||||
claimName: minecraft-backups
|
||||
- name: runtime
|
||||
emptyDir: {}
|
||||
- name: files
|
||||
secret:
|
||||
secretName: minecraft-files
|
||||
items:
|
||||
- key: server.properties
|
||||
path: server.properties
|
||||
18
services/minecraft/k8s/exception.yaml
Normal file
18
services/minecraft/k8s/exception.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
apiVersion: kyverno.io/v2
|
||||
kind: PolicyException
|
||||
metadata:
|
||||
name: minecraft-singleton
|
||||
namespace: kyverno
|
||||
annotations:
|
||||
policies.kyverno.io/reason: Minecraft owns one local world and permits one active server.
|
||||
quorate.internal/exception-owner: workloads
|
||||
spec:
|
||||
exceptions:
|
||||
- policyName: min-replicas
|
||||
ruleNames: [min-replicas]
|
||||
match:
|
||||
any:
|
||||
- resources:
|
||||
kinds: [Deployment]
|
||||
namespaces: [minecraft]
|
||||
names: [minecraft]
|
||||
53
services/minecraft/k8s/metrics.yaml
Normal file
53
services/minecraft/k8s/metrics.yaml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
apiVersion: operator.victoriametrics.com/v1beta1
|
||||
kind: VMPodScrape
|
||||
metadata:
|
||||
name: minecraft
|
||||
namespace: victoriametrics
|
||||
labels:
|
||||
observability.quorate.internal/scrape: "true"
|
||||
spec:
|
||||
namespaceSelector:
|
||||
matchNames: [minecraft]
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: minecraft
|
||||
app.kubernetes.io/instance: skies
|
||||
podMetricsEndpoints:
|
||||
- port: metrics
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
relabelConfigs:
|
||||
- targetLabel: application
|
||||
replacement: minecraft
|
||||
- sourceLabels:
|
||||
- __meta_kubernetes_pod_label_app_kubernetes_io_instance
|
||||
targetLabel: instance
|
||||
- action: labeldrop
|
||||
regex: pod
|
||||
metricRelabelConfigs:
|
||||
- action: labeldrop
|
||||
regex: pod
|
||||
- action: labeldrop
|
||||
regex: (?i)(.*password.*|.*passwd.*|.*secret.*|.*token.*|.*auth.*|.*bearer.*|.*cookie.*|.*dsn.*|.*uri.*|.*url.*|.*cmdline.*|.*command.*|.*args.*|.*env.*)
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: vmagent-minecraft
|
||||
namespace: victoriametrics
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vmagent
|
||||
app.kubernetes.io/instance: main
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: minecraft
|
||||
app.kubernetes.io/name: minecraft
|
||||
app.kubernetes.io/instance: skies
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "19565"
|
||||
protocol: TCP
|
||||
5
services/minecraft/k8s/namespace.yaml
Normal file
5
services/minecraft/k8s/namespace.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: minecraft
|
||||
labels: {gateway.quorate.internal/allow-short: "true"}
|
||||
104
services/minecraft/k8s/network-policy.yaml
Normal file
104
services/minecraft/k8s/network-policy.yaml
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: minecraft
|
||||
namespace: minecraft
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: minecraft
|
||||
app.kubernetes.io/instance: skies
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: grafana
|
||||
app.kubernetes.io/name: grafana
|
||||
app.kubernetes.io/instance: grafana
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "25575"
|
||||
protocol: TCP
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: victoriametrics
|
||||
app.kubernetes.io/name: vmagent
|
||||
app.kubernetes.io/instance: main
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "19565"
|
||||
protocol: TCP
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: short
|
||||
app.kubernetes.io/name: tunnel
|
||||
app.kubernetes.io/instance: short
|
||||
toPorts:
|
||||
- ports: [{port: "25565", protocol: TCP}]
|
||||
- fromEntities: [host]
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "25565"
|
||||
protocol: TCP
|
||||
- fromCIDR:
|
||||
- 10.115.187.218/24
|
||||
- 10.63.81.107/24
|
||||
- 10.162.8.60/24
|
||||
- 10.82.151.234/24
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "25565"
|
||||
protocol: TCP
|
||||
egress:
|
||||
- toFQDNs:
|
||||
- matchName: s3.quorate.internal
|
||||
toPorts:
|
||||
- ports: [{port: "443", protocol: TCP}]
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: versitygw
|
||||
app.kubernetes.io/name: versitygw
|
||||
app.kubernetes.io/instance: versitygw
|
||||
toPorts:
|
||||
- ports: [{port: "7070", protocol: TCP}]
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: kube-system
|
||||
k8s:k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchName: sessionserver.mojang.com
|
||||
- matchName: api.minecraftservices.com
|
||||
- matchName: s3.quorate.internal
|
||||
- toFQDNs:
|
||||
- matchName: sessionserver.mojang.com
|
||||
- matchName: api.minecraftservices.com
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: minecraft-artifacts
|
||||
namespace: versitygw
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: versitygw
|
||||
app.kubernetes.io/instance: versitygw
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: minecraft
|
||||
app.kubernetes.io/name: minecraft
|
||||
app.kubernetes.io/instance: skies
|
||||
toPorts:
|
||||
- ports: [{port: "7070", protocol: TCP}]
|
||||
9
services/minecraft/k8s/route.yaml
Normal file
9
services/minecraft/k8s/route.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: TCPRoute
|
||||
metadata: {name: minecraft, namespace: minecraft}
|
||||
spec:
|
||||
parentRefs:
|
||||
- {name: gateway-short, namespace: kube-system, sectionName: minecraft}
|
||||
rules:
|
||||
- backendRefs:
|
||||
- {name: minecraft, port: 25565}
|
||||
36
services/minecraft/k8s/service.yaml
Normal file
36
services/minecraft/k8s/service.yaml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: minecraft
|
||||
namespace: minecraft
|
||||
labels:
|
||||
io.cilium/bgp-announce: "true"
|
||||
annotations:
|
||||
lbipam.cilium.io/ips: 10.197.181.214
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: Local
|
||||
selector:
|
||||
app.kubernetes.io/name: minecraft
|
||||
app.kubernetes.io/instance: skies
|
||||
ports:
|
||||
- name: minecraft
|
||||
port: 25565
|
||||
targetPort: minecraft
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: minecraft-rcon
|
||||
namespace: minecraft
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: minecraft
|
||||
app.kubernetes.io/instance: skies
|
||||
ports:
|
||||
- name: rcon
|
||||
port: 25575
|
||||
targetPort: rcon
|
||||
protocol: TCP
|
||||
23
services/minecraft/k8s/storage.yaml
Normal file
23
services/minecraft/k8s/storage.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: minecraft
|
||||
namespace: minecraft
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: openebs-hostpath
|
||||
resources:
|
||||
requests:
|
||||
storage: 50Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: minecraft-backups
|
||||
namespace: minecraft
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: openebs-zfspv-1m-zstd
|
||||
resources:
|
||||
requests:
|
||||
storage: 100Gi
|
||||
148
services/minecraft/k8s/vso.yaml
Normal file
148
services/minecraft/k8s/vso.yaml
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: minecraft-vso
|
||||
namespace: minecraft
|
||||
---
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultAuth
|
||||
metadata:
|
||||
name: default
|
||||
namespace: minecraft
|
||||
spec:
|
||||
method: kubernetes
|
||||
mount: kubernetes
|
||||
kubernetes:
|
||||
role: minecraft
|
||||
serviceAccount: minecraft-vso
|
||||
vaultConnectionRef: vault/vault
|
||||
---
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: minecraft-artifacts
|
||||
namespace: minecraft
|
||||
spec:
|
||||
type: kv-v2
|
||||
mount: secret
|
||||
path: versitygw/users/minecraft
|
||||
refreshAfter: 1h
|
||||
destination:
|
||||
name: minecraft-artifacts
|
||||
create: true
|
||||
transformation:
|
||||
excludeRaw: true
|
||||
excludes: [.*]
|
||||
templates:
|
||||
accesskey:
|
||||
text: minecraft
|
||||
secretkey:
|
||||
text: '{{ get .Secrets "secretAccessKey" }}'
|
||||
vaultAuthRef: default
|
||||
---
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: minecraft-ca
|
||||
namespace: minecraft
|
||||
spec:
|
||||
type: kv-v2
|
||||
mount: secret
|
||||
path: ca/internal
|
||||
refreshAfter: 1h
|
||||
destination:
|
||||
name: minecraft-ca
|
||||
create: true
|
||||
transformation:
|
||||
excludeRaw: true
|
||||
vaultAuthRef: default
|
||||
---
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: minecraft
|
||||
namespace: minecraft
|
||||
spec:
|
||||
type: kv-v2
|
||||
mount: secret
|
||||
path: minecraft/server
|
||||
destination:
|
||||
name: minecraft
|
||||
create: true
|
||||
overwrite: true
|
||||
refreshAfter: 1h
|
||||
vaultAuthRef: default
|
||||
rolloutRestartTargets:
|
||||
- kind: Deployment
|
||||
name: minecraft
|
||||
---
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: minecraft-files
|
||||
namespace: minecraft
|
||||
spec:
|
||||
type: kv-v2
|
||||
mount: secret
|
||||
path: minecraft/files
|
||||
destination:
|
||||
name: minecraft-files
|
||||
create: true
|
||||
overwrite: true
|
||||
transformation:
|
||||
excludeRaw: true
|
||||
excludes: [.*]
|
||||
templates:
|
||||
server.properties:
|
||||
text: '{{ get .Secrets "server.properties" }}'
|
||||
refreshAfter: 1h
|
||||
vaultAuthRef: default
|
||||
---
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: minecraft-rcon
|
||||
namespace: minecraft
|
||||
spec:
|
||||
type: kv-v2
|
||||
mount: secret
|
||||
path: minecraft/rcon
|
||||
destination:
|
||||
name: minecraft-rcon
|
||||
create: true
|
||||
transformation:
|
||||
excludeRaw: true
|
||||
excludes: [.*]
|
||||
templates:
|
||||
password:
|
||||
text: '{{ get .Secrets "password" }}'
|
||||
refreshAfter: 1h
|
||||
vaultAuthRef: default
|
||||
rolloutRestartTargets:
|
||||
- kind: Deployment
|
||||
name: minecraft
|
||||
---
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: minecraft-pull
|
||||
namespace: minecraft
|
||||
spec:
|
||||
type: kv-v2
|
||||
mount: secret
|
||||
path: quay/oauth/project-delivery-reader
|
||||
refreshAfter: 5m
|
||||
destination:
|
||||
name: minecraft-pull
|
||||
create: true
|
||||
overwrite: true
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
transformation:
|
||||
excludeRaw: true
|
||||
excludes: [.*]
|
||||
templates:
|
||||
.dockerconfigjson:
|
||||
text: |
|
||||
{{- $token := get .Secrets "token" -}}
|
||||
{"auths":{"registry.quorate.internal":{"username":"$oauthtoken","password":"{{ $token }}","auth":"{{ printf "%s:%s" "$oauthtoken" $token | b64enc }}"}}}
|
||||
vaultAuthRef: default
|
||||
19
services/minecraft/kustomization.yaml
Normal file
19
services/minecraft/kustomization.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- k8s/namespace.yaml
|
||||
- k8s/vso.yaml
|
||||
- k8s/storage.yaml
|
||||
- k8s/deployment.yaml
|
||||
- k8s/service.yaml
|
||||
- k8s/route.yaml
|
||||
- k8s/metrics.yaml
|
||||
- k8s/network-policy.yaml
|
||||
- k8s/exception.yaml
|
||||
configMapGenerator:
|
||||
- name: minecraft-boot
|
||||
namespace: minecraft
|
||||
files:
|
||||
- bootstrap.fsx
|
||||
- store.fsx
|
||||
- run.fsx
|
||||
103
services/minecraft/pack.fsx
Normal file
103
services/minecraft/pack.fsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
open System
|
||||
open System.IO
|
||||
open System.IO.Compression
|
||||
open System.Security.Cryptography
|
||||
open System.Text
|
||||
open System.Text.Json
|
||||
open System.Text.Json.Nodes
|
||||
|
||||
let args = fsi.CommandLineArgs |> Array.skip 1
|
||||
if args.Length <> 3 then failwith "Usage: pack.fsx SOURCE.mrpack CLIENT_DIR OUTPUT_DIR"
|
||||
let source, client, output = args[0], args[1], args[2]
|
||||
let digest algorithm path =
|
||||
use stream = File.OpenRead path
|
||||
(match algorithm with
|
||||
| "sha512" -> SHA512.HashData stream
|
||||
| "sha1" -> SHA1.HashData stream
|
||||
| _ -> invalidArg "algorithm" algorithm)
|
||||
|> Convert.ToHexString
|
||||
|> _.ToLowerInvariant()
|
||||
let bytes (entry: ZipArchiveEntry) =
|
||||
use stream = entry.Open()
|
||||
use memory = new MemoryStream()
|
||||
stream.CopyTo memory
|
||||
memory.ToArray()
|
||||
let text value = Encoding.UTF8.GetBytes(value: string)
|
||||
let json value = JsonValue.Create(value: string) :> JsonNode
|
||||
let shaderMods =
|
||||
set [ "iris-neoforge-1.8.12+mc1.21.1.jar"
|
||||
"monocle-0.2.3.ms.jar"
|
||||
"colorwheel-neoforge-1.3.1-mc1.21.1.jar" ]
|
||||
let shaderConfig =
|
||||
set [ "overrides/config/iris.properties"
|
||||
"overrides/config/iris-excluded.json"
|
||||
"overrides/config/monocle.properties"
|
||||
"overrides/config/colorwheel-client.toml" ]
|
||||
let restores =
|
||||
[ "ftb-essentials-neoforge-2101.1.10.jar", "https://edge.forgecdn.net/files/8442/866/ftb-essentials-neoforge-2101.1.10.jar"
|
||||
"AdvancedBackups-neoforge-1.21-3.7.1.jar", "https://cdn.modrinth.com/data/Jrmoreqs/versions/ufIaRDFo/AdvancedBackups-neoforge-1.21-3.7.1.jar" ]
|
||||
let patcher = "EuphoriaPatcher-1.10.5-r5.9.3-neoforge.jar"
|
||||
let shader = "ComplementaryReimagined_r5.9.3.zip"
|
||||
let patched = "ComplementaryReimagined_r5.9.3 + EuphoriaPatches_1.10.5"
|
||||
let file path url =
|
||||
let local = Path.Combine(client, path)
|
||||
let hashes = JsonObject()
|
||||
hashes["sha512"] <- json (digest "sha512" local)
|
||||
hashes["sha1"] <- json (digest "sha1" local)
|
||||
let env = JsonObject()
|
||||
env["client"] <- json "required"
|
||||
env["server"] <- json "unsupported"
|
||||
let item = JsonObject()
|
||||
item["path"] <- json path
|
||||
item["hashes"] <- hashes
|
||||
item["env"] <- env
|
||||
item["downloads"] <- JsonArray(json url)
|
||||
item["fileSize"] <- JsonValue.Create(FileInfo(local).Length)
|
||||
item :> JsonNode
|
||||
|
||||
let build shaders =
|
||||
use original = ZipFile.OpenRead source
|
||||
let index = original.GetEntry("modrinth.index.json") |> bytes |> Encoding.UTF8.GetString |> JsonNode.Parse
|
||||
let files = index["files"].AsArray()
|
||||
for item in files do
|
||||
let path = item["path"].GetValue<string>()
|
||||
let hashes = item["hashes"]
|
||||
if digest "sha512" (Path.Combine(client, path)) <> hashes["sha512"].GetValue<string>() then
|
||||
failwithf "Source mod differs: %s" path
|
||||
let replaced = restores |> List.map (fun (name, _) -> "mods/" + name) |> Set.ofList |> Set.add ("mods/" + patcher)
|
||||
let selected = JsonArray()
|
||||
for item in files do
|
||||
let path = item["path"].GetValue<string>()
|
||||
if not (replaced.Contains path) && (shaders || not (shaderMods.Contains(Path.GetFileName path))) then
|
||||
selected.Add(item.DeepClone())
|
||||
for name, url in restores do selected.Add(file ("mods/" + name) url)
|
||||
if shaders then
|
||||
selected.Add(file ("mods/" + patcher) "https://cdn.modrinth.com/data/4H6sumDB/versions/QMsRXtSJ/EuphoriaPatcher-1.10.5-r5.9.3-neoforge.jar")
|
||||
selected.Add(file ("shaderpacks/" + shader) "https://cdn.modrinth.com/data/HVnmMxH1/versions/Bqen1mJX/ComplementaryReimagined_r5.9.3.zip")
|
||||
index["files"] <- selected
|
||||
index["name"] <- json (if shaders then "Enigmatica 10 - Custom (Shaders)" else "Enigmatica 10 - Custom (Minimal)")
|
||||
index["versionId"] <- json (if shaders then "1.33.0-shaders.2" else "1.33.0-minimal.2")
|
||||
index["summary"] <- json (if shaders then "Multiplayer pack with Complementary Reimagined and Euphoria Patches." else "Multiplayer pack without the shader renderer or administrative tools.")
|
||||
let entries = System.Collections.Generic.SortedDictionary<string, byte[]>(StringComparer.Ordinal)
|
||||
for entry in original.Entries do
|
||||
if [".jar"; ".zip"; ".mrpack"; ".glsl"; ".fsh"; ".vsh"; ".csh"] |> List.exists (fun suffix -> entry.FullName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) then
|
||||
failwithf "Embedded payload must be a manifest download: %s" entry.FullName
|
||||
if entry.FullName <> "modrinth.index.json" && not (shaderConfig.Contains entry.FullName) then
|
||||
entries.Add(entry.FullName, bytes entry)
|
||||
entries.Add("modrinth.index.json", text (index.ToJsonString(JsonSerializerOptions(WriteIndented = true))))
|
||||
if shaders then
|
||||
entries["overrides/config/iris.properties"] <- text ("shaderPack=" + patched + "\n")
|
||||
let name = if shaders then "e10c-shaders.mrpack" else "e10c.mrpack"
|
||||
let path = Path.Combine(output, name)
|
||||
use destination = File.Open(path, FileMode.CreateNew)
|
||||
use archive = new ZipArchive(destination, ZipArchiveMode.Create)
|
||||
for KeyValue(name, data) in entries do
|
||||
let entry = archive.CreateEntry(name, CompressionLevel.Optimal)
|
||||
entry.LastWriteTime <- DateTimeOffset(1980, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
use stream = entry.Open()
|
||||
stream.Write data
|
||||
printfn "%s files=%d mods=%d" path selected.Count (selected |> Seq.filter (fun item -> item["path"].GetValue<string>().StartsWith("mods/")) |> Seq.length)
|
||||
|
||||
Directory.CreateDirectory output |> ignore
|
||||
build false
|
||||
build true
|
||||
103
services/minecraft/publish.py
Normal file
103
services/minecraft/publish.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
PREFIX = "minecraft/"
|
||||
GROUPS = (
|
||||
"mods",
|
||||
"config",
|
||||
"defaultconfigs",
|
||||
"kubejs",
|
||||
"libraries",
|
||||
"libraries-integratedscripting",
|
||||
"settings",
|
||||
"tools",
|
||||
"world/datapacks",
|
||||
"world/serverconfig",
|
||||
)
|
||||
|
||||
|
||||
def inventory_name(group: str) -> str:
|
||||
return group.replace("/", "-") + ".sha256"
|
||||
|
||||
|
||||
def files_for(source: Path, tools: Path, exporter: Path) -> dict[str, dict[str, Path]]:
|
||||
files: dict[str, dict[str, Path]] = {group: {} for group in GROUPS}
|
||||
for group in ("config", "defaultconfigs", "kubejs", "libraries", "libraries-integratedscripting"):
|
||||
root = source / group
|
||||
files[group] = {p.relative_to(root).as_posix(): p for p in root.rglob("*") if p.is_file()}
|
||||
files["mods"] = {p.name: p for p in (source / "mods").glob("*.jar")}
|
||||
files["mods"][exporter.name] = exporter
|
||||
files["settings"]["user_jvm_args.txt"] = source / "user_jvm_args.txt"
|
||||
files["tools"] = {p.relative_to(tools).as_posix(): p for p in tools.rglob("*") if p.is_file()}
|
||||
ranks = source / "world/serverconfig/ftbranks/ranks.snbt"
|
||||
files["world/serverconfig"]["ftbranks/ranks.snbt"] = ranks
|
||||
return files
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
with path.open("rb") as source:
|
||||
return hashlib.file_digest(source, "sha256").hexdigest()
|
||||
|
||||
|
||||
def publish(client: Any, files: dict[str, dict[str, Path]]) -> None:
|
||||
def upload(item: tuple[str, str, Path]) -> tuple[str, str, str]:
|
||||
group, name, path = item
|
||||
expected = digest(path)
|
||||
key = PREFIX + group + "/" + name
|
||||
with path.open("rb") as source:
|
||||
client.put_object(Bucket="artifacts", Key=key, Body=source)
|
||||
body = client.get_object(Bucket="artifacts", Key=key)["Body"]
|
||||
try:
|
||||
actual = hashlib.file_digest(body, "sha256").hexdigest()
|
||||
finally:
|
||||
body.close()
|
||||
if actual != expected:
|
||||
raise ValueError(f"checksum differs: {key}")
|
||||
return group, name, expected
|
||||
|
||||
entries = [(group, name, path) for group, items in files.items() for name, path in sorted(items.items())]
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
hashes = []
|
||||
for result in executor.map(upload, entries):
|
||||
hashes.append(result)
|
||||
if len(hashes) % 100 == 0:
|
||||
print(f"verified {len(hashes)}/{len(entries)} files", flush=True)
|
||||
for group in GROUPS:
|
||||
lines = [f"{sha} {name}\n" for current, name, sha in hashes if current == group]
|
||||
body = "".join(lines).encode()
|
||||
key = PREFIX + inventory_name(group)
|
||||
client.put_object(Bucket="artifacts", Key=key, Body=body)
|
||||
remote = client.get_object(Bucket="artifacts", Key=key)["Body"]
|
||||
try:
|
||||
if remote.read() != body:
|
||||
raise ValueError(f"inventory differs: {key}")
|
||||
finally:
|
||||
remote.close()
|
||||
print(f"published {group}: {len(lines)} files", flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("tools", type=Path)
|
||||
parser.add_argument("exporter", type=Path)
|
||||
args = parser.parse_args()
|
||||
client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url="https://s3.quorate.internal",
|
||||
region_name="us-east-1",
|
||||
config=Config(s3={"addressing_style": "path"}, max_pool_connections=8),
|
||||
)
|
||||
publish(client, files_for(args.source, args.tools, args.exporter))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
24
services/minecraft/run.fsx
Normal file
24
services/minecraft/run.fsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#load "store.fsx"
|
||||
|
||||
open System
|
||||
open System.Runtime.InteropServices
|
||||
open Bootstrap
|
||||
|
||||
use client = Store.connect "/artifacts"
|
||||
|
||||
run
|
||||
{ Paths =
|
||||
{ Data = "/data"
|
||||
State = "/state"
|
||||
Backups = "/backups"
|
||||
Files = "/files" }
|
||||
Eula = Environment.GetEnvironmentVariable "EULA"
|
||||
Args = "libraries/net/neoforged/neoforge/21.1.253/unix_args.txt"
|
||||
PasswordPath = "/rcon/password"
|
||||
Saltatory = "6f1f573d493dcaeacad327cacfa722af5a07618c2668c93427614982882092d5" }
|
||||
RuntimeInformation.ProcessArchitecture
|
||||
(Store.manifest client)
|
||||
(Store.fetch client)
|
||||
(Store.saltatory client)
|
||||
|
||||
trust "/etc/ssl/certs/java/cacerts" "/data"
|
||||
53
services/minecraft/store-tests.fsx
Normal file
53
services/minecraft/store-tests.fsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#load "store.fsx"
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.Net
|
||||
open System.Text
|
||||
open System.Threading.Tasks
|
||||
open Amazon.Runtime
|
||||
open Amazon.S3
|
||||
open Amazon.S3.Model
|
||||
open Bootstrap
|
||||
|
||||
let check condition message = if not condition then failwith message
|
||||
|
||||
type MemoryStore() =
|
||||
inherit AmazonS3Client(BasicAWSCredentials("fixture", "synthetic-secret"), AmazonS3Config(ServiceURL = "http://127.0.0.1", AuthenticationRegion = "us-east-1"))
|
||||
let mutable reads = 0
|
||||
let mutable key = ""
|
||||
member _.Reads = reads
|
||||
member _.Key = key
|
||||
override _.GetObjectAsync(request: GetObjectRequest, _) =
|
||||
reads <- reads + 1
|
||||
key <- request.Key
|
||||
let bytes = Encoding.UTF8.GetBytes(if request.Key.EndsWith ".sha256" then "inventory" else "synthetic artifact")
|
||||
Task.FromResult(new GetObjectResponse(HttpStatusCode = HttpStatusCode.OK, ResponseStream = new MemoryStream(bytes)))
|
||||
|
||||
let root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))
|
||||
Directory.CreateDirectory root |> ignore
|
||||
|
||||
try
|
||||
use client = new MemoryStore()
|
||||
check (Store.manifest client "mods.sha256" = "inventory") "inventory"
|
||||
let sample = Path.Combine(root, "sample")
|
||||
File.WriteAllText(sample, "synthetic artifact")
|
||||
let expected = digest sample
|
||||
let path = Path.Combine(root, "object")
|
||||
Store.fetch client "mods/a.jar" expected path
|
||||
check (digest path = expected) "download"
|
||||
check (client.Key = "minecraft/mods/a.jar") "pack key"
|
||||
Store.fetch client "mods/a.jar" expected path
|
||||
check (client.Reads = 2) "cache hit"
|
||||
let rejected =
|
||||
try
|
||||
Store.fetch client "mods/a.jar" (String.replicate 64 "0") path
|
||||
false
|
||||
with :? InvalidOperationException -> true
|
||||
check rejected "wrong checksum accepted"
|
||||
check (digest path = expected) "failed download replaced cache"
|
||||
Store.saltatory client expected (Path.Combine(root, "saltatory.jar"))
|
||||
check (client.Key = "saltatory/" + expected + ".jar") "saltatory key"
|
||||
printfn "store tests passed"
|
||||
finally
|
||||
Directory.Delete(root, true)
|
||||
36
services/minecraft/store.fsx
Normal file
36
services/minecraft/store.fsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
module Store
|
||||
|
||||
#r "/opt/fsi/packages/AWSSDK.Core.dll"
|
||||
#r "/opt/fsi/packages/AWSSDK.S3.dll"
|
||||
#load "bootstrap.fsx"
|
||||
|
||||
open System.IO
|
||||
open Amazon.Runtime
|
||||
open Amazon.S3
|
||||
open Amazon.S3.Model
|
||||
open Bootstrap
|
||||
|
||||
let connect auth =
|
||||
let credential name = File.ReadAllText(Path.Combine(auth, name))
|
||||
let config = AmazonS3Config(ServiceURL = "https://s3.quorate.internal", AuthenticationRegion = "us-east-1", ForcePathStyle = true)
|
||||
new AmazonS3Client(BasicAWSCredentials(credential "accesskey", credential "secretkey"), config)
|
||||
|
||||
let request key = GetObjectRequest(BucketName = "artifacts", Key = key)
|
||||
|
||||
let manifest (client: AmazonS3Client) key =
|
||||
use reply = client.GetObjectAsync(request ("minecraft/" + key)).GetAwaiter().GetResult()
|
||||
use reader = new StreamReader(reply.ResponseStream)
|
||||
reader.ReadToEnd()
|
||||
|
||||
let fetchObject (client: AmazonS3Client) key hash path =
|
||||
if not (File.Exists path) || digest path <> hash then
|
||||
let temporary = path + ".download"
|
||||
use reply = client.GetObjectAsync(request key).GetAwaiter().GetResult()
|
||||
use output = File.Create temporary
|
||||
reply.ResponseStream.CopyTo output
|
||||
output.Close()
|
||||
if digest temporary <> hash then invalidOp "download checksum differs"
|
||||
File.Move(temporary, path, true)
|
||||
|
||||
let fetch client key = fetchObject client ("minecraft/" + key)
|
||||
let saltatory client hash = fetchObject client ("saltatory/" + hash + ".jar") hash
|
||||
58
services/minecraft/test_publish.py
Normal file
58
services/minecraft/test_publish.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import publish
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
def __init__(self) -> None:
|
||||
self.objects: dict[str, bytes] = {}
|
||||
|
||||
def put_object(self, *, Bucket: str, Key: str, Body: Any) -> None:
|
||||
assert Bucket == "artifacts"
|
||||
self.objects[Key] = Body if isinstance(Body, bytes) else Body.read()
|
||||
|
||||
def get_object(self, *, Bucket: str, Key: str) -> dict[str, io.BytesIO]:
|
||||
assert Bucket == "artifacts"
|
||||
return {"Body": io.BytesIO(self.objects[Key])}
|
||||
|
||||
|
||||
class PublishTest(unittest.TestCase):
|
||||
def test_publish(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source, tools = root / "pack", root / "tools"
|
||||
for group in ("config", "defaultconfigs", "kubejs", "libraries", "libraries-integratedscripting", "mods"):
|
||||
(source / group).mkdir(parents=True)
|
||||
(source / group / "file").write_text(group)
|
||||
(source / "mods/test.jar").write_bytes(b"mod")
|
||||
(source / "user_jvm_args.txt").write_text("-Xmx1G")
|
||||
ranks = source / "world/serverconfig/ftbranks/ranks.snbt"
|
||||
ranks.parent.mkdir(parents=True)
|
||||
ranks.write_text("ranks")
|
||||
(ranks.parent / "players.snbt").write_text("test player")
|
||||
(source / "server.properties").write_text("test credential")
|
||||
(tools / "amd64").mkdir(parents=True)
|
||||
(tools / "amd64/mc-monitor").write_bytes(b"monitor")
|
||||
exporter = root / "exporter.jar"
|
||||
exporter.write_bytes(b"exporter")
|
||||
files = publish.files_for(source, tools, exporter)
|
||||
client = MemoryStore()
|
||||
publish.publish(client, files)
|
||||
self.assertEqual(set(files["mods"]), {"test.jar", "exporter.jar"})
|
||||
self.assertNotIn("minecraft/server.properties", client.objects)
|
||||
self.assertNotIn("minecraft/world/serverconfig/ftbranks/players.snbt", client.objects)
|
||||
self.assertIn("minecraft/world/serverconfig/ftbranks/ranks.snbt", client.objects)
|
||||
self.assertEqual(client.objects["minecraft/world-datapacks.sha256"], b"")
|
||||
expected = hashlib.sha256(b"mod").hexdigest()
|
||||
self.assertIn(f"{expected} test.jar\n".encode(), client.objects["minecraft/mods.sha256"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue