Compare commits

..
Author SHA1 Message Date
koka 23b295356b Embed JEI and add a managed dedicated server pack (beta.280)
Build Sanctuary / build (push) Waiting to run
2026-10-25 04:17:18 +01:00
15 changed files with 514 additions and 40 deletions
+10
View File
@@ -1,5 +1,15 @@
# Sanctuary
## beta.280 — JEI réintégré et packwiz serveur
[Un seul JAR Sanctuary avec le fork JEI et un profil packwiz serveur](docs/jei-server-beta280.md).
Client et serveur partagent le même artefact ; le profil serveur ne contient
que Sanctuary et Fabric API. Le [kit Linux](docs/server-pack.md) prépare les
mises à jour et la connexion aux services Web existants. Build, 13 GameTests,
sept contrôles de l'outil de mise à jour, installation packwiz et démarrage du
JAR de production vérifiés. Une nouvelle CT pour 20 joueurs est en préparation
sur `gpu`, séparément de l'Alpha.
Les **quatre expansions validées sont disponibles dans Sanctuary normal** :
Nord glacial, Sud des canyons, Ouest océanique et Est volcanique.
Créer un monde **Sanctuary** en Petit, Moyen ou Grand, puis activer les ancres
+4 -2
View File
@@ -113,8 +113,10 @@ aucune copie des textures de mobs ni aucun ancien pouvoir n’est embarqué.
Just Enough Items, mezz, MIT : fork source de la branche 26.2, commit
`aae2dfcfb82e6b5bce787ba72bb2eda7339b274f`, adapté à Minecraft 26.3-pre-2.
Depuis beta.257, le fork Minecraft 26.3 `30.32.0-sanctuary.4` est un JAR client
séparé et facultatif, avec la licence d’origine et [la provenance du portage](mods/jei/PROVENANCE.md).
Depuis beta.280, le fork Minecraft 26.3 `30.32.0-sanctuary.4` est à nouveau
embarqué dans le JAR Sanctuary, avec la licence d’origine et
[la provenance du portage](mods/jei/PROVENANCE.md). Sa distribution séparée
facultative concernait les beta.257–278.
Les patches et la reconstruction sont fournis ; les téléchargements restent ignorés.
Baked Substring Index 0.1.0 (MIT, James Mitchell) et Suffix Tree 1.1.0
(Apache-2.0, Alessandro Bahgat Shehata) sont inclus avec leurs licences.
+13
View File
@@ -15,6 +15,12 @@ allprojects {
tasks.named('build') { dependsOn(':sanctuary:build', ':jei:build', ':sanctuary-test:build') }
tasks.named('check') { dependsOn(':sanctuary:check', ':jei:check', ':sanctuary-test:check', 'verifyPack') }
tasks.register('verifyServerUpdater', Exec) {
group = 'verification'
commandLine('python3', 'scripts/test_server_runtime.py')
}
tasks.named('check') { dependsOn('verifyServerUpdater') }
tasks.register('verifyPack', Exec) {
group = 'verification'
description = 'Verify pinned pack dependencies, versions and index hashes.'
@@ -28,6 +34,13 @@ tasks.register('assemblePack', Exec) {
commandLine('python3', 'scripts/pack.py', 'assemble')
}
tasks.register('assembleServerPack', Exec) {
group = 'distribution'
description = 'Build the Linux server kit and verify its packwiz profile.'
dependsOn('assemblePack')
commandLine('python3', 'scripts/server_pack.py')
}
tasks.register('verifyResourcePack', Exec) {
group = 'verification'
description = 'Verify the versioned Sanctuary texture sources and their release hashes.'
+63
View File
@@ -0,0 +1,63 @@
# DIST-280 — JEI embarqué et packwiz serveur
Branche `codex/jei-packwiz-server`, base commit `697e4a2` (beta.278).
Version réservée `beta.280` : beta.279 appartient au chantier parallèle
Map Island Zoom, dont les modifications ne sont pas reprises ici.
## Contrat
- Un seul JAR Sanctuary à distribuer, avec le fork JEI exact
`30.32.0-sanctuary.4` et ses notices/bibliothèques embarqués. Fabric API reste
une dépendance commune ; Sodium et Sound Physics restent facultatifs côté client.
- Même JAR sur client et serveur. JEI possède une initialisation commune
(recettes et transfert), ses écrans restent dans son entrée client.
- Un manifeste serveur `server/pack.toml` sur le même canal packwiz que le
client, avec uniquement Sanctuary et Fabric API. Même version, URL et hash
du JAR Sanctuary dans les deux profils. Publication atomique des manifestes
après vérification de l'artefact immuable.
- Un kit serveur local avec mise à jour packwiz sans interface et lancement
Fabric. Les mondes, configurations, permissions, listes de joueurs et secrets
restent hors de l'index. Une erreur de mise à jour empêche le démarrage.
- Documenter les paramètres existants de l'API Sanctuary Web et de la MariaDB
communautaire, sans inventer d'API ni migrer les données ou configurer un site
public implicitement. Les secrets restent dans l'environnement du service.
## Vérifications locales
`./gradlew check build assemblePack assembleServerPack
-PsanctuaryFocusedTests=recipes,progression -PsanctuaryAtlasOnly=true` réussit :
13 GameTests requis, contrôles purs du dépôt et assemblages. Le fixture ciblé
évite le harnais worldgen historique `unified`, sans modifier sa génération.
Les sept tests de l'outil de mise à jour passent séparément, y compris le
relais du signal d'arrêt et l'attente de Java avant libération du verrou.
Installation réelle avec packwiz, deux synchronisations et six fichiers
personnels témoins conservés octet par octet. Le JAR de production démarre et
s'arrête dans un monde plat de développement neuf avec le JEI imbriqué ;
celui-ci correspond au fork construit. Minecraft normalise lui-même les fichiers
`server.properties`, `ops.json` et `whitelist.json` au lancement : leurs valeurs
sont vérifiées après cet arrêt, distinctement de la préservation par packwiz.
La transition du profil client retire réellement le JEI séparé suivi par
packwiz et conserve les favoris. Les manifestes de publication client/serveur
référencent le même artefact. Aucun nouveau parcours graphique n'est revendiqué ;
les contrôles historiques Vulkan de l'interface ne sont pas rejoués ici.
Journaux et reçus ignorés : `build/jei-server-final.log`,
`build/server280-updater-final.log`, `build/server280-validation-delivery/validation.json`,
`build/client280-validation/validation.json`.
## Serveur dédié demandé le 25 octobre 2026
Le créateur demande une **nouvelle CT pour 20 joueurs** sur le nœud `gpu`.
La CT 350 `sanctuary-beta-20` est créée à cette fin : Debian 13 amd64,
6 vCPU, 8 Gio de RAM, disque 80 Gio, non privilégiée. Le budget Java retenu est
6 Gio, avec 20 places, portée 6 et simulation 4 ; ce dimensionnement n'est pas
une mesure de charge avec vingt joueurs connectés.
L'Alpha (CT 300), les CT 330 et 340 et les mondes existants restent hors cible.
Le nouveau monde utilisera `sanctuary:sanctuary` (réglages initiaux
`sanctuary:island231_medium`), graine `28020261025`, version beta.280.
Aucune expansion n'est activée par le déploiement. Le site est dans la CT 320 ;
son API et son schéma communautaire 3 sont présents. La mise en service du
nouveau serveur et le raccordement aux services sont en cours.
+16
View File
@@ -1,5 +1,21 @@
# Distribution packwiz et Prism
## beta.280 — JEI embarqué et profil serveur
JEI rejoint le JAR Sanctuary ; `mods/sanctuary.pw.toml` reste le seul artefact
Sanctuary à publier. `python3 scripts/pack.py release <sanctuary-https-url>`
prépare désormais le client et `server/pack.toml`, qui partagent le même JAR,
la même URL et le même hash. Le profil serveur contient seulement Fabric API
et Sanctuary ; Sodium et Sound Physics restent facultatifs côté client.
Les deux profils sont publiés dans le même commit du canal, après vérification
du JAR immuable. L'ancienne entrée JEI est retirée de l'index client.
`./gradlew assembleServerPack` construit un kit Linux avec les outils épinglés
et les commandes de synchronisation/démarrage. [Mode d'emploi](server-pack.md),
[contrat et vérifications](jei-server-beta280.md). La configuration Sanctuary Web,
MariaDB et les secrets restent hors de l'index. La préparation locale ne publie
pas le canal et ne met pas à jour une installation personnelle.
## beta.257 — Mods client séparés
JEI Sanctuary, Sound Physics Remastered et Sodium sont facultatifs et séparés
+100
View File
@@ -0,0 +1,100 @@
# Sanctuary — serveur Linux avec packwiz
Prérequis : Java 25, Python 3.11 ou plus récent, Linux et accès HTTPS sortant.
Plateforme épinglée : Minecraft **26.3**, Fabric Loader **0.19.5**, Fabric API
**0.160.5+26.3**. Le même JAR Sanctuary contient le fork JEI
**30.32.0-sanctuary.4**, son réseau commun et son interface client.
## Préparer le kit
Dans les sources : `./gradlew check build assemblePack assembleServerPack`.
Le kit est `build/Sanctuary-Server-beta.280.zip` ; les manifestes locaux sont
dans `build/packwiz-server/`. Le ZIP est un amorçage : il télécharge les mods
depuis le canal au premier démarrage, il n'est pas une copie hors ligne du pack.
Il n'est utilisable sur le canal public qu'après publication de cette version.
Décompresser le kit **une seule fois**, dans un dossier serveur neuf. Pour
reprendre une installation existante, arrêter le service, sauvegarder le dossier
entier, vérifier Minecraft/Loader et poser uniquement les fichiers du kit en
conservant le monde et la configuration. Ne pas écraser un fichier déjà présent
sans comparaison. Un ancien JEI externe géré par packwiz est retiré par le nouvel
index ; un JAR ajouté manuellement doit être retiré après sauvegarde pour éviter
de charger une autre version du fork. Conserver `config/jei/`.
Canal prévu, publié avec le client dans le même commit de distribution :
```text
https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/server/pack.toml
```
`./server.sh update` synchronise les mods sans démarrer Minecraft.
`./server.sh start` synchronise puis lance Fabric en console. En cas d'échec,
le serveur ne démarre pas. Accepter l'EULA Minecraft et régler le serveur
explicitement avant ouverture aux joueurs ; le kit ne les remplit pas.
Le lanceur Fabric télécharge les fichiers Mojang/Loader nécessaires au lancement.
Les outils de démarrage sont épinglés par hash dans `.tools/platform.json`.
Un changement de Minecraft ou Loader est refusé avant mise à jour et exige un
nouveau kit et un contrat de migration. Le verrou empêche une mise à jour en
parallèle d'un serveur lancé par ce kit. Arrêter aussi tout service historique
qui utiliserait un autre lanceur.
Variables facultatives : `SANCTUARY_JAVA` (chemin du binaire Java),
`SANCTUARY_MEMORY` (`4G` par défaut), `SANCTUARY_PACK_URL` (autre canal ou
serveur HTTP de laboratoire). Les variables sont héritées de l'environnement ;
le script ne charge pas automatiquement un `.env`.
Un exemple systemd est fourni sous `examples/sanctuary-beta.service`.
Installer le kit dans `/srv/minecraft/sanctuary-beta`, créer l'utilisateur
`minecraft`, donner ce dossier à cet utilisateur et placer l'environnement
privé dans `/etc/sanctuary/beta.env` (root, mode 600). Copier ensuite l'unité
dans `/etc/systemd/system/`, recharger systemd et activer le service. Le mode
`KillMode=mixed` transmet l'arrêt au lanceur, qui attend la sauvegarde Java
avant de rendre le verrou de mise à jour.
## Relier Sanctuary Web
L'API d'accueil et la communauté sont deux services existants distincts.
Le pack ne contient ni secret ni configuration de production.
Pour l'accès/candidature, fournir dans l'environnement du service Minecraft :
```text
SANCTUARY_APP_URL=https://sanctuary-minecraft.net
SANCTUARY_API_TOKEN=<jeton API du site>
```
Le site doit fournir les POST authentifiés `/api/v1/access-codes/verify` et
`/redeem`, avec les reçus d'admission de beta.166. Aucun appel d'essai ne doit
consommer un vrai code de joueur. Sans ces deux paramètres, le mod autonome
laisse l'accueil sans code ; une configuration partielle ferme l'accueil.
Pour Gazette/Tableau et les statistiques, reprendre le **serverId existant**
dans `config/sanctuary-community.json`, configurer `mode: database`, le JDBC
MariaDB, l'utilisateur et `passwordEnv: SANCTUARY_COMMUNITY_DB_PASSWORD`.
Le mot de passe est uniquement dans l'environnement. Utiliser le même UUID dans
`SANCTUARY_COMMUNITY_SERVER_ID` côté Laravel. L'exemple sous `examples/` doit être
complété, puis copié volontairement ; il ne remplace aucune configuration.
Garder `online-mode=true` pour l'identité et la liaison des comptes.
Les migrations SQL sont celles du site et doivent être vérifiées sur la base
cible avant activation. Basculer du mode fichier vers MariaDB n'importe pas les
anciennes publications : cela demande un contrat de migration séparé.
Le kit n'effectue aucune migration SQL ni modification de sauvegarde.
## Mises à jour et retour arrière
Arrêter le service, sauvegarder, lancer `./server.sh update`, vérifier les logs,
puis redémarrer le même dossier serveur. `packwiz.json` doit rester présent.
Le pack ne possède que les mods Fabric API et Sanctuary. Il ne gère ni monde,
`server.properties`, `eula.txt`, `config/`, listes de joueurs, permissions,
captures ou secrets. Les réglages des opérateurs restent en place.
Sauvegarder aussi l'identifiant d'admission et ses reçus avec le monde, ainsi
que les configurations et l'UUID communautaire. Un retour arrière reprend le
dernier artefact immuable et la sauvegarde correspondant à son contrat de
données ; ne pas supprimer de journaux pour forcer le chargement.
Référence : [installation packwiz sur serveur](https://packwiz.infra.link/tutorials/installing/packwiz-installer/#using-a-modpack-with-a-server).
Dans le dépôt, consulter aussi `docs/inscription-web-beta166.md` et
`docs/community-contract-v1.md` pour les contrats et droits SQL complets.
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.278
pack_version=beta.278
mod_version=beta.280
pack_version=beta.280
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.4
+4 -2
View File
@@ -27,8 +27,10 @@ le cache ignoré manque, extrait le commit fixé et applique `patches/26.3.patch
La première reconstruction nécessite Internet, Git, patch et Python 3.
`./gradlew :jei:sourcesJar` produit aussi les sources Java effectivement compilées.
Depuis beta.257, JEI est un JAR client séparé et facultatif. Sanctuary conserve
son pont de catalogue ; le retrait de JEI ne supprime aucune connaissance serveur.
Depuis beta.280, JEI est de nouveau embarqué dans Sanctuary, avec ses métadonnées
Fabric, ses entrées communes/cliente et son pont de catalogue. Il n'est plus
distribué séparément dans packwiz. La séparation facultative beta.257–278
n'a modifié aucune connaissance serveur ; ces données restent conservées.
JEI conserve ses deux bibliothèques internes nécessaires à la recherche :
- `net.mezzdev:baked-substring-index:0.1.0`, MIT, James Mitchell.
+2
View File
@@ -686,6 +686,8 @@ dependencies {
if (providers.gradleProperty('sanctuaryJeiRuntime').map { it.toBoolean() }.getOrElse(true)) {
runtimeOnly project(':jei')
}
// One distributed Sanctuary JAR; keep the exact JEI fork and its own metadata.
include project(':jei')
minecraft "com.mojang:minecraft:${rootProject.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${rootProject.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_api_version}"
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.278"
version = "beta.280"
pack-format = "packwiz:1.1.0"
[index]
+48 -28
View File
@@ -106,7 +106,7 @@ def https_url(value):
return value
def assemble(jar_url=None, jei_url=None):
def assemble(jar_url=None):
values, entries = check()
jar = ROOT / 'mods/sanctuary/build/libs' / ('sanctuary-' + values['mod_version'] + '.jar')
require(jar.is_file(), 'Build Sanctuary before assembling the pack: ' + str(jar))
@@ -122,17 +122,19 @@ def assemble(jar_url=None, jei_url=None):
require('fr/koka/sanctuarytest/QuickTestMod.class' not in archive.namelist(), 'Lab entrypoint leaked into production')
require(not {'jei', 'sound_physics_remastered', 'sodium'} & metadata['depends'].keys(),
'External client mods must not be required by Sanctuary')
embedded_jei = []
for nested_mod in metadata.get('jars', []):
with zipfile.ZipFile(io.BytesIO(archive.read(nested_mod['file']))) as candidate:
if 'fabric.mod.json' in candidate.namelist():
info = json.loads(candidate.read('fabric.mod.json'))
require(info['id'] not in ('jei', 'sound_physics_remastered', 'sodium'),
require(info['id'] not in ('sound_physics_remastered', 'sodium'),
'External client mod is still nested in Sanctuary: ' + info['id'])
if info['id'] == 'jei':
embedded_jei.append(archive.read(nested_mod['file']))
require(all('licenses/sound-physics-remastered/' + name in archive.namelist()
for name in ('LICENSE', 'PROVENANCE.md')), 'Sound Physics notices missing')
built_jei = ROOT / 'mods/jei/build/libs' / f"jei-sanctuary-{values['minecraft_version']}-{values['jei_version']}.jar"
require(built_jei.is_file(), 'Build the separate Sanctuary JEI fork before assembling the pack')
with zipfile.ZipFile(built_jei) as jei:
require(len(embedded_jei) == 1, 'Sanctuary must embed exactly one JEI fork')
with zipfile.ZipFile(io.BytesIO(embedded_jei[0])) as jei:
info = json.loads(jei.read('fabric.mod.json'))
require(info['id'] == 'jei' and info['version'] == values['jei_version'], 'JEI metadata drift')
require(info['depends']['minecraft'] == values['minecraft_version'].replace('-pre-', '-pre.'), 'JEI Minecraft version drift')
@@ -152,7 +154,6 @@ def assemble(jar_url=None, jei_url=None):
info = json.loads(archive.read('fabric.mod.json'))
require(info['id'] == 'sound_physics_remastered' and info['version'] == values['sound_physics_version'], 'Sound Physics version drift')
require(info['depends']['minecraft'] == values['minecraft_version'] + '.x', 'Sound Physics Minecraft version drift')
require(bool(jar_url) == bool(jei_url), 'Release needs immutable URLs for both Sanctuary and JEI')
destination = ROOT / ('build/packwiz-release' if jar_url else 'build/packwiz')
# Only this generated staging directory is replaced; never touch a game instance.
if destination.exists():
@@ -180,36 +181,55 @@ def assemble(jar_url=None, jei_url=None):
else:
shutil.copy2(jar, target)
staged_entries.append({'file': 'mods/' + jar.name, 'hash': sha256(target)})
target = destination / 'mods' / ('jei-sanctuary.pw.toml' if jei_url else built_jei.name)
if jei_url:
target.write_text('\n'.join([
'name = "JEI Sanctuary"', 'filename = ' + json.dumps(built_jei.name),
'side = "client"', '', '[download]',
'url = ' + json.dumps(https_url(jei_url)), 'hash-format = "sha256"',
'hash = ' + json.dumps(sha256(built_jei)), '', '[option]',
'optional = true', 'default = true',
'description = "Catalogue de recettes facultatif. / Optional recipe catalogue."', '',
]), encoding='utf-8')
staged_entries.append({'file': 'mods/jei-sanctuary.pw.toml', 'hash': sha256(target), 'metafile': True})
else:
# Local previews ship the fork as a loose JAR, removable in the launcher.
shutil.copy2(built_jei, target)
staged_entries.append({'file': 'mods/' + built_jei.name, 'hash': sha256(target)})
write_index(destination, staged_entries)
server = destination / 'server' if jar_url else ROOT / 'build/packwiz-server'
if server.exists():
shutil.rmtree(server)
server.mkdir(parents=True)
server_entries = []
for entry in staged_entries:
if entry['file'] not in ('mods/fabric-api.pw.toml', 'mods/sanctuary.pw.toml', 'mods/' + jar.name):
continue
target = server / entry['file']
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(destination / entry['file'], target)
server_entries.append(entry)
write_index(server, server_entries, name='Sanctuary Server')
verify_server(server)
print('Client pack staged at ' + str(destination))
print('Server pack staged at ' + str(server))
def write_index(destination, entries, name=None):
lines = ['hash-format = "sha256"', '']
for entry in sorted(staged_entries, key=lambda e: e['file']):
for entry in sorted(entries, key=lambda e: e['file']):
lines.append('[[files]]')
for key, value in entry.items():
lines.append(key + ' = ' + json.dumps(value))
lines.extend(key + ' = ' + json.dumps(value) for key, value in entry.items())
lines.append('')
index_path = destination / 'index.toml'
index_path.write_text('\n'.join(lines), encoding='utf-8')
manifest = (SOURCE / 'pack.toml').read_text(encoding='utf-8')
if name:
manifest = manifest.replace('name = "Sanctuary"', 'name = ' + json.dumps(name), 1)
manifest, count = re.subn(r'(?m)^hash = "[a-f0-9]+"$',
'hash = "' + sha256(index_path) + '"', manifest)
require(count == 1, 'Expected a single pack index hash')
(destination / 'pack.toml').write_text(manifest, encoding='utf-8')
check_index(destination)
print('Pack staged at ' + str(destination))
def verify_server(base):
manifest, entries = check_index(base)
values = properties()
require(manifest['version'] == values['pack_version'], 'Server pack version drift')
require(manifest['versions'] == read_toml(SOURCE / 'pack.toml')['versions'], 'Server platform drift')
files = {entry['file'] for entry in entries}
sanctuary = 'mods/sanctuary.pw.toml' if 'mods/sanctuary.pw.toml' in files else 'mods/sanctuary-' + values['mod_version'] + '.jar'
require(files == {'mods/fabric-api.pw.toml', sanctuary}, 'Server pack must contain only Fabric API and Sanctuary')
for entry in entries:
if entry.get('metafile'):
require(read_toml(base / entry['file'])['side'] == 'both', 'Client mod in server pack')
return manifest, entries
def prism(pack_url):
@@ -255,10 +275,10 @@ def prism(pack_url):
if __name__ == '__main__':
require((len(sys.argv) == 2 and sys.argv[1] in ('check', 'assemble'))
or (len(sys.argv) == 3 and sys.argv[1] == 'prism')
or (len(sys.argv) == 4 and sys.argv[1] == 'release'),
'Usage: python3 scripts/pack.py check|assemble|release <sanctuary-https-url> <jei-https-url>|prism <pack-https-url>')
or (len(sys.argv) == 3 and sys.argv[1] == 'release'),
'Usage: python3 scripts/pack.py check|assemble|release <sanctuary-https-url>|prism <pack-https-url>')
if sys.argv[1] == 'release':
assemble(https_url(sys.argv[2]), https_url(sys.argv[3]))
assemble(https_url(sys.argv[2]))
elif sys.argv[1] == 'prism':
prism(sys.argv[2])
elif sys.argv[1] == 'assemble':
+7 -5
View File
@@ -124,6 +124,10 @@ def channel_file(relative):
def publish_channel(origin, version, source_head):
staged = ROOT / 'build/packwiz-release'
pack.check_index(staged)
pack.verify_server(staged / 'server')
pack.require((staged / 'mods/sanctuary.pw.toml').read_bytes() ==
(staged / 'server/mods/sanctuary.pw.toml').read_bytes(),
'Client and server must publish the same Sanctuary artifact')
files = [path for path in staged.rglob('*') if path.is_file()]
pack.require(all(channel_file(path.relative_to(staged)) for path in files),
'Distribution branch must contain only generated TOML metadata and the pack icon')
@@ -183,10 +187,9 @@ def main():
mod = json.loads(archive.read('fabric.mod.json'))
pack.require(mod['id'] == 'sanctuary' and mod['version'] == values['mod_version'],
'Built JAR does not match the Sanctuary source version')
jei = ROOT / 'mods/jei/build/libs' / f"jei-sanctuary-{values['minecraft_version']}-{values['jei_version']}.jar"
# Validate every artifact before creating a public release or uploading a file.
pack.assemble()
assets = [jar, jei] + [path.resolve() for path in args.asset]
assets = [jar] + [path.resolve() for path in args.asset]
pack.require(all(path.is_file() for path in assets), 'A requested release asset is missing')
pack.require(len({path.name for path in assets}) == len(assets), 'Release asset names must be unique')
notes = args.notes_file.read_text(encoding='utf-8') if args.notes_file else (
@@ -207,10 +210,9 @@ def main():
pack.require(not release.get('draft'), 'The existing release is a draft; publish it explicitly first')
pack.require(remote_tag_commit(tag) == head, 'Release tag must resolve to the verified source commit')
jar_url = gitea.upload(release, jar)
jei_url = gitea.upload(release, jei)
for asset in assets[2:]:
for asset in assets[1:]:
gitea.upload(release, asset)
pack.assemble(jar_url, jei_url)
pack.assemble(jar_url)
publish_channel(origin, version, head)
stable_url = gitea.repo_url + '/raw/branch/' + CHANNEL + '/pack.toml'
with urlopen(stable_url, timeout=60) as response:
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Build a Linux server bootstrap kit; never install into an existing server."""
import hashlib
import json
from pathlib import Path
from urllib.request import urlopen
import zipfile
import pack
TOOLS = {
'fabric-server-launch.jar': (
'https://meta.fabricmc.net/v2/versions/loader/26.3/0.19.5/1.1.2/server/jar',
'0b56ad54d762172e8b8748e467f584f071e4dedecd93dc336cf2c68837e790be'),
'packwiz-installer.jar': (
'https://github.com/packwiz/packwiz-installer/releases/download/v0.5.14/packwiz-installer.jar',
'c9f646908d340d84773948a9a7d98bc1dae250d35e1016dc6e2b8459760b5598'),
'packwiz-installer-bootstrap.jar': (pack.BOOTSTRAP_URL, pack.BOOTSTRAP_SHA256),
}
PACK_URL = 'https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/server/pack.toml'
def put(archive, name, data, mode=0o100644):
entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
entry.compress_type = zipfile.ZIP_DEFLATED
entry.external_attr = mode << 16
archive.writestr(entry, data)
def assemble():
values = pack.properties()
manifest, _ = pack.verify_server(pack.ROOT / 'build/packwiz-server')
pack.require(manifest['versions'] == {'minecraft': '26.3', 'fabric': '0.19.5'},
'Re-pin the server launcher for an explicit Minecraft/Fabric migration')
cache = pack.ROOT / 'build/server-tools'
cache.mkdir(parents=True, exist_ok=True)
for name, (url, digest) in TOOLS.items():
target = cache / name
if not target.is_file():
with urlopen(url, timeout=45) as response:
data = response.read()
pack.require(hashlib.sha256(data).hexdigest() == digest, 'Server tool hash mismatch: ' + name)
target.write_bytes(data)
pack.require(pack.sha256(target) == digest, 'Cached server tool hash mismatch: ' + name)
platform = {'versions': manifest['versions'], 'pack_url': PACK_URL,
'sha256': {name: digest for name, (_, digest) in TOOLS.items()}}
output = pack.ROOT / 'build' / f'Sanctuary-Server-{values["pack_version"]}.zip'
with zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as archive:
for name in TOOLS:
put(archive, '.tools/' + name, (cache / name).read_bytes())
put(archive, '.tools/server.py', (pack.ROOT / 'scripts/server_runtime.py').read_bytes())
put(archive, '.tools/platform.json', json.dumps(platform, indent=2) + '\n')
put(archive, 'server.sh', '#!/bin/sh\nset -eu\ncd "$(dirname "$0")"\nexec python3 .tools/server.py "$@"\n', mode=0o100755)
put(archive, 'README.md', (pack.ROOT / 'docs/server-pack.md').read_bytes())
# Examples are deliberately outside config/ and outside the pack index.
put(archive, 'examples/sanctuary-community.json.example', json.dumps({
'mode': 'database', 'serverId': 'REPLACE_WITH_EXISTING_SERVER_UUID',
'jdbcUrl': 'jdbc:mariadb://db.example.net:3306/sanctuary?sslMode=verify-full',
'user': 'sanctuary_game', 'passwordEnv': 'SANCTUARY_COMMUNITY_DB_PASSWORD',
}, indent=2) + '\n')
put(archive, 'examples/sanctuary.env.example',
'SANCTUARY_APP_URL=https://sanctuary-minecraft.net\n'
'SANCTUARY_API_TOKEN=\nSANCTUARY_COMMUNITY_DB_PASSWORD=\n'
'SANCTUARY_MEMORY=4G\n')
put(archive, 'examples/sanctuary-beta.service',
'[Unit]\nDescription=Sanctuary Beta Minecraft server\n'
'Wants=network-online.target\nAfter=network-online.target\n\n'
'[Service]\nType=simple\nUser=minecraft\nGroup=minecraft\n'
'WorkingDirectory=/srv/minecraft/sanctuary-beta\n'
'EnvironmentFile=/etc/sanctuary/beta.env\n'
'ExecStart=/srv/minecraft/sanctuary-beta/server.sh start\n'
'Restart=on-failure\nRestartSec=30\nTimeoutStopSec=90\nKillMode=mixed\n'
'UMask=0077\nNoNewPrivileges=true\nPrivateTmp=true\nProtectHome=true\n'
'ProtectSystem=strict\nReadWritePaths=/srv/minecraft/sanctuary-beta\n'
'LimitNOFILE=65535\n\n[Install]\nWantedBy=multi-user.target\n')
print('Server bootstrap kit: ' + str(output))
if __name__ == '__main__':
assemble()
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Linux server kit entrypoint. Configuration and worlds are never pack-owned."""
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path
import signal
import subprocess
import tomllib
from urllib.request import urlopen
def update(root, platform, url, java):
# packwiz updates mods, not Minecraft/Loader. Refuse an implicit platform migration.
with urlopen(url, timeout=30) as response:
manifest = tomllib.loads(response.read(65536).decode('utf-8'))
if manifest['versions'] != platform['versions']:
raise SystemExit('Minecraft/Fabric changed: prepare an explicit migration before updating.')
tools = root / '.tools'
for name, digest in platform['sha256'].items():
if hashlib.sha256((tools / name).read_bytes()).hexdigest() != digest:
raise SystemExit('Server tool hash mismatch: ' + name)
subprocess.run([java, '-jar', str(tools / 'packwiz-installer-bootstrap.jar'),
'--bootstrap-main-jar', str(tools / 'packwiz-installer.jar'),
'--bootstrap-no-update', '-g', '-s', 'server',
'--pack-folder', str(root), url], cwd=root, check=True)
def run_minecraft(root, java, memory):
process = subprocess.Popen([java, '-Xms512M', '-Xmx' + memory, '-jar',
str(root / '.tools/fabric-server-launch.jar'), 'nogui'], cwd=root)
def stop(signum, _frame):
process.send_signal(signum)
previous = {s: signal.signal(s, stop) for s in (signal.SIGTERM, signal.SIGINT)}
try:
# Keep the update lock until Java has finished saving during shutdown.
return process.wait()
finally:
for signum, handler in previous.items():
signal.signal(signum, handler)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=('update', 'start'), nargs='?', default='start')
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
platform = json.loads((root / '.tools/platform.json').read_text())
java = os.environ.get('SANCTUARY_JAVA', 'java')
with (root / '.sanctuary-server.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise SystemExit('Server is running or an update is already in progress.')
update(root, platform, os.environ.get('SANCTUARY_PACK_URL', platform['pack_url']), java)
if args.command == 'start':
memory = os.environ.get('SANCTUARY_MEMORY', '4G')
# No shell evaluation of Java options or credentials.
raise SystemExit(run_minecraft(root, java, memory))
if __name__ == '__main__':
try:
main()
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
# Do not print environment values or HTTP response bodies.
raise SystemExit('Server operation failed (' + type(error).__name__ + ').')
+95
View File
@@ -0,0 +1,95 @@
"""Guard the updater's platform boundary and failure behaviour without a live server."""
import hashlib
import io
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
# Reuse pack's macOS Python 3.11+ selection before importing tomllib users.
import pack
import server_runtime
class ServerUpdateTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
(self.root / '.tools').mkdir()
(self.root / '.tools/tool.jar').write_bytes(b'verified-tool')
self.platform = {'versions': {'minecraft': '26.3', 'fabric': '0.19.5'},
'sha256': {'tool.jar': hashlib.sha256(b'verified-tool').hexdigest()}}
self.platform['pack_url'] = 'https://pack.example/pack.toml'
(self.root / '.tools/platform.json').write_text(json.dumps(self.platform))
def manifest(self, minecraft='26.3', loader='0.19.5'):
return io.BytesIO(f'[versions]\nminecraft="{minecraft}"\nfabric="{loader}"\n'.encode())
def test_headless_server_update_uses_pinned_tools_and_instance(self):
with patch('server_runtime.urlopen', return_value=self.manifest()), patch('server_runtime.subprocess.run') as run:
server_runtime.update(self.root, self.platform, 'https://pack.example/pack.toml', '/java path/java')
args = run.call_args.args[0]
self.assertEqual(args[0], '/java path/java')
self.assertEqual(args[args.index('-s') + 1], 'server')
self.assertIn('-g', args)
self.assertIn('--bootstrap-no-update', args)
self.assertEqual(args[args.index('--pack-folder') + 1], str(self.root))
self.assertTrue(run.call_args.kwargs['check'])
def test_platform_changes_stop_before_packwiz(self):
for minecraft, loader in [('26.4', '0.19.5'), ('26.3', '0.20.0')]:
with self.subTest(minecraft=minecraft, loader=loader), patch('server_runtime.urlopen', return_value=self.manifest(minecraft, loader)), patch('server_runtime.subprocess.run') as run:
with self.assertRaisesRegex(SystemExit, 'explicit migration'):
server_runtime.update(self.root, self.platform, 'https://pack.example/pack.toml', 'java')
run.assert_not_called()
def test_tool_hash_mismatch_stops_before_packwiz(self):
(self.root / '.tools/tool.jar').write_bytes(b'changed')
with patch('server_runtime.urlopen', return_value=self.manifest()), patch('server_runtime.subprocess.run') as run:
with self.assertRaisesRegex(SystemExit, 'hash mismatch'):
server_runtime.update(self.root, self.platform, 'https://pack.example/pack.toml', 'java')
run.assert_not_called()
def test_update_failure_propagates_and_personal_files_are_untouched(self):
settings = self.root / 'server.properties'
settings.write_bytes(b'online-mode=true\n')
with patch('server_runtime.urlopen', return_value=self.manifest()), patch('server_runtime.subprocess.run', side_effect=subprocess.CalledProcessError(1, 'installer')):
with self.assertRaises(subprocess.CalledProcessError):
server_runtime.update(self.root, self.platform, 'https://pack.example/pack.toml', 'java')
self.assertEqual(settings.read_bytes(), b'online-mode=true\n')
def test_failed_update_prevents_minecraft_start(self):
with patch('server_runtime.__file__', str(self.root / '.tools/server.py')), patch('sys.argv', ['server.py', 'start']), patch('server_runtime.update', side_effect=OSError('unreachable')), patch('server_runtime.subprocess.run') as run:
with self.assertRaises(OSError):
server_runtime.main()
run.assert_not_called()
def test_running_server_prevents_an_update(self):
with (self.root / '.sanctuary-server.lock').open('a') as lock:
server_runtime.fcntl.flock(lock, server_runtime.fcntl.LOCK_EX)
with patch('server_runtime.__file__', str(self.root / '.tools/server.py')), patch('sys.argv', ['server.py', 'update']), patch('server_runtime.update') as update:
with self.assertRaisesRegex(SystemExit, 'already in progress'):
server_runtime.main()
update.assert_not_called()
def test_shutdown_waits_for_java_before_releasing_the_lock(self):
handlers = {}
def install(signum, handler):
handlers[signum] = handler
return 'original-handler'
with patch('server_runtime.signal.signal', side_effect=install), patch('server_runtime.subprocess.Popen') as popen:
process = popen.return_value
def wait():
handlers[server_runtime.signal.SIGTERM](server_runtime.signal.SIGTERM, None)
return 0
process.wait.side_effect = wait
self.assertEqual(server_runtime.run_minecraft(self.root, 'java', '8G'), 0)
process.send_signal.assert_called_once_with(server_runtime.signal.SIGTERM)
self.assertTrue(all(h == 'original-handler' for h in handlers.values()))
if __name__ == '__main__':
unittest.main()