diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 92684562b..359efdcfc 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -46,7 +46,7 @@ jobs: - name: Install Prerequisites run: | brew update - brew install icu4c libdeflate zstd argon2 + brew install icu4c libdeflate argon2 - name: Set Library Path run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV - name: Build @@ -124,11 +124,14 @@ jobs: dnf config-manager --set-enabled crb dnf install -y epel-release if: ${{ matrix.epel }} + # Runtime packages only, deliberately. Installing the -dev packages here would add the + # unversioned .so symlink and mask the very thing the binding packages now probe for, so a + # regression in versioned-SONAME resolution would sail through CI. - name: Install Prerequisites using dnf - run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel + run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate libargon2 if: ${{ matrix.packageManager == 'dnf' }} - name: Install Prerequisites using apt - run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata + run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate0 libargon2-1 tzdata if: ${{ matrix.packageManager == 'apt' }} - uses: actions/checkout@v7 with: diff --git a/Projects/BuildTool/BuildOptions.cs b/Projects/BuildTool/BuildOptions.cs index 9aea5010d..9e1d56388 100644 --- a/Projects/BuildTool/BuildOptions.cs +++ b/Projects/BuildTool/BuildOptions.cs @@ -14,4 +14,11 @@ public sealed class BuildOptions public string? Arch { get; set; } public bool SkipPrereqs { get; set; } public bool Interactive { get; set; } + + /// + /// Report the native library prerequisites and exit. The interactive flow is the only other + /// path that runs these checks, so without this there is no way to verify a deployment target + /// from a script or a container. + /// + public bool CheckPrereqsOnly { get; set; } } diff --git a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs index 4f84aa373..8050f6533 100644 --- a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs +++ b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using BuildTool.Platform; using BuildTool.Publishing; @@ -31,8 +32,9 @@ public static class NativeLibraryChecker "Linux", [ ".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0", - "Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev", - "Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel", + "Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN", + " (libicuNN varies by release, e.g. libicu76 — run build-tool --check-prereqs there for the exact name)", + "Fedora/RHEL: sudo dnf install -y libdeflate libargon2 libicu", "CentOS: Also requires epel-release and CRB enabled" ] ), @@ -176,82 +178,70 @@ public static class NativeLibraryChecker return results; } + /// + /// Libraries the server needs from the system on Linux. + /// + /// zstd is absent because ZstdNet bundles libzstd for every RID. liburing is absent because + /// IORingGroup issues io_uring syscalls directly and imports only libc, libSystem.dylib, + /// kernel32.dll, kernelbase.dll and ws2_32.dll. + /// + /// ICU is here because the server does not set InvariantGlobalization and its runtimeconfig + /// sets System.Globalization.PredefinedCulturesOnly to false, so it genuinely needs ICU. + /// + /// MaxSoVersion bounds the dlopen fallback used when ldconfig cannot answer. It is per library + /// because the SONAME digit is: libdeflate is .so.0 and libargon2 is .so.1 on the same machine, + /// while ICU tracks its own release train and was .so.74 on Ubuntu 24.04, .so.76 on Alpine and + /// .so.77 on Fedora. A single small bound silently reports ICU missing when it is installed. + /// + private static readonly (string Name, int MaxSoVersion)[] _linuxLibraries = + [ + ("libicuuc", 99), + ("libdeflate", 9), + ("libargon2", 9) + ]; + private static List CheckLinux(PlatformInfo platform) { - return platform.PackageManager switch - { - PackageManager.Apt => CheckLinuxApt(), - PackageManager.Dnf => CheckLinuxDnf(platform), - _ => CheckLinuxGeneric(platform) - }; - } + // Ask whether the loader can find each library rather than whether a named package is + // installed. Package names were why the -dev packages were mandated, and no hardcoded name + // works for ICU anyway: its apt package is release-specific (libicu72, libicu74, ...). + // ldconfig -p is the loader's own cache, so matching on the "libfoo.so" prefix covers + // libdeflate.so.0, libargon2.so.1 and libicuuc.so.76 alike. + // + // It is only ever a fast *positive* signal. musl's ldconfig exits 0 while producing no + // usable cache, so trusting a negative from it reports every library missing on Alpine even + // when all of them are installed. A cache can also be stale or omit LD_LIBRARY_PATH. + // Anything it does not vouch for gets dlopen'd for real before being called missing. + var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p"); + var cache = ldResult.Success ? ldResult.StandardOutput : null; - private static List CheckLinuxApt() - { var results = new List(); - var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev" }; var missing = new List(); - foreach (var package in packages) + foreach (var (name, maxSoVersion) in _linuxLibraries) { - var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}"); - var installed = result.Success && result.StandardOutput.Contains("ii"); + var found = cache?.Contains($"{name}.so", StringComparison.Ordinal) == true || + CanLoad(name, maxSoVersion); - if (!installed) + if (!found) { - missing.Add(package); + missing.Add(name); } results.Add(new PrerequisiteResult { - Name = package, - Passed = installed, - Details = installed ? "Installed" : "Not installed" + Name = name, + Passed = found, + Details = found ? "Found" : "Not found" }); } - if (missing.Count > 0) + if (missing.Count == 0) { - results.Add(new PrerequisiteResult - { - Name = "Install all missing", - Passed = false, - IsWarning = true, - Details = "Run the following command to install all missing dependencies:", - InstallCommand = $"sudo apt-get install -y {string.Join(' ', missing)}" - }); + return results; } - return results; - } - - private static List CheckLinuxDnf(PlatformInfo platform) - { - var results = new List(); - var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel" }; - var missing = new List(); - - foreach (var package in packages) - { - var result = ProcessRunner.RunCaptured("rpm", $"-q {package}"); - var installed = result.Success; - - if (!installed) - { - missing.Add(package); - } - - results.Add(new PrerequisiteResult - { - Name = package, - Passed = installed, - Details = installed ? "Installed" : "Not installed" - }); - } - - // Check if this is CentOS (needs EPEL) - var isCentOs = platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true; - if (isCentOs && missing.Count > 0) + if (platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true) { results.Add(new PrerequisiteResult { @@ -263,48 +253,106 @@ public static class NativeLibraryChecker }); } - if (missing.Count > 0) + results.Add(new PrerequisiteResult { - results.Add(new PrerequisiteResult - { - Name = "Install all missing", - Passed = false, - IsWarning = true, - Details = "Run the following command to install all missing dependencies:", - InstallCommand = $"sudo dnf install -y {string.Join(' ', missing)}" - }); - } + Name = "Install all missing", + Passed = false, + IsWarning = true, + Details = "Install the runtime libraries. The -dev/-devel packages are not required:", + InstallCommand = BuildInstallCommand(platform, missing) + }); return results; } - private static List CheckLinuxGeneric(PlatformInfo platform) + /// + /// Asks the loader directly, for when ldconfig cannot answer. Mirrors the binding packages' + /// own probing: the unversioned name first, then libfoo.so.N descending. Bare names go through + /// the full loader search path, so LD_LIBRARY_PATH and /etc/ld.so.conf.d still apply. + /// + private static bool CanLoad(string library, int maxSoVersion) { - var results = new List(); - - // Use ldconfig to check for shared libraries - var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p"); - var ldOutput = ldResult.Success ? ldResult.StandardOutput : ""; - - var libraries = new Dictionary + if (TryLoadAndFree($"{library}.so")) { - ["libicu"] = "libicuuc", - ["libdeflate"] = "libdeflate", - ["zstd"] = "libzstd", - ["libargon2"] = "libargon2" - }; - - foreach (var (name, soName) in libraries) - { - var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase); - results.Add(new PrerequisiteResult - { - Name = name, - Passed = found, - Details = found ? "Found" : "Not found — install using your package manager" - }); + return true; } - return results; + for (var soVersion = maxSoVersion; soVersion >= 0; soVersion--) + { + if (TryLoadAndFree($"{library}.so.{soVersion}")) + { + return true; + } + } + + return false; + } + + private static bool TryLoadAndFree(string candidate) + { + if (!NativeLibrary.TryLoad(candidate, out var handle)) + { + return false; + } + + NativeLibrary.Free(handle); + return true; + } + + private static string BuildInstallCommand(PlatformInfo platform, List missing) + { + switch (platform.PackageManager) + { + case PackageManager.Apt: + { + var packages = missing.Select( + library => library switch + { + "libdeflate" => "libdeflate0", + "libargon2" => "libargon2-1", + _ => ResolveAptIcuPackage() + } + ); + + return $"sudo apt-get install -y {string.Join(' ', packages)}"; + } + case PackageManager.Dnf: + { + var packages = missing.Select( + library => library switch + { + "libdeflate" => "libdeflate", + "libargon2" => "libargon2", + _ => "libicu" + } + ); + + return $"sudo dnf install -y {string.Join(' ', packages)}"; + } + default: + return $"Install your distribution's runtime packages for: {string.Join(", ", missing)}"; + } + } + + /// + /// ICU's apt package carries the ABI version in its name and there is no stable alias, so ask + /// apt which one this release actually ships instead of printing a name that rots. + /// + private static string ResolveAptIcuPackage() + { + var result = ProcessRunner.RunCaptured("apt-cache", "search --names-only ^libicu[0-9]+$"); + if (!result.Success) + { + return "libicu"; + } + + var best = result.StandardOutput + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Split(' ', 2)[0].Trim()) + .Where(name => name.StartsWith("libicu", StringComparison.Ordinal)) + .OrderBy(name => int.TryParse(name.AsSpan(6), out var version) ? version : 0) + .LastOrDefault(); + + return best ?? "libicu"; } } diff --git a/Projects/BuildTool/Program.cs b/Projects/BuildTool/Program.cs index 3b36066da..dd2b16479 100644 --- a/Projects/BuildTool/Program.cs +++ b/Projects/BuildTool/Program.cs @@ -36,6 +36,29 @@ options.Os ??= detectedPlatform.OsRid; options.Arch ??= detectedPlatform.ArchRid; var rid = $"{options.Os}-{options.Arch}"; +if (options.CheckPrereqsOnly) +{ + var allPassed = true; + + foreach (var result in NativeLibraryChecker.Check(detectedPlatform)) + { + if (!result.IsWarning) + { + Console.WriteLine($"{result.Name,-16} {(result.Passed ? "OK" : "MISSING"),-8} {result.Details}"); + allPassed &= result.Passed; + continue; + } + + Console.WriteLine(result.Details); + if (result.InstallCommand is not null) + { + Console.WriteLine($" {result.InstallCommand}"); + } + } + + return allPassed ? 0 : 1; +} + // Run prerequisite checks unless skipped if (!options.SkipPrereqs) { @@ -108,6 +131,12 @@ static BuildOptions ParseArguments(string[] args) hasNamedArgs = true; break; } + case "--check-prereqs": + { + options.CheckPrereqsOnly = true; + hasNamedArgs = true; + break; + } case "--interactive": { options.Interactive = true; diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index b022c584c..8ad6a610b 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -36,7 +36,7 @@ - + diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 4f230902d..bf288e629 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -40,12 +40,12 @@ false - + - + diff --git a/README.md b/README.md index b61204743..1b76ce804 100644 --- a/README.md +++ b/README.md @@ -87,18 +87,25 @@ dnf install -y dnf-plugins-core dnf config-manager --set-enabled crb dnf install -y epel-release # Prerequisites -dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel +dnf install -y findutils libicu libdeflate libargon2 ``` ### Ubuntu, Debian, etc ```shell apt-get update -y -apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev +apt-get install -y libicu-dev libdeflate0 libargon2-1 ``` +Only the runtime libraries are needed — the `-dev`/`-devel` packages are not. ICU is the exception +on Debian and Ubuntu, where the runtime package carries the ABI version in its name (`libicu74`, +`libicu76`, …) and there is no stable alias, so `libicu-dev` is the version-independent way to pull +it in. Run `./build-tool --check-prereqs` to print the exact packages your release needs. + +`zstd` is no longer listed because ZstdNet bundles `libzstd` for every platform. + ## OSX Requirements ```shell -brew install icu4c libdeflate zstd argon2 +brew install icu4c libdeflate argon2 ``` ## Running the Server