Skip to content

Commit c23ed16

Browse files
committed
Fix deploy coop maps
1 parent 242ff2a commit c23ed16

8 files changed

Lines changed: 543 additions & 545 deletions

File tree

apps/faf-legacy-deployment/scripts/CoopDeployer.kt

Lines changed: 74 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
import org.apache.commons.compress.archivers.zip.Zip64Mode
1+
@file:Suppress("PackageDirectoryMismatch")
2+
3+
package com.faforever.coopdeployer
4+
5+
import com.faforever.CHECKSUMS_FILENAME
6+
import com.faforever.FafDatabase
7+
import com.faforever.GitRepo
8+
import com.faforever.Log
9+
import com.faforever.extractChecksumsFromZip
10+
import com.faforever.generateChecksums
11+
import com.faforever.md5
212
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
313
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
4-
import org.eclipse.jgit.api.Git
514
import org.slf4j.LoggerFactory
615
import java.io.IOException
716
import java.net.URI
@@ -12,16 +21,8 @@ import java.nio.file.Files
1221
import java.nio.file.Path
1322
import java.nio.file.Paths
1423
import java.nio.file.StandardCopyOption
15-
import java.nio.file.attribute.FileTime
1624
import java.nio.file.attribute.PosixFilePermission
17-
import java.security.MessageDigest
18-
import java.sql.Connection
19-
import java.sql.DriverManager
2025
import java.time.Duration
21-
import java.util.zip.CRC32
22-
import java.util.zip.ZipEntry
23-
import java.util.zip.ZipOutputStream
24-
import kotlin.io.path.inputStream
2526

2627
private val log = LoggerFactory.getLogger("CoopDeployer")
2728

@@ -34,33 +35,6 @@ fun Path.setPerm664() {
3435
Files.setPosixFilePermissions(this, perms)
3536
}
3637

37-
data class FeatureModGitRepo(
38-
val workDir: Path,
39-
val repoUrl: String,
40-
val gitRef: String,
41-
) {
42-
fun checkout(): Path {
43-
if (Files.exists(workDir.resolve(".git"))) {
44-
log.info("Repo exists — fetching and checking out $gitRef...")
45-
Git.open(workDir.toFile()).use { git ->
46-
git.fetch().call()
47-
git.checkout().setName(gitRef).call()
48-
}
49-
} else {
50-
log.info("Cloning repository $repoUrl")
51-
Git.cloneRepository()
52-
.setURI(repoUrl)
53-
.setDirectory(workDir.toFile())
54-
.call()
55-
log.info("Checking out $gitRef")
56-
Git.open(workDir.toFile()).use { git ->
57-
git.checkout().setName(gitRef).call()
58-
}
59-
}
60-
61-
return workDir
62-
}
63-
}
6438

6539
data class GithubReleaseAssetDownloader(
6640
val repoOwner: String = "FAForever",
@@ -160,25 +134,14 @@ data class GithubReleaseAssetDownloader(
160134

161135
}
162136

163-
data class FafDatabase(
164-
val host: String,
165-
val database: String,
166-
val username: String,
167-
val password: String,
137+
data class CoopDatabase(
168138
val dryRun: Boolean
169-
) : AutoCloseable {
139+
) : FafDatabase() {
170140
/**
171141
* Definition of an existing file in the database
172142
*/
173143
data class PatchFile(val mod: String, val fileId: Int, val name: String, val md5: String, val version: Int)
174144

175-
private val connection: Connection =
176-
DriverManager.getConnection(
177-
"jdbc:mariadb://$host/$database?useSSL=false&serverTimezone=UTC",
178-
username,
179-
password
180-
)
181-
182145
fun getCurrentPatchFile(mod: String, fileId: Int): PatchFile? {
183146
val sql = """
184147
SELECT uf.fileId, uf.name, uf.md5, t.v
@@ -191,7 +154,7 @@ data class FafDatabase(
191154
WHERE uf.fileId = ?
192155
""".trimIndent()
193156

194-
connection.prepareStatement(sql).use { stmt ->
157+
prepareStatement(sql).use { stmt ->
195158
stmt.setInt(1, fileId)
196159
val rs = stmt.executeQuery()
197160
while (rs.next()) {
@@ -213,32 +176,25 @@ data class FafDatabase(
213176
}
214177
val del = "DELETE FROM updates_${mod}_files WHERE fileId=? AND version=?"
215178
val ins = "INSERT INTO updates_${mod}_files (fileId, version, name, md5, obselete) VALUES (?, ?, ?, ?, 0)"
216-
connection.prepareStatement(del).use {
179+
prepareStatement(del).use {
217180
it.setInt(1, fileId)
218181
it.setInt(2, version)
219182
it.executeUpdate()
220183
}
221-
connection.prepareStatement(ins).use {
184+
prepareStatement(ins).use {
222185
it.setInt(1, fileId)
223186
it.setInt(2, version)
224187
it.setString(3, name)
225188
it.setString(4, md5)
226189
it.executeUpdate()
227190
}
228191
}
229-
230-
override fun close() {
231-
connection.close()
232-
}
233192
}
234193

235-
private const val MINIMUM_ZIP_DATE = 315532800000L // 1980-01-01
236-
private val MINIMUM_ZIP_FILE_TIME = FileTime.fromMillis(MINIMUM_ZIP_DATE)
237-
238194
class Patcher(
239195
val patchVersion: Int,
240196
val targetDir: Path,
241-
val db: FafDatabase,
197+
val db: CoopDatabase,
242198
val dryRun: Boolean,
243199
) {
244200
/**
@@ -315,16 +271,22 @@ class Patcher(
315271

316272
val tmp = Files.createTempFile("coop", ".zip")
317273
log.info("Zipping sources with base={} -> {}", base, tmp)
318-
zipPreserveStructure(existing, tmp, base)
274+
val newChecksums = zipPreserveStructure(existing, tmp, base)
319275

320-
val newMd5 = tmp.md5()
276+
// Compare checksums.md5 content with existing ZIP (if any)
321277
val oldFile = db.getCurrentPatchFile(mod, fileId)
278+
val existingZip = oldFile?.let { outDir.resolve(it.name) }
279+
val oldChecksums = existingZip?.let { extractChecksumsFromZip(it) }
322280

323-
if (newMd5 == oldFile?.md5) {
281+
if (newChecksums == oldChecksums) {
324282
log.info("{} unchanged from version {}, skipping", name, oldFile.version)
283+
Files.deleteIfExists(tmp)
325284
return
326285
}
327286

287+
// ZIP content changed - compute MD5 for database record
288+
val newMd5 = tmp.md5()
289+
328290
if (!dryRun) {
329291
log.info("Moving zip to {}", target)
330292
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING)
@@ -334,21 +296,10 @@ class Patcher(
334296
db.insertOrReplace(mod, fileId, patchVersion, name, newMd5)
335297
} else {
336298
log.info("[DRYRUN] Would move {} -> {}", tmp, target)
299+
Files.deleteIfExists(tmp)
337300
}
338301
}
339302

340-
private fun Path.md5(): String {
341-
val md = MessageDigest.getInstance("MD5")
342-
this.inputStream().use { input ->
343-
val buf = ByteArray(4096)
344-
var r: Int
345-
while (input.read(buf).also { r = it } != -1) {
346-
md.update(buf, 0, r)
347-
}
348-
}
349-
return md.digest().joinToString("") { "%02x".format(it) }
350-
}
351-
352303
private fun Path.commonPath(other: Path): Path {
353304
val a = toAbsolutePath().normalize()
354305
val b = other.toAbsolutePath().normalize()
@@ -361,30 +312,55 @@ class Patcher(
361312
else a.root.resolve(a.subpath(0, commonCount))
362313
}
363314

364-
private fun zipPreserveStructure(sources: List<Path>, outputFile: Path, base: Path) {
315+
/**
316+
* Create a ZIP archive with checksums.md5 embedded for content-based change detection.
317+
* Returns the checksums.md5 content for comparison purposes.
318+
*/
319+
private fun zipPreserveStructure(sources: List<Path>, outputFile: Path, base: Path): String {
365320
Files.createDirectories(outputFile.parent)
366321

322+
// First, collect all files to include
323+
val allFiles = mutableListOf<Path>()
324+
for (src in sources) {
325+
if (!Files.exists(src)) {
326+
log.warn("Could not find path {}", src)
327+
continue
328+
}
329+
if (Files.isDirectory(src)) {
330+
Files.walk(src).use { stream ->
331+
stream.filter { Files.isRegularFile(it) }.forEach { allFiles.add(it) }
332+
}
333+
} else {
334+
allFiles.add(src)
335+
}
336+
}
337+
338+
// Sort files for deterministic ordering
339+
allFiles.sortBy { base.relativize(it).toString() }
340+
341+
// Generate checksums.md5 content
342+
val checksums = generateChecksums(allFiles, base)
343+
367344
// Never pass a stream here; this will cause extended local headers to be used, making it incompatible to FA!
368345
ZipArchiveOutputStream(outputFile.toFile()).use { zos ->
369346
zos.setMethod(ZipArchiveEntry.DEFLATED)
370347

371-
for (src in sources) {
372-
if (!Files.exists(src)) {
373-
// skip
374-
log.warn("Could not find path {}", src)
375-
continue
376-
}
377-
if (Files.isDirectory(src)) {
378-
Files.walk(src).use { stream ->
379-
stream
380-
.filter { Files.isRegularFile(it) }
381-
.forEach { zos.pushNormalizedFile(base, it) }
382-
}
383-
} else {
384-
zos.pushNormalizedFile(base, src)
385-
}
348+
// Write checksums.md5 as first entry
349+
val checksumBytes = checksums.toByteArray()
350+
val checksumEntry = ZipArchiveEntry(CHECKSUMS_FILENAME).apply {
351+
size = checksumBytes.size.toLong()
352+
}
353+
zos.putArchiveEntry(checksumEntry)
354+
zos.write(checksumBytes)
355+
zos.closeArchiveEntry()
356+
357+
// Write all files
358+
for (path in allFiles) {
359+
zos.pushNormalizedFile(base, path)
386360
}
387361
}
362+
363+
return checksums
388364
}
389365

390366
private fun ZipArchiveOutputStream.pushNormalizedFile(base: Path, path: Path) {
@@ -393,13 +369,7 @@ class Patcher(
393369
val archiveName = base.relativize(path).toString().replace("\\", "/")
394370

395371
// Use the same constructor as the FAF API:
396-
val entry = ZipArchiveEntry(path.toFile(), archiveName).apply {
397-
// Ensure deterministic times
398-
setTime(MINIMUM_ZIP_FILE_TIME)
399-
setCreationTime(MINIMUM_ZIP_FILE_TIME)
400-
setLastModifiedTime(MINIMUM_ZIP_FILE_TIME)
401-
setLastAccessTime(MINIMUM_ZIP_FILE_TIME)
402-
}
372+
val entry = ZipArchiveEntry(path.toFile(), archiveName)
403373

404374
this.putArchiveEntry(entry)
405375
Files.newInputStream(path).use { inp -> inp.copyTo(this) }
@@ -409,22 +379,18 @@ class Patcher(
409379
}
410380

411381
fun main() {
382+
Log.init()
383+
412384
val PATCH_VERSION = System.getenv("PATCH_VERSION") ?: error("PATCH_VERSION required")
413385
val REPO_URL = System.getenv("GIT_REPO_URL") ?: "https://github.com/FAForever/fa-coop.git"
414386
val GIT_REF = System.getenv("GIT_REF") ?: "v$PATCH_VERSION"
415-
val WORKDIR = System.getenv("GIT_WORKDIR") ?: "/tmp/fa-coop-kt"
387+
val WORKDIR = System.getenv("GIT_WORKDIR") ?: "/tmp/fa-coop"
416388
val DRYRUN = (System.getenv("DRY_RUN") ?: "false").lowercase() in listOf("1", "true", "yes")
417-
418-
val DB_HOST = System.getenv("DATABASE_HOST") ?: "localhost"
419-
val DB_NAME = System.getenv("DATABASE_NAME") ?: "faf"
420-
val DB_USER = System.getenv("DATABASE_USERNAME") ?: "root"
421-
val DB_PASS = System.getenv("DATABASE_PASSWORD") ?: "banana"
422-
423389
val TARGET_DIR = Paths.get("./legacy-featured-mod-files")
424390

425391
log.info("=== Kotlin Coop Deployer v{} ===", PATCH_VERSION)
426392

427-
val repo = FeatureModGitRepo(
393+
val repo = GitRepo(
428394
workDir = Paths.get(WORKDIR),
429395
repoUrl = REPO_URL,
430396
gitRef = GIT_REF
@@ -476,13 +442,7 @@ fun main() {
476442
Patcher.PatchFile(25, "FAF_Coop_Operation_Tight_Spot_VO.v%d.nx2", null),
477443
)
478444

479-
FafDatabase(
480-
host = DB_HOST,
481-
database = DB_NAME,
482-
username = DB_USER,
483-
password = DB_PASS,
484-
dryRun = DRYRUN
485-
).use { db ->
445+
CoopDatabase(dryRun = DRYRUN).use { db ->
486446
val patcher = Patcher(
487447
patchVersion = PATCH_VERSION.toInt(),
488448
targetDir = TARGET_DIR,

0 commit comments

Comments
 (0)