diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..594ab1d2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,64 @@ +# Version updates. Versions that live in more than one file are guarded by +# PlatformCompatibilityTests, so each group below is shaped to move every copy in one PR. +version: 2 + +# A new SDK changes global.json (dotnet-sdk) and the Dockerfile's sdk stage (docker) together, +# in one PR; separately, each would fail the guard that they agree. +multi-ecosystem-groups: + dotnet-sdk: + schedule: + interval: monthly + +updates: + - package-ecosystem: dotnet-sdk + directory: / + multi-ecosystem-group: dotnet-sdk + patterns: [ "*" ] + ignore: + # Dependabot ignores global.json's rollForward, so a new major is held back here. + - dependency-name: "*" + update-types: [ "version-update:semver-major" ] + + - package-ecosystem: docker + directory: / + multi-ecosystem-group: dotnet-sdk + # The Dockerfile's only images are the SDK and runtime. + patterns: [ "*" ] + ignore: + - dependency-name: "dotnet/*" + versions: [ ">= 11" ] + + # Every NuGet version is in Directory.Packages.props, and dotnet-ef in .config/dotnet-tools.json. + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + ef-core: + patterns: [ "Microsoft.EntityFrameworkCore*", "Pomelo.EntityFrameworkCore.MySql", "dotnet-ef" ] + mstest: + patterns: [ "MSTest.*", "Microsoft.NET.Test.Sdk", "coverlet.*" ] + dotrecast: + patterns: [ "DotRecast.*" ] + other: + patterns: [ "*" ] + ignore: + # Pomelo 9.0.0 supports EF Core 9 only. Moving to EF Core 10 needs a MySQL provider + # decision of its own; Dependabot shouldn't make it. + - dependency-name: "Microsoft.EntityFrameworkCore*" + versions: [ ">= 10" ] + - dependency-name: "dotnet-ef" + versions: [ ">= 10" ] + - dependency-name: "Pomelo.EntityFrameworkCore.MySql" + versions: [ ">= 10" ] + + # Workflows that hold secrets or push images pin actions by commit; this keeps those pins and + # the major tags in the other workflows current. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: [ "*" ] diff --git a/.github/scripts/TestShards.cs b/.github/scripts/TestShards.cs index 33e5c8b6..9d038419 100644 --- a/.github/scripts/TestShards.cs +++ b/.github/scripts/TestShards.cs @@ -14,6 +14,9 @@ // // Every lane but the last lists what it runs; the last runs everything the others don't list. So a // test this script fails to find, or one added since, still runs exactly once, in the last lane. +// +// Tests in an excluded category (ExcludedCategories below: the live-MySQL tests, which run in their +// own job against a MySQL server) are left out of the plan, and every lane's filter excludes them. using System.Reflection; using System.Reflection.Metadata; @@ -26,6 +29,8 @@ return 2; } +string[] ExcludedCategories = ["MySql"]; + var assemblyPath = args[0]; var laneCount = int.Parse(args[1]); var outputDir = args[2]; @@ -37,7 +42,7 @@ return 2; } -var classes = FindTests(assemblyPath); +var classes = FindTests(assemblyPath, ExcludedCategories); if (classes.Count == 0) { Console.Error.WriteLine($"no [TestClass] types found in {assemblyPath}"); @@ -85,6 +90,8 @@ double ClassWeight(string cls) => filter = listed.Count > 0 ? string.Join("&", listed.Select(t => t.Replace("=", "!="))) : "FullyQualifiedName!=__run_everything__"; + // & binds tighter than |, so a lane's list of alternatives is grouped before the exclusion. + filter = $"({filter})" + string.Concat(ExcludedCategories.Select(c => $"&TestCategory!={c}")); File.WriteAllText(Path.Combine(outputDir, $"lane-{i}.filter"), filter); } @@ -111,8 +118,9 @@ double ClassWeight(string cls) => File.AppendAllLines(summary, report.Prepend("### Test lanes").Append("")); return 0; -// Test class full name -> test method name -> number of cases (each [DataRow] is one). -static Dictionary> FindTests(string path) +// Test class full name -> test method name -> number of cases (each [DataRow] is one). A method +// with, or in a class with, a [TestCategory] in excluded isn't listed. +static Dictionary> FindTests(string path, string[] excluded) { using var stream = File.OpenRead(path); using var pe = new PEReader(stream); @@ -122,7 +130,8 @@ static Dictionary> FindTests(string path) foreach (var handle in md.TypeDefinitions) { var type = md.GetTypeDefinition(handle); - if ((type.Attributes & TypeAttributes.Abstract) != 0 || !HasAttribute(md, type.GetCustomAttributes(), "TestClassAttribute")) + if ((type.Attributes & TypeAttributes.Abstract) != 0 || !HasAttribute(md, type.GetCustomAttributes(), "TestClassAttribute") + || Categories(md, type.GetCustomAttributes()).Intersect(excluded).Any()) continue; var methods = new Dictionary(StringComparer.Ordinal); @@ -130,7 +139,8 @@ static Dictionary> FindTests(string path) { var method = md.GetMethodDefinition(methodHandle); var attributes = method.GetCustomAttributes(); - if (!HasAttribute(md, attributes, "TestMethodAttribute") && !HasAttribute(md, attributes, "DataTestMethodAttribute")) + if (!HasAttribute(md, attributes, "TestMethodAttribute") && !HasAttribute(md, attributes, "DataTestMethodAttribute") + || Categories(md, attributes).Intersect(excluded).Any()) continue; methods[md.GetString(method.Name)] = Math.Max(1, attributes.Count(a => AttributeName(md, md.GetCustomAttribute(a)) == "DataRowAttribute")); @@ -146,6 +156,20 @@ static Dictionary> FindTests(string path) static bool HasAttribute(MetadataReader md, CustomAttributeHandleCollection attributes, string name) => attributes.Any(a => AttributeName(md, md.GetCustomAttribute(a)) == name); +// The names in [TestCategory("...")] attributes: a blob of the 0x0001 prolog and one string argument. +static IEnumerable Categories(MetadataReader md, CustomAttributeHandleCollection attributes) +{ + foreach (var handle in attributes) + { + var attribute = md.GetCustomAttribute(handle); + if (AttributeName(md, attribute) != "TestCategoryAttribute") + continue; + var blob = md.GetBlobReader(attribute.Value); + if (blob.ReadUInt16() == 1 && blob.ReadSerializedString() is { } category) + yield return category; + } +} + static string? AttributeName(MetadataReader md, CustomAttribute attribute) { switch (attribute.Constructor.Kind) diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 00000000..f36800fc --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,110 @@ +name: Container + +# Builds the image and starts it with docker-compose.yml, then checks each service listens on +# every port compose maps for it. Not a required check, so a paths filter is safe here. +on: + push: + branches: [ development ] + paths: [ Dockerfile, .dockerignore, docker-compose.yml, global.json, Directory.Packages.props, Rasa.NET.sln, '.config/**', 'navmesh/**', 'src/**', .github/workflows/container.yml ] + pull_request: + paths: [ Dockerfile, .dockerignore, docker-compose.yml, global.json, Directory.Packages.props, Rasa.NET.sln, '.config/**', 'navmesh/**', 'src/**', .github/workflows/container.yml ] + +permissions: + contents: read + +concurrency: + group: container-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + smoke: + name: container smoke test + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + # The layer cache is read on every run and written only from development, for the same + # reason as the NuGet cache in dotnet.yml: a PR's cache can only be read by that PR. + - name: Build the image + id: image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + load: true + push: false + tags: rasa_net + cache-from: type=gha + cache-to: ${{ github.event_name == 'push' && 'type=gha,mode=max' || '' }} + # Compose mounts these from the repository root. The image runs as UID 1654, so they must be + # writable by it. + - name: Create the mounted files + run: | + touch rasaauth.db rasachar.db rasaworld.db + echo '{}' > appsettings.env.json + chmod 666 rasaauth.db rasachar.db rasaworld.db appsettings.env.json + - run: docker compose up --detach --no-build + - name: Wait for both servers + run: | + ready() { docker compose logs --no-color "$1" 2>&1 | grep -q "$2"; } + for _ in $(seq 120); do + if ready auth 'Listening for clients on port' && ready game 'Server ready!'; then + exit 0 + fi + # Both services have restart: always, so a crash shows as a restart, not an exit. + for id in $(docker compose ps --all --quiet); do + if [ "$(docker inspect -f '{{.RestartCount}} {{.State.Status}}' "$id")" != "0 running" ]; then + echo "::error::A service crashed or stopped during startup." + exit 1 + fi + done + sleep 2 + done + echo "::error::The servers weren't ready within 4 minutes." + exit 1 + # Probed from inside each container: a probe of the published port on the host always + # connects, because Docker's proxy accepts the connection itself whether or not anything + # listens behind it. UDP can't be probed this way, so its startup log line is checked. + - name: Check every mapped port + run: | + status=0 + config=$(docker compose config --format json) + for service in auth game; do + for port in $(jq -r --arg s "$service" '.services[$s].ports[]? | select((.protocol // "tcp") == "tcp") | .target' <<< "$config"); do + if docker compose exec -T "$service" bash -c "exec 3<>/dev/tcp/127.0.0.1/$port" 2>/dev/null; then + echo "$service listens on tcp/$port" + else + echo "::error::$service doesn't listen on tcp/$port, which docker-compose.yml maps." + status=1 + fi + done + for port in $(jq -r --arg s "$service" '.services[$s].ports[]? | select(.protocol == "udp") | .target' <<< "$config"); do + if docker compose logs --no-color "$service" | grep -qE "UDP port ${port}([^0-9]|$)"; then + echo "$service listens on udp/$port" + else + echo "::error::$service never logged listening on udp/$port, which docker-compose.yml maps." + status=1 + fi + done + done + exit $status + - name: Check the image runs as a non-root user + run: test "$(docker run --rm rasa_net id -u)" = 1654 + # The containers are left running so their logs can be read here. + - name: Service logs + if: always() + run: docker compose logs --no-color + # Advisory: reports known HIGH and CRITICAL vulnerabilities and never fails the job, not even + # when the vulnerability database can't be downloaded. Its cache is off so PR runs save none. + # Pinned by commit: the trivy-action tags were overwritten with a credential stealer in March + # 2026 (CVE-2026-33634). + - name: Scan the image + if: always() && steps.image.outcome == 'success' + continue-on-error: true + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: rasa_net + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: '0' + cache: 'false' diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 095aa044..fccdb16e 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -9,9 +9,11 @@ permissions: contents: read # A new push to a PR cancels that PR's older run. Runs on development are never cancelled: a -# cancelled run saves no NuGet cache and leaves no timings for the next run's test plan. +# cancelled run saves no NuGet cache, leaves no timings for the next run's test plan, and leaves +# its commit unreleasable. Each push gets a group of its own, since a group holds only one +# pending run and replaces it with the next. concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.ref }}${{ github.event_name == 'push' && format('-{0}', github.sha) || '' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: @@ -78,7 +80,7 @@ jobs: uses: actions/cache/restore@v6 with: path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'global.json', '.config/dotnet-tools.json') }} + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }} restore-keys: nuget-${{ runner.os }}- - name: Restore dependencies run: dotnet restore @@ -100,7 +102,8 @@ jobs: HEAD: ${{ github.head_ref }} run: | last_green() { - gh run list -R "$REPO" --workflow dotnet.yml --status success --limit 3 --json databaseId --jq '.[].databaseId' "$@" 2>/dev/null || true + gh run list -R "$REPO" --workflow dotnet.yml --status success --limit 3 \ + --json databaseId --jq '.[].databaseId' "$@" 2>/dev/null || true } runs=$(last_green --branch development --event push) if [ -z "$runs" ] && [ -n "$HEAD" ]; then @@ -108,12 +111,14 @@ jobs: fi for run in $runs; do echo "Timings from run $run." - gh run download "$run" -R "$REPO" --pattern 'test-results-*' --dir "timings/$run" || echo "::notice::No timings from run $run." + gh run download "$run" -R "$REPO" --pattern 'test-results-*' --dir "timings/$run" \ + || echo "::notice::No timings from run $run." done - name: Plan test lanes id: plan run: | - dotnet run .github/scripts/TestShards.cs -- src/Rasa.Test/bin/Debug/net10.0/Rasa.Test.dll $((RUNNERS * LANES)) src/Rasa.Test/bin/Debug/net10.0/lanes timings + dotnet run .github/scripts/TestShards.cs -- \ + src/Rasa.Test/bin/Debug/net10.0/Rasa.Test.dll $((RUNNERS * LANES)) src/Rasa.Test/bin/Debug/net10.0/lanes timings echo "runners=$(jq -cn --argjson n "$RUNNERS" '[range($n)]')" >> "$GITHUB_OUTPUT" # One archive so file modes survive (upload-artifact doesn't keep them). - name: Package test build @@ -158,7 +163,11 @@ jobs: dotnet test "$bin/Rasa.Test.dll" --list-tests > /dev/null pids=() for lane in $(seq $((RUNNER * LANES)) $((RUNNER * LANES + LANES - 1))); do - dotnet test "$bin/Rasa.Test.dll" --filter "$(cat "$bin/lanes/lane-$lane.filter")" --blame-hang-timeout 10m --logger "trx;LogFileName=lane-$lane.trx" --results-directory "TestResults/lane-$lane" > "lane-$lane.log" 2>&1 & + dotnet test "$bin/Rasa.Test.dll" \ + --filter "$(cat "$bin/lanes/lane-$lane.filter")" \ + --blame-hang-timeout 10m \ + --logger "trx;LogFileName=lane-$lane.trx" \ + --results-directory "TestResults/lane-$lane" > "lane-$lane.log" 2>&1 & pids+=("$lane:$!") done status=0 @@ -178,10 +187,122 @@ jobs: path: TestResults/ retention-days: 14 + # Fails when any of the six EF contexts has model changes no migration covers. The design-time + # factories use a fixed MySQL server version, so no database server is needed. + drift: + name: migration drift + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + - name: Restore NuGet cache + uses: actions/cache/restore@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }} + restore-keys: nuget-${{ runner.os }}- + - run: dotnet tool restore + - run: dotnet build src/Rasa.Game + - name: Check every context for pending model changes + run: | + status=0 + for context in SqliteAuthContext MySqlAuthContext SqliteCharContext MySqlCharContext SqliteWorldContext MySqlWorldContext; do + echo "::group::$context" + if ! dotnet ef migrations has-pending-model-changes --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context"; then + echo "::error::$context has model changes without a migration (or could not be checked)." + status=1 + fi + echo "::endgroup::" + done + exit $status + + # The MySql test category against a real server, after every MySQL migration is applied to it + # from empty, the way a MySQL deployment gets its schema. The other lanes leave this category + # out (TestShards.cs) and only build the MySQL model offline. + mysql: + name: mysql (${{ matrix.mysql }}) + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + mysql: [ '8.0', '8.4' ] + services: + mysql: + image: mysql:${{ matrix.mysql }} + env: + MYSQL_ROOT_PASSWORD: rasa-ci + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -prasa-ci" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + env: + RASA_TEST_MYSQL: Server=127.0.0.1;Port=3306;User ID=root;Password=rasa-ci + steps: + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + - name: Restore NuGet cache + uses: actions/cache/restore@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }} + restore-keys: nuget-${{ runner.os }}- + # The design-time factories read databasesettings.json and then databasesettings.env.json from + # the build output, the same files the servers read; the override points them at the service. + # The timeout is also the command timeout, and the seed migrations insert thousands of rows. + - name: Point the database settings at the service + run: | + jq '.Databases |= ((.Auth, .Char, .World) |= (.Host = "127.0.0.1" | .Port = 3306 | .User = "root" | .Password = "rasa-ci" + | .TimeoutInMilliseconds = 600000))' \ + src/Rasa.DBL/databasesettings.json > src/Rasa.DBL/databasesettings.env.json + - run: dotnet tool restore + - name: Build + run: dotnet build src/Rasa.Test + - name: Apply the MySQL migrations + run: | + for context in MySqlAuthContext MySqlCharContext MySqlWorldContext; do + dotnet ef database update --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context" + done + - name: Test + run: | + dotnet test src/Rasa.Test --no-build \ + --filter TestCategory=MySql \ + --blame-hang-timeout 10m \ + --logger "trx;LogFileName=mysql.trx" \ + --results-directory TestResults + ran=$(grep -o ']*outcome="Passed"' TestResults/mysql.trx | wc -l) + echo "MySQL ${{ matrix.mysql }}: $ran results, $passed passed." | tee -a "$GITHUB_STEP_SUMMARY" + if [ "$passed" -eq 0 ] || [ "$passed" -ne "$ran" ]; then + echo "::error::Every MySql test must run and pass against the server ($passed of $ran passed)." + exit 1 + fi + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: mysql-results-${{ matrix.mysql }} + path: TestResults/ + retention-days: 14 + # The one check to require: it passes when every lane passed, or when the PR changed only docs. tests-complete: name: tests complete - needs: [ changes, build, test ] + needs: [ changes, build, test, drift, mysql ] if: always() runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -196,6 +317,8 @@ jobs: CHANGES: ${{ needs.changes.result }} BUILD: ${{ needs.build.result }} TEST: ${{ needs.test.result }} + DRIFT: ${{ needs.drift.result }} + MYSQL: ${{ needs.mysql.result }} run: | if [ "$CHANGES" = success ] && [ "$CODE" = false ]; then echo "Docs-only change; tests skipped." @@ -208,7 +331,7 @@ jobs: failed=$((failed + $(grep -o ']*outcome="Failed"' "$trx" | wc -l))) done echo "Across all lanes: $total results, $passed passed, $failed failed." | tee -a "$GITHUB_STEP_SUMMARY" - if [ "$BUILD" != success ] || [ "$TEST" != success ]; then - echo "::error::build: $BUILD, test runners: $TEST" + if [ "$BUILD" != success ] || [ "$TEST" != success ] || [ "$DRIFT" != success ] || [ "$MYSQL" != success ]; then + echo "::error::build: $BUILD, test runners: $TEST, migration drift: $DRIFT, mysql: $MYSQL" exit 1 fi diff --git a/.github/workflows/prune-container.yml b/.github/workflows/prune-container.yml new file mode 100644 index 00000000..334538f5 --- /dev/null +++ b/.github/workflows/prune-container.yml @@ -0,0 +1,44 @@ +name: Prune container + +# Weekly, off the release's critical path: keeps the 14 newest image versions in GHCR and +# deletes older ones. A version is an image digest; releases are pushed without attestations, +# so each release is exactly one version and latest/development are always among the kept. +on: + schedule: + - cron: '17 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + prune: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + packages: write + steps: + # Nothing to prune until the first release (or on a fork that never released). + - name: Check the package exists + id: package + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.repository }} + run: | + kind=users + [ "$(gh api "repos/$REPO" --jq .owner.type)" = Organization ] && kind=orgs + if out=$(gh api "$kind/$OWNER/packages/container/rasa.net" 2>&1); then + echo "exists=true" >> "$GITHUB_OUTPUT" + elif grep -q 'HTTP 404' <<< "$out"; then + echo "::notice::No rasa.net container package yet; nothing to prune." + else + echo "$out" + exit 1 + fi + - if: steps.package.outputs.exists == 'true' + uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5.0.0 + with: + package-name: rasa.net + package-type: container + min-versions-to-keep: 14 diff --git a/.github/workflows/release-container.yml b/.github/workflows/release-container.yml new file mode 100644 index 00000000..3c79e29f --- /dev/null +++ b/.github/workflows/release-container.yml @@ -0,0 +1,123 @@ +name: Release container + +# Builds development's HEAD and pushes it to GHCR. Started by a maintainer's /release comment +# (release-on-comment.yml) or by hand from the Actions tab. +on: + workflow_dispatch: + inputs: + requested_by: + description: Who asked for the release + required: false + pull_request: + description: The PR the /release comment was on + required: false + comment_id: + description: The /release comment, which gets a reaction for the outcome + required: false + +permissions: + contents: read + +concurrency: + group: release-container + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + actions: read + issues: write + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: development + # Only a commit whose CI passed on development is released. A /release usually comes right + # after a merge, while that commit's CI is still running, so this waits for it to finish. The + # container smoke test only runs when the image's inputs changed; if it ran, it must pass too. + - name: Wait for CI on this commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + sha=$(git rev-parse HEAD) + latest() { + gh run list -R "$REPO" --workflow "$1" --commit "$sha" --event push --branch development \ + --limit 1 --json status,conclusion --jq '.[0] | "\(.status) \(.conclusion)"' + } + for _ in $(seq 80); do + ci=$(latest dotnet.yml) + container=$(latest container.yml) + if [ "${ci%% *}" = completed ] && { [ -z "$container" ] || [ "${container%% *}" = completed ]; }; then + break + fi + sleep 30 + done + echo "dotnet.yml: ${ci:-no run}; container.yml: ${container:-no run}" + if [ "$ci" != "completed success" ]; then + echo "::error::CI on development's HEAD ($sha) didn't pass (${ci:-no run}); nothing was released." + exit 1 + fi + if [ -n "$container" ] && [ "$container" != "completed success" ]; then + echo "::error::The container smoke test on $sha didn't pass ($container); nothing was released." + exit 1 + fi + # GHCR names must be lowercase. The dated tag is what gets kept and pruned; latest and + # development always point at the newest release. + - name: Name the image + id: name + env: + REPO: ${{ github.repository }} + run: | + { + echo "image=ghcr.io/${REPO,,}" + echo "dated=$(date -u +%Y%m%d-%H%M)-$(git rev-parse --short=7 HEAD)" + echo "revision=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + # No provenance or SBOM: they're pushed as untagged manifests, which the prune workflow + # would count and could delete out from under their image. + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: true + provenance: false + sbom: false + tags: | + ${{ steps.name.outputs.image }}:${{ steps.name.outputs.dated }} + ${{ steps.name.outputs.image }}:development + ${{ steps.name.outputs.image }}:latest + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ steps.name.outputs.revision }} + cache-from: type=gha + - name: Summary + env: + IMAGE: ${{ steps.name.outputs.image }} + DATED: ${{ steps.name.outputs.dated }} + REQUESTED_BY: ${{ inputs.requested_by }} + PR: ${{ inputs.pull_request }} + run: | + echo "Pushed \`$IMAGE:$DATED\` (also \`development\` and \`latest\`)." >> "$GITHUB_STEP_SUMMARY" + if [ -n "$REQUESTED_BY" ]; then + echo "Requested by @$REQUESTED_BY${PR:+ on #$PR}." >> "$GITHUB_STEP_SUMMARY" + fi + - name: React to the /release comment + if: always() && inputs.comment_id != '' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + COMMENT_ID: ${{ inputs.comment_id }} + OUTCOME: ${{ job.status }} + run: | + content=-1 + [ "$OUTCOME" = success ] && content=rocket + gh api --method POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" -f content="$content" > /dev/null diff --git a/.github/workflows/release-on-comment.yml b/.github/workflows/release-on-comment.yml new file mode 100644 index 00000000..98b0877c --- /dev/null +++ b/.github/workflows/release-on-comment.yml @@ -0,0 +1,58 @@ +name: Release on comment + +# A maintainer comments "/release" on a merged PR to publish development's HEAD as a container +# image. issue_comment workflows always run from development's copy of this file, so a PR can't +# change what this does; but anyone can comment, so the commenter's permission is checked. +on: + issue_comment: + types: [ created ] + +permissions: + contents: read + +jobs: + release: + if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/release') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: write + issues: write + pull-requests: read + steps: + # Every comment field arrives through env, never ${{ }} inside the script. + - env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BODY: ${{ github.event.comment.body }} + COMMENTER: ${{ github.event.comment.user.login }} + COMMENT_ID: ${{ github.event.comment.id }} + PR: ${{ github.event.issue.number }} + run: | + react() { + gh api --method POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" -f content="$1" > /dev/null + } + + # "/release" alone or followed by whitespace; "/releasenotes" isn't a command. + if ! [[ "$BODY" =~ ^/release([[:space:]]|$) ]]; then + echo "Not a /release command." + exit 0 + fi + + permission=$(gh api "repos/$REPO/collaborators/$COMMENTER/permission" --jq .permission 2>/dev/null || echo none) + if [ "$permission" != admin ] && [ "$permission" != write ]; then + echo "::warning::$COMMENTER has '$permission' permission; /release needs write, maintain or admin." + react -1 + exit 0 + fi + + if [ "$(gh api "repos/$REPO/pulls/$PR" --jq '.merged and .base.ref == "development"')" != true ]; then + echo "::warning::PR #$PR isn't merged into development." + react -1 + exit 0 + fi + + # 👀 now; the release run adds 🚀 or 👎 when it finishes. + react eyes + gh workflow run release-container.yml -R "$REPO" --ref development \ + -f requested_by="$COMMENTER" -f pull_request="$PR" -f comment_id="$COMMENT_ID" diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..4b7a573a --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,34 @@ + + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Dockerfile b/Dockerfile index e0870e94..8977a000 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0.401 +# Build in the SDK image global.json pins, run in the much smaller runtime image as its built-in +# non-root user. The runtime stage keeps the build's output paths, so docker-compose.yml's working +# directories and volume mounts are the same in both. +FROM mcr.microsoft.com/dotnet/sdk:10.0.401 AS build WORKDIR /app @@ -6,10 +9,20 @@ COPY src /app/src COPY Rasa.NET.sln /app COPY Rasa.NET.sln.DotSettings /app COPY global.json /app +COPY Directory.Packages.props /app COPY .config /app/.config ARG NUGET_SOURCE=https://api.nuget.org/v3/index.json RUN dotnet restore --source "$NUGET_SOURCE" RUN dotnet build --no-restore --configuration Release -COPY navmesh /app/src/Rasa.Game/bin/Release/net10.0/navmesh +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime + +WORKDIR /app + +# Owned by app (UID 1654) so SQLite can create its journal files beside the mounted databases. +COPY --from=build --chown=app:app /app/src/Rasa.Auth/bin/Release/net10.0 /app/src/Rasa.Auth/bin/Release/net10.0 +COPY --from=build --chown=app:app /app/src/Rasa.Game/bin/Release/net10.0 /app/src/Rasa.Game/bin/Release/net10.0 +COPY --chown=app:app navmesh /app/src/Rasa.Game/bin/Release/net10.0/navmesh + +USER app diff --git a/docs/docker_setup.md b/docs/docker_setup.md index 87f79575..5f87b44a 100644 --- a/docs/docker_setup.md +++ b/docs/docker_setup.md @@ -2,8 +2,10 @@ This provides an alternative to building and using the project directly on your system. Use Docker with Linux containers and Docker Compose v2. -The Dockerfile builds all .NET 10 projects with SDK **10.0.401**, matching -`global.json` and CI. Each Compose service uses its Release output directory as +The Dockerfile builds all .NET 10 projects in the SDK image `global.json` pins +(the same SDK as CI), then copies the Auth and Game Release output into the much +smaller .NET 10 runtime image, which runs as its built-in non-root `app` user +(UID 1654). Each Compose service uses its Release output directory as its working directory. This matches the runtime loaders, which resolve `appsettings.json`, `appsettings.env.json`, `databasesettings.json`, and `databasesettings.env.json` from the process working directory. The three @@ -47,6 +49,14 @@ foreach ($file in 'rasaauth.db', 'rasachar.db', 'rasaworld.db') { docker compose build ``` +On a Linux host, the container's `app` user (UID 1654) must be able to write the +mounted databases. Docker Desktop on Windows and macOS doesn't need this. + +```sh +touch rasaauth.db rasachar.db rasaworld.db +sudo chown 1654:1654 rasaauth.db rasachar.db rasaworld.db +``` + This branch's consolidated migration history requires fresh databases, including when replacing databases from earlier versions of this branch. Do not run this as an existing-save conversion procedure or edit migration @@ -85,6 +95,25 @@ Release-build output placement from the project files. This verifies configuration, SQLite, knowledge-base, and navmesh paths without relying on a host `bin` directory as proof of image contents. +CI's `Container` workflow (`.github/workflows/container.yml`) builds the image +on PRs that touch it, starts it with this Compose file, and checks from inside +each container that every port Compose maps is listening. + +## Use a released image + +Maintainers publish `development` to the GitHub Container Registry by commenting +`/release` on a merged PR. To run a release instead of building locally, pull it +and tag it with the name Compose uses: + +```sh +docker pull ghcr.io/infiniterasa/rasa.net:latest +docker tag ghcr.io/infiniterasa/rasa.net:latest rasa_net +docker compose up --no-build +``` + +Released images are built for `linux/amd64` only. Each release also has a dated tag (`-`); the 14 newest +are kept. + ## Start Server Next, run `docker compose up --build`. diff --git a/docs/setup.md b/docs/setup.md index daea0e94..76c97cec 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -10,7 +10,7 @@ This guide will help you install and setup the required tools to run Rasa.NET. T ## Download, install, and setup the required tooling The following tools are required to setup, build, and run Rasa.NET: -- .NET SDK 10.0.401 (pinned in the repository's `global.json`) +- The .NET 10 SDK version pinned in the repository's `global.json` - Optional: a Visual Studio release that supports this .NET 10 SDK - Database System, either: - MySQL Server and Workbench or @@ -30,8 +30,20 @@ All solution projects target .NET 10, including `Rasa.Missions`. Install the exact SDK selected by `global.json`; SDK roll-forward is disabled so local builds, CI and Docker use the same version. -- [Download the .NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) and install version **10.0.401**. The SDK includes the runtime. -- From the repository root, run `dotnet --version` and verify `10.0.401`. +- [Download the .NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) and install the exact version in `global.json`'s `sdk.version`. The SDK includes the runtime. +- From the repository root, run `dotnet --version` and verify it prints that version. + +#### Updating the SDK or packages + +The SDK version lives in two places: `global.json` and the `sdk` build stage in +the `Dockerfile`. NuGet versions all live in `Directory.Packages.props`, and the +`dotnet-ef` tool in `.config/dotnet-tools.json` follows the EF Core packages. +Change every copy in the same commit; `PlatformCompatibilityTests` fails when +they disagree. Dependabot opens grouped update PRs (`.github/dependabot.yml`), +and moves `global.json` and the `Dockerfile` together in one SDK PR. If an SDK +PR changes only one of them (for example because the image isn't published +yet), push the other change to that PR's branch before merging it. EF Core 10 and Pomelo +updates are ignored until the project chooses a MySQL provider for EF Core 10. The supported portable deployment identifiers are `win-x64`, `osx-x64` and `linux-x64`. These preserve the Windows, macOS and Linux x64 deployment families, not support for the obsolete operating-system versions named by the old .NET 5 identifiers. Use an operating system supported by .NET 10. @@ -178,7 +190,7 @@ To access a MySql server with EF Core, set `Provider` to `MySql`. You need to pr If you want to add additional migrations as part of a feature, see "Creating migrations". ## Working with the databases and EF Core -The databases are kept up to date with EF Core. The compatible package set is EF Core/SQLite/Design **9.0.20** with Pomelo MySQL **9.0.0**, running on .NET 10. Pomelo 9 supports EF Core 9, not EF Core 10; upgrade these providers together. EF Core 9 support ends November 10, 2026, so this dependency choice needs review before that date. MySQL 8.0 and 8.4 are supported by the provider. +The databases are kept up to date with EF Core. The compatible package set is EF Core/SQLite/Design 9 with Pomelo MySQL 9, running on .NET 10; the exact versions are in `Directory.Packages.props`. Pomelo 9 supports EF Core 9, not EF Core 10; upgrade these providers together. EF Core 9 support ends November 10, 2026, so this dependency choice needs review before that date. MySQL 8.0 and 8.4 are supported by the provider. MySQL schema names up to the server's 64-character limit are supported. Rasa preserves Pomelo's migration-lock names for schemas up to 45 characters and uses a deterministic, case-normalized SHA256 lock name for longer schemas. This keeps migration synchronization and history intact without renaming databases. The naming override uses Pomelo 9's protected lock-name hook; revalidate it when upgrading the provider. @@ -190,17 +202,20 @@ First restore the solution and the repository-local EF tool from the repository - Open powershell - `dotnet restore` - `dotnet tool restore` -- `dotnet ef --version` (expected: `9.0.20`) +- `dotnet ef --version` (expected: the `dotnet-ef` version in `.config/dotnet-tools.json`) Before upgrading an existing database, back it up and test these commands on a disposable copy. Keep `__EFMigrationsHistory`; do not use `EnsureCreated`, delete the database, or suppress pending-model errors to bypass an upgrade failure. SQLite applies migrations automatically on server startup; MySQL requires the commands below before starting the servers. -Before declaring content or gameplay work ready, also check for provider/model -drift from the repository root: +Before declaring content or gameplay work ready, also check all six contexts for +provider/model drift from the repository root. CI's `migration drift` job runs the +same commands and fails on any pending change. None of them needs a database server. -- `dotnet ef migrations has-pending-model-changes --project src\Rasa.DBL --startup-project src\Rasa.Game --context SqliteWorldContext` -- `dotnet ef migrations has-pending-model-changes --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlWorldContext` -- `dotnet ef migrations has-pending-model-changes --project src\Rasa.DBL --startup-project src\Rasa.Game --context SqliteCharContext` -- `dotnet ef migrations has-pending-model-changes --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlCharContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context SqliteAuthContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context MySqlAuthContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context SqliteCharContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context MySqlCharContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context SqliteWorldContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context MySqlWorldContext` If required mission content is broken, `Rasa.Game` now logs each actionable mission diagnostic and refuses to print `Server ready!` until the content is @@ -223,6 +238,16 @@ To apply any pending migrations, execute the following commands: Explicitly providing the context is required as we have to work with different contexts according to database and provider. +If a MySQL World database was first built before the fix for `reconstruction_note` +(October 2026), its build may have stopped at `SeedWorldContent` with "Data too long +for column 'reconstruction_note'". `ConsolidatedWorldSchema` is then already recorded +as applied with the column too narrow, so re-running the update fails the same way. +Widen the column once, then run the World update again: + +```sql +ALTER TABLE mission_evidence MODIFY reconstruction_note text CHARACTER SET utf8mb4 NOT NULL; +``` + You can also migrate to any specific migration (forward or backward) by passing the migration name or the index/number of the migration as an argument. Obviously, this works with other DbContexts, too: - `dotnet ef database update "MigrationName" --context=MySqlAuthContext` migrates MySqlAuthContext to "MigrationName". @@ -405,12 +430,35 @@ Use `--map adv_foreas_concordia_wilderness` to rebuild one map. Generated files ### Database compatibility tests -The compatibility tests verify the pinned SDK, all solution project targets, the retained navigation project/package references, cryptographic fixtures, connection-string-specific MySQL server-version caching, and deterministic migration-lock names for schemas through MySQL's 64-character limit. The MySQL configuration tests use a fixed server version and do not connect to a database. +The compatibility tests verify that the SDK, Docker images and package versions agree across the files that pin them, all solution project targets, the retained navigation project/package references, the Docker image layout, cryptographic fixtures, connection-string-specific MySQL server-version caching, and deterministic migration-lock names for schemas through MySQL's 64-character limit. The MySQL configuration tests use a fixed server version and do not connect to a database. ```powershell dotnet test src\Rasa.Test\Rasa.Test.csproj --filter "FullyQualifiedName~Compatibility" ``` +### Live MySQL tests + +The tests in the `MySql` category run against a real MySQL server: every MySQL migration applied from empty, the same row counts as Sqlite in every table afterwards, and accounts, characters and missions written and read back through the repositories. CI runs them against MySQL 8.0 and 8.4 in the `mysql` job and leaves them out of the other test lanes. Without a server, `dotnet test` reports them as skipped. + +To run them locally, start a throwaway server (`--tmpfs` keeps its data in memory, which makes the seed migrations much faster): + +```powershell +docker run -d --name rasa-mysql -p 3306:3306 --tmpfs /var/lib/mysql -e MYSQL_ROOT_PASSWORD=rasa-ci mysql:8.4 +``` + +Point the design-time factories at it with a `src\Rasa.DBL\databasesettings.env.json` (see [Database configuration](#database-configuration)) that sets `Host` `127.0.0.1`, `User` `root`, `Password` `rasa-ci` and a `TimeoutInMilliseconds` of a few minutes for each of `Auth`, `Char` and `World`; the timeout is also the command timeout, and the World seed is large. Then build, apply the migrations, and run the category with the server in `RASA_TEST_MYSQL`: + +```powershell +dotnet build src\Rasa.Test +dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlAuthContext +dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlCharContext +dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlWorldContext +$env:RASA_TEST_MYSQL = "Server=127.0.0.1;Port=3306;User ID=root;Password=rasa-ci" +dotnet test src\Rasa.Test --no-build --filter TestCategory=MySql +``` + +The tests use the database names from `databasesettings.json` (and `databasesettings.env.json`, if it sets them), check row counts against an empty build, and remove the rows they add, so point them only at a throwaway server, never at one with real data. Delete `databasesettings.env.json` afterwards, or the servers you run from that build will read it too. + ### Create a game user The authentication server can be used to create a user by running a command in the terminal. The usage is: `create `. Running this command will create a new user in the database that you can use to login with the game client. diff --git a/src/Rasa.Auth/Rasa.Auth.csproj b/src/Rasa.Auth/Rasa.Auth.csproj index ef4cb426..cbaaea80 100644 --- a/src/Rasa.Auth/Rasa.Auth.csproj +++ b/src/Rasa.Auth/Rasa.Auth.csproj @@ -32,10 +32,10 @@ - - - - + + + + diff --git a/src/Rasa.DBL/Context/Auth/DesignTimeMySqlAuthContextFactory.cs b/src/Rasa.DBL/Context/Auth/DesignTimeMySqlAuthContextFactory.cs index d8ad396f..8474545d 100644 --- a/src/Rasa.DBL/Context/Auth/DesignTimeMySqlAuthContextFactory.cs +++ b/src/Rasa.DBL/Context/Auth/DesignTimeMySqlAuthContextFactory.cs @@ -1,10 +1,18 @@ -using Microsoft.EntityFrameworkCore.Design; +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using JetBrains.Annotations; namespace Rasa.Context.Auth { using Configuration; + using Configuration.ConnectionStrings; + using Configuration.ContextSetup; + using Services.DbContext; /// /// Used by EF Core to create and execute migrations. @@ -14,7 +22,20 @@ public class DesignTimeMySqlAuthContextFactory : DesignTimeContextFactoryBase, I { public MySqlAuthContext CreateDbContext(string[] args) { - return CreateDbContext(DatabaseProvider.MySql); + var configuration = new ConfigurationBuilder() + .AddJsonFile("databasesettings.json", false, false) + .AddJsonFile("databasesettings.env.json", true, false) + .Build(); + var databases = new DatabaseConfiguration(); + configuration.GetSection("Databases").Bind(databases); + databases.Provider = DatabaseProvider.MySql.ToString(); + + return new MySqlAuthContext( + Options.Create(databases), + new MySqlDbContextConfigurationService( + new MySqlConnectionStringFactory(), + _ => new MySqlServerVersion(new Version(8, 4, 0))), + new MySqlDbContextPropertyModifier()); } } } \ No newline at end of file diff --git a/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs b/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs index 2665eef7..1661a15c 100644 --- a/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs +++ b/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs @@ -393,7 +393,12 @@ protected override void Up(MigrationBuilder migrationBuilder) local_client_path = table.Column(type: "varchar(256)", nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), confidence = table.Column(type: "double unsigned", nullable: false), - reconstruction_note = table.Column(type: "varchar(256)", nullable: false) + // text, not the varchar(256) this first shipped with: SeedWorldContent, next, + // writes today's Bootcamp evidence, some of whose notes are longer, and MySQL + // refused them ("Data too long"), so no MySQL world database could be built from + // empty. Databases already past this point were widened by + // WildernessAliaBranches and WildernessEvidenceCapacity. + reconstruction_note = table.Column(type: "text", nullable: false) .Annotation("MySql:CharSet", "utf8mb4") }, constraints: table => diff --git a/src/Rasa.DBL/Rasa.DBL.csproj b/src/Rasa.DBL/Rasa.DBL.csproj index 0492607a..d2e94547 100644 --- a/src/Rasa.DBL/Rasa.DBL.csproj +++ b/src/Rasa.DBL/Rasa.DBL.csproj @@ -18,14 +18,14 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/src/Rasa.DBL/Services/DbContext/MySqlMigrationHistoryRepository.cs b/src/Rasa.DBL/Services/DbContext/MySqlMigrationHistoryRepository.cs index 4942ba55..7123fd45 100644 --- a/src/Rasa.DBL/Services/DbContext/MySqlMigrationHistoryRepository.cs +++ b/src/Rasa.DBL/Services/DbContext/MySqlMigrationHistoryRepository.cs @@ -6,6 +6,14 @@ namespace Rasa.Services.DbContext { + // MySQL's GET_LOCK refuses names over 64 characters, and Pomelo builds the migration lock name + // from the database name. The lock name can only be changed by overriding GetDatabaseLockName + // on Pomelo's internal MySqlHistoryRepository: there's no option for it, and implementing + // IHistoryRepository on the public HistoryRepository base would mean copying Pomelo's locking + // and history-table SQL. So this deliberately uses the internal API (EF1001). A Pomelo upgrade + // that changes it breaks the build here, and MySqlPlatformCompatibilityTests checks both the + // registration and the lock names. +#pragma warning disable EF1001 internal sealed class MySqlMigrationHistoryRepository : MySqlHistoryRepository { public MySqlMigrationHistoryRepository(HistoryRepositoryDependencies dependencies) @@ -17,6 +25,7 @@ protected override string GetDatabaseLockName(string databaseName) { return BoundLockName(base.GetDatabaseLockName(databaseName)); } +#pragma warning restore EF1001 internal static string BoundLockName(string name) { diff --git a/src/Rasa.Game/Managers/ManifestationManager.cs b/src/Rasa.Game/Managers/ManifestationManager.cs index 528eecae..574d3863 100644 --- a/src/Rasa.Game/Managers/ManifestationManager.cs +++ b/src/Rasa.Game/Managers/ManifestationManager.cs @@ -4279,10 +4279,6 @@ public void UpdateStatsValues(Client client, bool fullreset) var mindBonus = 0; var spiritBonus = 0; - var healthBonus = 0; - var chiBonus = 0; - var regenBonus = 0; - float armorBonusPercent = (float)Math.Max(0.0, (totalBody - (2 * (level - 1) + 10)) * 0.667); // every body attribute over the default base attribute gives 0.667% bonus armo; float logosBonusPercent = (float)Math.Max(0.0, (totalMind - (2 * (level - 1) + 10)) * 0.375); // every mind attribute over the default base attribute gives 0.375% bonus logos damage float critBonusPercent = (float)Math.Max(0.0, (totalSpirit - (2 * (level - 1) + 10)) * 0.065); // every spirit attribute over the default base attribute gives 0.065% bonus crit chance; diff --git a/src/Rasa.Game/Managers/MapChannelManager.cs b/src/Rasa.Game/Managers/MapChannelManager.cs index d1ca1927..85a0b853 100644 --- a/src/Rasa.Game/Managers/MapChannelManager.cs +++ b/src/Rasa.Game/Managers/MapChannelManager.cs @@ -21,7 +21,6 @@ public partial class MapChannelManager { private static MapChannelManager _instance; private static readonly object InstanceLock = new object(); - private readonly int MapChannel_PlayerQueue = 32; public readonly Dictionary MapChannelArray = new Dictionary(); // list of loaded maps public readonly Timer Timer = new(); diff --git a/src/Rasa.Game/Memory/PythonWriter.cs b/src/Rasa.Game/Memory/PythonWriter.cs index ac067292..3ca50d68 100644 --- a/src/Rasa.Game/Memory/PythonWriter.cs +++ b/src/Rasa.Game/Memory/PythonWriter.cs @@ -287,7 +287,7 @@ public override string ToString() var currentPosition = Writer.BaseStream.Position; Writer.BaseStream.Position = BeginPositon; - Writer.BaseStream.Read(data, 0, data.Length); + Writer.BaseStream.ReadExactly(data); Writer.BaseStream.Position = currentPosition; using var pr = new PythonReader(new BinaryReader(new MemoryStream(data))); diff --git a/src/Rasa.Game/Rasa.Game.csproj b/src/Rasa.Game/Rasa.Game.csproj index 3592bf78..9a2212d5 100644 --- a/src/Rasa.Game/Rasa.Game.csproj +++ b/src/Rasa.Game/Rasa.Game.csproj @@ -16,12 +16,12 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Rasa.NavMesh/Rasa.NavMesh.csproj b/src/Rasa.NavMesh/Rasa.NavMesh.csproj index 010a64f4..9d846e0d 100644 --- a/src/Rasa.NavMesh/Rasa.NavMesh.csproj +++ b/src/Rasa.NavMesh/Rasa.NavMesh.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/Rasa.Navigation/Rasa.Navigation.csproj b/src/Rasa.Navigation/Rasa.Navigation.csproj index 7b215f0c..a4c05a8f 100644 --- a/src/Rasa.Navigation/Rasa.Navigation.csproj +++ b/src/Rasa.Navigation/Rasa.Navigation.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs b/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs index 3ec5157e..6b049020 100644 --- a/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs +++ b/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs @@ -201,34 +201,81 @@ internal sealed class DockerImageLayout private readonly Dictionary _copiedHostFiles = new Dictionary(StringComparer.Ordinal); - private DockerImageLayout() + internal string BaseImage { get; } + internal string User { get; private set; } = "root"; + internal string WorkingDirectory { get; private set; } = "/"; + + private DockerImageLayout(string baseImage) { + BaseImage = baseImage; } + // The layout of the Dockerfile's final stage. Earlier stages are modelled too, so a + // COPY --from= takes exactly the files that stage would hold. internal static DockerImageLayout Create(string repositoryRoot) { - var layout = new DockerImageLayout(); + return CreateStages(repositoryRoot).Last(); + } + + internal static IReadOnlyList CreateStages(string repositoryRoot) + { var dockerfile = DockerfileModel.Parse( File.ReadAllText(Path.Combine(repositoryRoot, "Dockerfile"))); var dockerIgnore = DockerIgnoreMatcher.Load(repositoryRoot); - var workingDirectory = "/"; + var stages = new List(); + var stageNames = new Dictionary( + StringComparer.OrdinalIgnoreCase); + DockerImageLayout layout = null; foreach (var instruction in dockerfile.Instructions) { + if (instruction.Name == "FROM") + { + // --platform and other flags choose how the base image is pulled, not what + // the stage holds. + var from = SplitArguments(instruction.Arguments) + .Where(field => !field.StartsWith("--", StringComparison.Ordinal)) + .ToArray(); + if (from.Length != 1 && + !(from.Length == 3 && from[1].Equals("AS", StringComparison.OrdinalIgnoreCase))) + throw new InvalidDataException($"Unsupported Docker FROM instruction: {instruction.Arguments}"); + + layout = new DockerImageLayout(from[0]); + if (stageNames.TryGetValue(from[0], out var parent)) + layout.CopyStage(parent); + // COPY --from takes a stage's name or its index. + stageNames[stages.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)] = layout; + stages.Add(layout); + if (from.Length == 3) + stageNames[from[2]] = layout; + continue; + } + + if (layout == null) + { + if (instruction.Name == "ARG") + continue; + throw new InvalidDataException($"Docker {instruction.Name} comes before any FROM."); + } + switch (instruction.Name) { case "WORKDIR": - workingDirectory = PosixPath.Resolve( - workingDirectory, + layout.WorkingDirectory = PosixPath.Resolve( + layout.WorkingDirectory, instruction.Arguments); - layout.AddDirectory(workingDirectory); + layout.AddDirectory(layout.WorkingDirectory); break; case "COPY": layout.ApplyCopy( repositoryRoot, - workingDirectory, + layout.WorkingDirectory, instruction.Arguments, - dockerIgnore); + dockerIgnore, + stageNames); + break; + case "USER": + layout.User = instruction.Arguments; break; case "RUN": var build = DotNetBuildCommand.Parse(instruction.Arguments); @@ -236,12 +283,15 @@ internal static DockerImageLayout Create(string repositoryRoot) build.Configuration.Equals( "Release", StringComparison.OrdinalIgnoreCase)) - layout.ApplyBuild(workingDirectory, build); + layout.ApplyBuild(layout.WorkingDirectory, build); break; } } - return layout; + if (layout == null) + throw new InvalidDataException("Dockerfile has no FROM instruction."); + + return stages; } internal bool ContainsFile(string path) => @@ -262,12 +312,35 @@ private void ApplyCopy( string repositoryRoot, string workingDirectory, string arguments, - DockerIgnoreMatcher dockerIgnore) - { - var fields = SplitArguments(arguments); + DockerIgnoreMatcher dockerIgnore, + IReadOnlyDictionary stages) + { + // --chown and --chmod set ownership and modes, which the layout doesn't model. + // --from copies from an earlier stage instead of the build context. + var fields = SplitArguments(arguments).ToList(); + string fromStage = null; + while (fields.Count != 0 && fields[0].StartsWith("--", StringComparison.Ordinal)) + { + const string fromPrefix = "--from="; + if (fields[0].StartsWith(fromPrefix, StringComparison.Ordinal)) + fromStage = fields[0].Substring(fromPrefix.Length); + else if (!fields[0].StartsWith("--chown=", StringComparison.Ordinal) && + !fields[0].StartsWith("--chmod=", StringComparison.Ordinal)) + throw new InvalidDataException($"Unsupported Docker COPY instruction: {arguments}"); + fields.RemoveAt(0); + } + if (fields.Count != 2) throw new InvalidDataException($"Unsupported Docker COPY instruction: {arguments}"); + if (fromStage != null) + { + if (!stages.TryGetValue(fromStage, out var stage) || stage == this) + throw new InvalidDataException($"Docker COPY --from names no earlier stage: {arguments}"); + ApplyStageCopy(stage, workingDirectory, fields[0], fields[1]); + return; + } + var source = Path.GetFullPath(Path.Combine( repositoryRoot, fields[0].Replace('/', Path.DirectorySeparatorChar))); @@ -306,6 +379,63 @@ private void ApplyCopy( } } + private void ApplyStageCopy( + DockerImageLayout stage, + string workingDirectory, + string sourcePath, + string destinationPath) + { + var source = PosixPath.Resolve("/", sourcePath); + var destination = PosixPath.Resolve(workingDirectory, destinationPath); + if (stage._files.Contains(source)) + { + if (destinationPath.EndsWith("/", StringComparison.Ordinal) || + _directories.Contains(destination)) + destination = PosixPath.Resolve( + destination, + source.Substring(source.LastIndexOf('/') + 1)); + CopyStageFile(stage, source, destination); + return; + } + + if (!stage._directories.Contains(source)) + throw new InvalidDataException( + $"Docker COPY --from source '{sourcePath}' does not exist in that stage."); + + AddDirectory(destination); + var prefix = source.TrimEnd('/') + "/"; + foreach (var file in stage._files + .Where(path => path.StartsWith(prefix, StringComparison.Ordinal)) + .ToArray()) + CopyStageFile( + stage, + file, + PosixPath.Resolve(destination, file.Substring(prefix.Length))); + } + + private void CopyStageFile( + DockerImageLayout stage, + string source, + string destination) + { + if (stage._copiedHostFiles.TryGetValue(source, out var hostPath)) + AddCopiedFile(hostPath, destination); + else + AddFile(destination); + } + + private void CopyStage(DockerImageLayout parent) + { + _files.UnionWith(parent._files); + _directories.UnionWith(parent._directories); + foreach (var pair in parent._copiedFiles) + _copiedFiles[pair.Key] = pair.Value; + foreach (var pair in parent._copiedHostFiles) + _copiedHostFiles[pair.Key] = pair.Value; + User = parent.User; + WorkingDirectory = parent.WorkingDirectory; + } + private void ApplyBuild( string workingDirectory, DotNetBuildCommand build) diff --git a/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs b/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs index e10a22be..b37d8ac2 100644 --- a/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs +++ b/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs @@ -131,6 +131,138 @@ RUN dotnet build src/App/App.csproj src/Decoy/Decoy.csproj -c Release () => DockerImageLayout.Create(repository.Root)); } + [TestMethod] + public void FinalStageHoldsOnlyWhatItCopiesFromEarlierStages() + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", "**/bin\n**/obj\n"); + repository.Write("Dockerfile", """ + FROM mcr.microsoft.com/dotnet/sdk:10.0.401 AS build + WORKDIR /app + COPY src /app/src + RUN dotnet build src/App/App.csproj -c Release + FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime + WORKDIR /app + COPY --from=build --chown=app:app /app/src/App/bin/Release/net10.0 /app/src/App/bin/Release/net10.0 + COPY --chown=app:app assets /app/src/App/bin/Release/net10.0/assets + USER app + """); + repository.Write("src/App/App.csproj", Project( + "App", + @"..\Shared\Shared.csproj", + "appsettings.json")); + repository.Write("src/App/appsettings.json", "{}"); + repository.Write("src/Shared/Shared.csproj", Project("Shared")); + repository.Write("assets/map.nav", "mesh"); + + var stages = DockerImageLayout.CreateStages(repository.Root); + var image = DockerImageLayout.Create(repository.Root); + + Assert.AreEqual(2, stages.Count); + Assert.AreEqual("mcr.microsoft.com/dotnet/runtime:10.0", image.BaseImage); + Assert.AreEqual("app", image.User); + Assert.AreEqual("root", stages[0].User); + Assert.AreEqual("mcr.microsoft.com/dotnet/sdk:10.0.401", stages[0].BaseImage); + Assert.IsTrue(image.ContainsFile("/app/src/App/bin/Release/net10.0/App.dll")); + Assert.IsTrue(image.ContainsFile("/app/src/App/bin/Release/net10.0/Shared.dll")); + Assert.IsTrue(image.ContainsFile("/app/src/App/bin/Release/net10.0/appsettings.json")); + Assert.IsTrue(image.ContainsFile("/app/src/App/bin/Release/net10.0/assets/map.nav")); + Assert.IsFalse(image.ContainsFile("/app/src/App/App.csproj")); + Assert.IsFalse(image.ContainsFile("/app/src/Shared/bin/Release/net10.0/Shared.dll")); + Assert.IsTrue(stages[0].ContainsFile("/app/src/App/App.csproj")); + } + + [TestMethod] + public void StagesAreAddressableByIndexAndInheritTheirParentsWorkingDirectory() + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", "**/bin\n**/obj\n"); + repository.Write("Dockerfile", """ + FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0.401 AS source + WORKDIR /app + COPY src /app/src + FROM source AS build + RUN dotnet build src/App/App.csproj -c Release + FROM mcr.microsoft.com/dotnet/runtime:10.0 + COPY --from=1 /app/src/App/bin/Release/net10.0 /app + """); + repository.Write("src/App/App.csproj", Project("App")); + + var stages = DockerImageLayout.CreateStages(repository.Root); + + Assert.AreEqual("/app", stages[1].WorkingDirectory); + Assert.IsTrue(stages[1].ContainsFile("/app/src/App/bin/Release/net10.0/App.dll")); + Assert.IsTrue(stages[2].ContainsFile("/app/App.dll")); + Assert.AreEqual("/", stages[2].WorkingDirectory); + } + + [TestMethod] + public void CopyFromTheCurrentStageIsRejected() + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", string.Empty); + repository.Write("Dockerfile", """ + FROM scratch AS build + WORKDIR /app + COPY --from=build /app /copy + """); + + Assert.ThrowsExactly( + () => DockerImageLayout.Create(repository.Root)); + } + + [TestMethod] + public void CopyFromAnUnknownStageIsRejected() + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", string.Empty); + repository.Write("Dockerfile", """ + FROM mcr.microsoft.com/dotnet/runtime:10.0 + COPY --from=build /app /app + """); + + Assert.ThrowsExactly( + () => DockerImageLayout.Create(repository.Root)); + } + + [TestMethod] + public void CopyOfAPathMissingFromTheStageIsRejected() + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", string.Empty); + repository.Write("Dockerfile", """ + FROM scratch AS build + WORKDIR /app + FROM mcr.microsoft.com/dotnet/runtime:10.0 + COPY --from=build /app/missing /app + """); + + Assert.ThrowsExactly( + () => DockerImageLayout.Create(repository.Root)); + } + + [TestMethod] + [DataRow("COPY --link src /app/src")] + [DataRow("COPY --parents src /app/src")] + [DataRow("COPY src other /app/")] + [DataRow("COPY --chown=app:app src other /app/")] + public void UnsupportedCopyFormsAreStillRejected(string copy) + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", string.Empty); + repository.Write("Dockerfile", $""" + FROM scratch + WORKDIR /app + {copy} + """); + repository.Write("src/App/App.csproj", Project("App")); + repository.Write("other/file.txt", "x"); + + var exception = Assert.ThrowsExactly( + () => DockerImageLayout.Create(repository.Root)); + StringAssert.StartsWith(exception.Message, "Unsupported Docker COPY instruction"); + } + private static string Project( string assemblyName, string projectReference = null, diff --git a/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs b/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs index f07fc093..723f50c7 100644 --- a/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs +++ b/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs @@ -12,15 +12,20 @@ namespace Rasa.Test.Compatibility public class PlatformCompatibilityTests { [TestMethod] - public void RepositoryPinsVerifiedDotNetSdk() + public void RepositoryPinsOneExactDotNetSdk() { var repositoryRoot = FindRepositoryRoot(); - using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(repositoryRoot, "global.json"))); + var globalJson = RepositoryPins.Read(repositoryRoot, "global.json"); + using var document = JsonDocument.Parse(globalJson); var sdk = document.RootElement.GetProperty("sdk"); - Assert.AreEqual("10.0.401", sdk.GetProperty("version").GetString()); Assert.AreEqual("disable", sdk.GetProperty("rollForward").GetString()); Assert.IsFalse(sdk.GetProperty("allowPrerelease").GetBoolean()); + Assert.AreEqual( + string.Empty, + string.Join(Environment.NewLine, RepositoryPins.CheckSdk( + globalJson, + RepositoryPins.Read(repositoryRoot, "Dockerfile")))); } [TestMethod] @@ -61,8 +66,39 @@ public void NavigationProjectsKeepPr95Dependencies() var navMesh = XDocument.Load(Path.Combine(repositoryRoot, "src", "Rasa.NavMesh", "Rasa.NavMesh.csproj")); CollectionAssert.Contains(ReadProjectReferences(game).ToArray(), @"..\Rasa.Navigation\Rasa.Navigation.csproj"); - Assert.AreEqual("2026.3.1", ReadPackageVersion(navigation, "DotRecast.Detour")); - Assert.AreEqual("2026.3.1", ReadPackageVersion(navMesh, "DotRecast.Recast")); + CollectionAssert.Contains(ReadPackageReferences(navigation).ToArray(), "DotRecast.Detour"); + CollectionAssert.Contains(ReadPackageReferences(navMesh).ToArray(), "DotRecast.Recast"); + } + + [TestMethod] + public void PackagesThatMoveTogetherShareOneVersion() + { + var repositoryRoot = FindRepositoryRoot(); + + Assert.AreEqual( + string.Empty, + string.Join(Environment.NewLine, RepositoryPins.CheckPackages( + RepositoryPins.Read(repositoryRoot, "Directory.Packages.props"), + RepositoryPins.Read(repositoryRoot, ".config", "dotnet-tools.json")))); + } + + [TestMethod] + public void DockerImageRunsAsTheRuntimeImagesNonRootUser() + { + var image = DockerImageLayout.Create(FindRepositoryRoot()); + + Assert.AreEqual("app", image.User); + StringAssert.StartsWith(image.BaseImage, "mcr.microsoft.com/dotnet/runtime:"); + } + + [TestMethod] + public void DockerBuildStageHasTheFilesThatPinTheBuild() + { + var build = DockerImageLayout.CreateStages(FindRepositoryRoot())[0]; + + // Without these, the image would restore and build with other versions than CI. + foreach (var file in new[] { "global.json", "Directory.Packages.props", ".config/dotnet-tools.json" }) + Assert.IsTrue(build.ContainsFile("/app/" + file), $"The build stage has no /app/{file}."); } [TestMethod] @@ -110,11 +146,11 @@ private static IEnumerable ReadProjectReferences(XDocument project) .Where(reference => reference != null); } - private static string ReadPackageVersion(XDocument project, string package) + private static IEnumerable ReadPackageReferences(XDocument project) { return project.Descendants("PackageReference") - .Single(reference => reference.Attribute("Include")?.Value == package) - .Attribute("Version")?.Value; + .Select(reference => reference.Attribute("Include")?.Value) + .Where(reference => reference != null); } private static void AssertServiceLayout( diff --git a/src/Rasa.Test/Compatibility/RepositoryPins.cs b/src/Rasa.Test/Compatibility/RepositoryPins.cs new file mode 100644 index 00000000..b6b0cebf --- /dev/null +++ b/src/Rasa.Test/Compatibility/RepositoryPins.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Xml.Linq; + +namespace Rasa.Test.Compatibility +{ + // Version pins that live in more than one file. The guards check that the copies agree rather + // than that they hold particular versions, so an update (by hand or by Dependabot) that moves + // every copy together passes, and one that misses a copy fails. + internal static class RepositoryPins + { + private const string SdkImage = "mcr.microsoft.com/dotnet/sdk"; + private const string RuntimeImage = "mcr.microsoft.com/dotnet/runtime"; + + internal static string ReadGlobalJsonSdk(string globalJson) + { + using var document = JsonDocument.Parse(globalJson); + return document.RootElement.GetProperty("sdk").GetProperty("version").GetString(); + } + + internal static IReadOnlyList ReadDockerfileImages(string dockerfile) + { + // The image of each FROM, without flags such as --platform or a pinned @sha256 digest. + return DockerfileModel.Parse(dockerfile).Instructions + .Where(instruction => instruction.Name == "FROM") + .Select(instruction => instruction.Arguments + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .First(field => !field.StartsWith("--", StringComparison.Ordinal)) + .Split('@')[0]) + .ToArray(); + } + + // Every SDK stage builds with the SDK global.json pins, and the image runs on that SDK's + // runtime line. Returns the disagreements; empty when the files agree. + internal static IReadOnlyList CheckSdk(string globalJson, string dockerfile) + { + var problems = new List(); + var sdk = ReadGlobalJsonSdk(globalJson); + var images = ReadDockerfileImages(dockerfile); + var sdkTags = images + .Where(image => image.StartsWith(SdkImage + ":", StringComparison.Ordinal)) + .Select(image => image.Substring(SdkImage.Length + 1)) + .ToArray(); + + if (sdkTags.Length == 0) + problems.Add($"The Dockerfile has no {SdkImage} stage."); + foreach (var tag in sdkTags.Where(tag => tag != sdk)) + problems.Add($"The Dockerfile builds with {SdkImage}:{tag}, but global.json pins {sdk}."); + + // The runtime image may float on the line (10.0) or pin a patch (10.0.5). + var final = images.LastOrDefault() ?? string.Empty; + var sdkLine = string.Join('.', sdk.Split('.').Take(2)); + var runtimeLine = $"{RuntimeImage}:{sdkLine}"; + if (final != runtimeLine && !final.StartsWith(runtimeLine + ".", StringComparison.Ordinal)) + problems.Add($"The Dockerfile's final stage is {final}, not {runtimeLine}."); + + return problems; + } + + // Packages that must move together share one version in Directory.Packages.props, and the + // dotnet-ef tool matches the EF Core packages it migrates. + internal static IReadOnlyList CheckPackages(string packagesProps, string toolManifest) + { + var problems = new List(); + var versions = XDocument.Parse(packagesProps).Descendants("PackageVersion") + .ToDictionary( + item => item.Attribute("Include").Value, + item => item.Attribute("Version").Value, + StringComparer.OrdinalIgnoreCase); + + RequireSameVersion(versions, problems, "DotRecast.Detour", "DotRecast.Recast"); + RequireSameVersion( + versions, + problems, + "Microsoft.EntityFrameworkCore", + "Microsoft.EntityFrameworkCore.Design", + "Microsoft.EntityFrameworkCore.Sqlite"); + RequireSameVersion(versions, problems, "MSTest.TestAdapter", "MSTest.TestFramework"); + + using var tools = JsonDocument.Parse(toolManifest); + var dotnetEf = tools.RootElement.GetProperty("tools").GetProperty("dotnet-ef") + .GetProperty("version").GetString(); + if (versions.TryGetValue("Microsoft.EntityFrameworkCore", out var efCore) && dotnetEf != efCore) + problems.Add($"dotnet-ef is {dotnetEf} in .config/dotnet-tools.json, but EF Core is {efCore}."); + + return problems; + } + + private static void RequireSameVersion( + IReadOnlyDictionary versions, + List problems, + params string[] packages) + { + var missing = packages.Where(package => !versions.ContainsKey(package)).ToArray(); + if (missing.Length != 0) + { + problems.Add($"Directory.Packages.props has no version for {string.Join(", ", missing)}."); + return; + } + + if (packages.Select(package => versions[package]).Distinct().Count() > 1) + problems.Add("These packages must share one version: " + + string.Join(", ", packages.Select(package => $"{package} {versions[package]}")) + "."); + } + + internal static string Read(string repositoryRoot, params string[] path) => + File.ReadAllText(Path.Combine(new[] { repositoryRoot }.Concat(path).ToArray())); + } +} diff --git a/src/Rasa.Test/Compatibility/RepositoryPinsTests.cs b/src/Rasa.Test/Compatibility/RepositoryPinsTests.cs new file mode 100644 index 00000000..d935cc61 --- /dev/null +++ b/src/Rasa.Test/Compatibility/RepositoryPinsTests.cs @@ -0,0 +1,132 @@ +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Rasa.Test.Compatibility +{ + [TestClass] + public class RepositoryPinsTests + { + private const string GlobalJson = """ + { "sdk": { "version": "10.0.401", "rollForward": "disable", "allowPrerelease": false } } + """; + + private const string ToolManifest = """ + { "version": 1, "isRoot": true, "tools": { "dotnet-ef": { "version": "9.0.20", "commands": [ "dotnet-ef" ] } } } + """; + + [TestMethod] + public void SdkCheckAcceptsAMultiStageDockerfileOnTheGlobalJsonSdk() + { + Assert.AreEqual(0, RepositoryPins.CheckSdk(GlobalJson, Dockerfile("10.0.401", "10.0")).Count); + } + + [TestMethod] + public void SdkCheckRejectsADockerfileBuildingWithAnotherSdk() + { + var problems = RepositoryPins.CheckSdk(GlobalJson, Dockerfile("10.0.402", "10.0")); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "sdk:10.0.402"); + } + + [TestMethod] + public void SdkCheckRejectsARuntimeFromAnotherLine() + { + var problems = RepositoryPins.CheckSdk(GlobalJson, Dockerfile("10.0.401", "11.0")); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "runtime:11.0"); + } + + [TestMethod] + public void SdkCheckRejectsAnImageThatShipsTheSdk() + { + var problems = RepositoryPins.CheckSdk( + GlobalJson, + "FROM mcr.microsoft.com/dotnet/sdk:10.0.401\nRUN dotnet build\n"); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "final stage"); + } + + [TestMethod] + public void SdkCheckReadsImagesPastPlatformFlagsDigestsAndPatchPins() + { + var dockerfile = """ + FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0.401@sha256:0123 AS build + FROM mcr.microsoft.com/dotnet/runtime:10.0.12 AS runtime + """; + + Assert.AreEqual(0, RepositoryPins.CheckSdk(GlobalJson, dockerfile).Count); + Assert.AreEqual(1, RepositoryPins.CheckSdk(GlobalJson, dockerfile.Replace("10.0.12", "10.01")).Count); + } + + [TestMethod] + public void PackageCheckAcceptsMatchingVersions() + { + Assert.AreEqual(0, RepositoryPins.CheckPackages(Packages(), ToolManifest).Count); + } + + [TestMethod] + public void PackageCheckRejectsDotRecastPackagesThatDisagree() + { + var problems = RepositoryPins.CheckPackages(Packages(recast: "2026.4.0"), ToolManifest); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "DotRecast.Recast 2026.4.0"); + } + + [TestMethod] + public void PackageCheckRejectsEfCorePackagesThatDisagree() + { + var problems = RepositoryPins.CheckPackages(Packages(efSqlite: "9.0.21"), ToolManifest); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "Microsoft.EntityFrameworkCore.Sqlite 9.0.21"); + } + + [TestMethod] + public void PackageCheckRejectsADotnetEfToolOnAnotherVersion() + { + var problems = RepositoryPins.CheckPackages( + Packages(), + ToolManifest.Replace("9.0.20", "9.0.21")); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "dotnet-ef is 9.0.21"); + } + + [TestMethod] + public void PackageCheckRejectsAMissingPackage() + { + var problems = RepositoryPins.CheckPackages( + string.Join('\n', Packages().Split('\n').Where(line => !line.Contains("DotRecast.Detour"))), + ToolManifest); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "no version for DotRecast.Detour"); + } + + private static string Dockerfile(string sdk, string runtime) => $""" + FROM mcr.microsoft.com/dotnet/sdk:{sdk} AS build + RUN dotnet build --configuration Release + FROM mcr.microsoft.com/dotnet/runtime:{runtime} AS runtime + COPY --from=build /app /app + USER app + """; + + private static string Packages(string recast = "2026.3.1", string efSqlite = "9.0.20") => $""" + + + + + + + + + + + + """; + } +} diff --git a/src/Rasa.Test/Database/LiveMySql.cs b/src/Rasa.Test/Database/LiveMySql.cs new file mode 100644 index 00000000..8b4115a8 --- /dev/null +++ b/src/Rasa.Test/Database/LiveMySql.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MySqlConnector; + +namespace Rasa.Test.Database +{ + using Rasa.Configuration; + using Rasa.Configuration.ConnectionStrings; + using Rasa.Configuration.ContextSetup; + using Rasa.Context; + using Rasa.Services.DbContext; + + /// + /// Contexts on a live MySQL server, for the tests in the "MySql" category. The server comes from + /// RASA_TEST_MYSQL, a MySqlConnector connection string without a database + /// ("Server=127.0.0.1;Port=3306;User ID=root;Password=..."); the database names are the ones + /// databasesettings.json and databasesettings.env.json give, which is where + /// `dotnet ef database update` applied the migrations. Contexts are configured the way the servers configure them (server version + /// detection, the bounded migration lock), not with the offline setup the model tests use. + /// + /// CI runs this category in its own job against mysql:8.0 and mysql:8.4 and leaves it out of + /// the other lanes; without RASA_TEST_MYSQL the tests report inconclusive. docs/setup.md + /// has the commands to run them locally. + /// + internal static class LiveMySql + { + internal const string Category = "MySql"; + internal const string ServerVariable = "RASA_TEST_MYSQL"; + + internal static RasaDbContextBase CreateContext(Type contextType) + { + var server = Environment.GetEnvironmentVariable(ServerVariable); + if (string.IsNullOrWhiteSpace(server)) + Assert.Inconclusive( + $"{ServerVariable} is not set. These tests need a MySQL server with the migrations applied; see docs/setup.md."); + + var builder = new MySqlConnectionStringBuilder(server); + var databases = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("databasesettings.json", false, false) + .AddJsonFile("databasesettings.env.json", true, false) + .Build() + .GetSection("Databases"); + + DatabaseConnectionConfiguration Connection(string name) => new() + { + Host = builder.Server, + Port = builder.Port, + User = builder.UserID, + Password = builder.Password, + Database = databases[$"{name}:Database"], + TimeoutInMilliseconds = 30000 + }; + + var options = Options.Create(new DatabaseConfiguration + { + Provider = "MySql", + Auth = Connection("Auth"), + Char = Connection("Char"), + World = Connection("World") + }); + return (RasaDbContextBase)Activator.CreateInstance( + contextType, + options, + new MySqlDbContextConfigurationService(new MySqlConnectionStringFactory()), + new MySqlDbContextPropertyModifier()); + } + } +} diff --git a/src/Rasa.Test/Database/MigrationConsolidationTests.cs b/src/Rasa.Test/Database/MigrationConsolidationTests.cs index ec67b5f9..10166747 100644 --- a/src/Rasa.Test/Database/MigrationConsolidationTests.cs +++ b/src/Rasa.Test/Database/MigrationConsolidationTests.cs @@ -101,8 +101,8 @@ public void SqliteCharacterSchemaPreservesMissionColumnDefaults() ("mission_scene", "assignment_id", "''"), ("mission_timer", "sequence_id", "0") }) - Assert.AreEqual(value, database.Database.SqlQueryRaw( - $"SELECT dflt_value AS Value FROM pragma_table_info('{table}') WHERE name = '{column}'").Single(), + Assert.AreEqual(value, database.Database.SqlQuery( + $"SELECT dflt_value AS Value FROM pragma_table_info({table}) WHERE name = {column}").Single(), $"{table}.{column}"); } diff --git a/src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs b/src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs new file mode 100644 index 00000000..1618c896 --- /dev/null +++ b/src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Rasa.Test.Database +{ + using Rasa.Context; + using Rasa.Context.Auth; + using Rasa.Context.Char; + using Rasa.Context.World; + using Rasa.Repositories.Auth.Account; + using Rasa.Repositories.Char.Character; + using Rasa.Repositories.Char.CharacterMission; + using Rasa.Repositories.Char.GameAccount; + using Rasa.Services.Passwords; + using Rasa.Services.Random; + using Rasa.Structures.Char; + + /// + /// The MySQL contexts against a real server with every migration applied by + /// `dotnet ef database update`, the way a MySQL deployment gets its schema. The other database + /// tests build the MySQL model offline against a faked server version; these are the ones that + /// find a migration MySQL rejects, seed data that differs from Sqlite's, or a constraint only + /// one provider enforces. See for how they find the server. + /// + [TestClass] + [TestCategory(LiveMySql.Category)] + [DoNotParallelize] + public class MySqlLiveDatabaseTests + { + [TestMethod] + [DataRow(typeof(MySqlAuthContext))] + [DataRow(typeof(MySqlCharContext))] + [DataRow(typeof(MySqlWorldContext))] + public void EveryMigrationIsAppliedAndNoneIsPending(Type contextType) + { + using var context = LiveMySql.CreateContext(contextType); + + CollectionAssert.AreEqual( + context.Database.GetMigrations().ToArray(), + context.Database.GetAppliedMigrations().ToArray()); + Assert.IsFalse(context.Database.GetPendingMigrations().Any()); + } + + /// + /// Every table holds as many rows after MySQL's migrations as after Sqlite's: the seed data + /// and the data migrations are written per provider, and this is what notices when one of + /// them drifts. The tests here that write rows remove them again. + /// + [TestMethod] + [DataRow(typeof(MySqlAuthContext), typeof(SqliteAuthContext))] + [DataRow(typeof(MySqlCharContext), typeof(SqliteCharContext))] + [DataRow(typeof(MySqlWorldContext), typeof(SqliteWorldContext))] + public void MigratedTablesHoldTheSameRowsAsSqlite(Type mySqlType, Type sqliteType) + { + using var mySql = LiveMySql.CreateContext(mySqlType); + var path = Path.Combine(AppContext.BaseDirectory, "TestDatabases", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + try + { + using var sqlite = PersistenceIntegrationTests.CreateContext(sqliteType, Path.Combine(path, "database")); + sqlite.Database.Migrate(); + + var tables = mySql.Model.GetEntityTypes() + .Select(entity => entity.GetTableName()) + .Where(table => table != null) + .Distinct() + .OrderBy(table => table, StringComparer.Ordinal) + .ToArray(); + Assert.IsTrue(tables.Length > 0); + + var differences = new List(); + foreach (var table in tables) + { + var expected = CountRows(sqlite, table); + var actual = CountRows(mySql, table); + if (expected != actual) + differences.Add($"{table}: Sqlite {expected}, MySQL {actual}"); + } + + Assert.AreEqual(0, differences.Count, string.Join(Environment.NewLine, differences)); + } + finally + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + Directory.Delete(path, true); + } + } + + [TestMethod] + public void AuthAccountsRoundTripAndUsernamesAreUniqueWithoutCase() + { + var name = "Live" + Guid.NewGuid().ToString("N")[..12]; + using var context = (AuthContext)LiveMySql.CreateContext(typeof(MySqlAuthContext)); + using var random = new RandomNumberService(); + var accounts = new AuthAccountRepository(context, random, new HashSettings()); + try + { + accounts.Create(name + "@example.invalid", name, "password123"); + + using var reopened = (AuthContext)LiveMySql.CreateContext(typeof(MySqlAuthContext)); + var found = new AuthAccountRepository(reopened, random, new HashSettings()) + .FindByUserNameOrEmail(name.ToUpperInvariant(), null); + Assert.IsNotNull(found); + Assert.AreEqual(name, found.Username); + Assert.AreEqual("0.0.0.0", found.LastIp); + Assert.IsFalse(found.Locked); + Assert.IsTrue(accounts.CheckPassword(found, "password123")); + Assert.IsFalse(accounts.CheckPassword(found, "password124")); + + // MySQL's default collation compares without case, so the unique index already + // refuses "Bob" beside "bob"; Sqlite's BINARY collation would store both. + using var duplicate = (AuthContext)LiveMySql.CreateContext(typeof(MySqlAuthContext)); + Assert.ThrowsExactly(() => + new AuthAccountRepository(duplicate, random, new HashSettings()) + .Create("other-" + name + "@example.invalid", name.ToUpperInvariant(), "password123")); + } + finally + { + using var cleanup = LiveMySql.CreateContext(typeof(MySqlAuthContext)); + cleanup.Database.ExecuteSql($"DELETE FROM `account` WHERE username = {name}"); + } + } + + [TestMethod] + public void CharactersAndMissionsRoundTripAndForeignKeysHold() + { + var accountId = 4_000_000_000u + (uint)Random.Shared.Next(1, 100_000_000); + var name = "Live" + accountId; + try + { + uint characterId; + using (var context = (CharContext)LiveMySql.CreateContext(typeof(MySqlCharContext))) + { + var accounts = new GameAccountRepository(context); + accounts.CreateOrUpdate(accountId, name, name + "@example.invalid"); + var character = new CharacterRepository(context) + .Create(accounts.Get(accountId), 1, name, 1, 1.0, 0); + Assert.IsNotNull(character, "The character was not created; the error is in the log above."); + characterId = character.Id; + new CharacterMissionRepository(context).Add(new CharacterMissionEntry(characterId, 429, 2)); + } + + using (var reopened = (CharContext)LiveMySql.CreateContext(typeof(MySqlCharContext))) + { + var character = new CharacterRepository(reopened).GetByAccountId(accountId, 1); + Assert.IsNotNull(character); + Assert.AreEqual(characterId, character.Id); + Assert.AreEqual(name, character.GameAccount.Name); + Assert.AreEqual(string.Empty, character.GameAccount.FamilyName); + var mission = new CharacterMissionRepository(reopened).GetByCharacterAndMission(characterId, 429); + Assert.IsNotNull(mission); + Assert.AreEqual(2u, mission.MissionState); + Assert.AreEqual(32, mission.AssignmentId.Length); + + // InnoDB enforces both keys: an account can't go while it has a character + // (Restrict), and a character takes its missions with it (Cascade). + reopened.Remove(reopened.GameAccountEntries.Single(e => e.Id == accountId)); + Assert.ThrowsExactly(() => reopened.SaveChanges()); + } + + using (var context = (CharContext)LiveMySql.CreateContext(typeof(MySqlCharContext))) + { + context.Database.ExecuteSql($"DELETE FROM `character` WHERE id = {characterId}"); + Assert.AreEqual(0, context.CharacterMissionEntries.Count(e => e.CharacterId == characterId)); + } + } + finally + { + using var cleanup = LiveMySql.CreateContext(typeof(MySqlCharContext)); + cleanup.Database.ExecuteSql($"DELETE FROM `character` WHERE account_id = {accountId}"); + cleanup.Database.ExecuteSql($"DELETE FROM `account` WHERE id = {accountId}"); + } + } + + // Backticks quote an identifier in both MySQL and Sqlite. An identifier can't be a + // parameter; the table names come from the model. + private static int CountRows(RasaDbContextBase context, string table) => + context.Database.SqlQueryRaw("SELECT COUNT(*) AS Value FROM `" + table + "`").Single(); + + private sealed class HashSettings : IPasswordHashSettings + { + public string Pepper => string.Empty; + public int Iterations => PasswordHasher.MinimumIterations; + } + } +} diff --git a/src/Rasa.Test/Memory/NonContiguousMemoryStreamTests.cs b/src/Rasa.Test/Memory/NonContiguousMemoryStreamTests.cs index 3b90ba87..6a0f82f4 100644 --- a/src/Rasa.Test/Memory/NonContiguousMemoryStreamTests.cs +++ b/src/Rasa.Test/Memory/NonContiguousMemoryStreamTests.cs @@ -78,9 +78,9 @@ public void TestRead() var read3 = new byte[70]; var readCount3 = stream.Read(read3, 0, read3.Length); - Assert.AreEqual(readCount1, 30); - Assert.AreEqual(readCount2, 20); - Assert.AreEqual(readCount3, 70); + Assert.AreEqual(30, readCount1); + Assert.AreEqual(20, readCount2); + Assert.AreEqual(70, readCount3); Assert.AreEqual(stream.Position, stream.Length); // Validate read1 @@ -130,20 +130,20 @@ public void TestRemoveBytes() stream.CopyFromArray(buffer2); var throwAwayData = new byte[2]; - stream.Read(throwAwayData, 0, 2); + Assert.AreEqual(2, stream.Read(throwAwayData, 0, 2)); stream.RemoveBytes(3); var data = new byte[7]; - stream.Read(data, 0, data.Length); - - Assert.AreEqual(data[0], 3); - Assert.AreEqual(data[1], 4); - Assert.AreEqual(data[2], 0); - Assert.AreEqual(data[3], 2); - Assert.AreEqual(data[4], 4); - Assert.AreEqual(data[5], 6); - Assert.AreEqual(data[6], 8); + Assert.AreEqual(data.Length, stream.Read(data, 0, data.Length)); + + Assert.AreEqual(3, data[0]); + Assert.AreEqual(4, data[1]); + Assert.AreEqual(0, data[2]); + Assert.AreEqual(2, data[3]); + Assert.AreEqual(4, data[4]); + Assert.AreEqual(6, data[5]); + Assert.AreEqual(8, data[6]); Assert.AreEqual(stream.Length, buffer1.Length + buffer2.Length - 3); Assert.AreEqual(stream.Position, stream.Length); } @@ -166,12 +166,12 @@ public void TestRemoveMoreBytes() stream.CopyFromArray(buffer2); var throwAwayData = new byte[6]; - stream.Read(throwAwayData, 0, 6); + Assert.AreEqual(6, stream.Read(throwAwayData, 0, 6)); stream.RemoveBytes(9); - Assert.AreEqual(stream.Length, 1); - Assert.AreEqual(stream.Position, 0); + Assert.AreEqual(1, stream.Length); + Assert.AreEqual(0, stream.Position); } [TestMethod] @@ -192,12 +192,12 @@ public void TestRemoveAllBytes() stream.CopyFromArray(buffer2); var throwAwayData = new byte[6]; - stream.Read(throwAwayData, 0, 6); + Assert.AreEqual(6, stream.Read(throwAwayData, 0, 6)); stream.RemoveBytes(10); - Assert.AreEqual(stream.Length, 0); - Assert.AreEqual(stream.Position, 0); + Assert.AreEqual(0, stream.Length); + Assert.AreEqual(0, stream.Position); } [TestMethod] diff --git a/src/Rasa.Test/Missions/BootcampWorldContentTests.cs b/src/Rasa.Test/Missions/BootcampWorldContentTests.cs index 522be3c3..324e0066 100644 --- a/src/Rasa.Test/Missions/BootcampWorldContentTests.cs +++ b/src/Rasa.Test/Missions/BootcampWorldContentTests.cs @@ -813,9 +813,12 @@ public void ConsolidatedWorldContentPreservesEveryBootcampTableRowCount() ["mission_scenario_step"] = 52, ["mission_evidence"] = 17, ["mission_scene_binding"] = 5, ["mission_channel_policy"] = 1, ["mission_repeat_policy"] = 0 }; + // A table name can't be a SQL parameter; these come from the literal list above. +#pragma warning disable EF1002 foreach (var (table, count) in expected) Assert.AreEqual(count, context.Database.SqlQueryRaw( $"SELECT COUNT(*) AS Value FROM {table} WHERE content_revision = 'deployment_11'").Single(), table); +#pragma warning restore EF1002 Assert.AreEqual(1, context.Set().Count()); }); } diff --git a/src/Rasa.Test/Missions/MissionEquipmentProgressTests.cs b/src/Rasa.Test/Missions/MissionEquipmentProgressTests.cs index b301628e..78c2d8e4 100644 --- a/src/Rasa.Test/Missions/MissionEquipmentProgressTests.cs +++ b/src/Rasa.Test/Missions/MissionEquipmentProgressTests.cs @@ -18,7 +18,7 @@ namespace Rasa.Test.Missions [DoNotParallelize] public class MissionEquipmentProgressTests { - [DataTestMethod] + [TestMethod] [DataRow(false)] [DataRow(true)] public void CommittedEquipCompletesConfiguredEquipmentObjective(bool matchTemplateId) diff --git a/src/Rasa.Test/Missions/MissionProgressRuleAuthoringTests.cs b/src/Rasa.Test/Missions/MissionProgressRuleAuthoringTests.cs index 9f4c237f..5c903832 100644 --- a/src/Rasa.Test/Missions/MissionProgressRuleAuthoringTests.cs +++ b/src/Rasa.Test/Missions/MissionProgressRuleAuthoringTests.cs @@ -163,7 +163,7 @@ public void ItemCounterRuleAcceptsUIntBoundaryRange() Assert.IsNull(diagnostic); } - [DataTestMethod] + [TestMethod] [DataRow(false, 7000U)] [DataRow(true, 2800U)] public void ItemEquippedRuleAcceptsClassOrTemplateSelection( diff --git a/src/Rasa.Test/Missions/RuntimeHarnessRetentionTests.cs b/src/Rasa.Test/Missions/RuntimeHarnessRetentionTests.cs new file mode 100644 index 00000000..50e203ba --- /dev/null +++ b/src/Rasa.Test/Missions/RuntimeHarnessRetentionTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Runtime.CompilerServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Rasa.Test.Missions.Wilderness; + +namespace Rasa.Test.Missions +{ + /// + /// A runtime harness builds a whole world: a map channel, its managers, creatures and a + /// navmesh query. Once it is disposed nothing static may keep that world reachable, or every + /// test that builds one adds its world to the test host for the rest of the run (#132: the + /// suite in one process grew past 12 GB). + /// + [TestClass] + public class RuntimeHarnessRetentionTests + { + [TestMethod] + public void DisposedWildernessHarnessLeavesItsWorldCollectable() + { + var world = CreateAndDisposeWildernessHarness(); + + AssertCollected(world.Map, "map channel"); + AssertCollected(world.Maps, "map channel manager"); + AssertCollected(world.Manager, "mission application"); + } + + [TestMethod] + public void DisposedBootcampHarnessLeavesItsWorldCollectable() + { + var world = CreateAndDisposeBootcampHarness(); + + AssertCollected(world.Map, "map channel"); + AssertCollected(world.Maps, "map channel manager"); + } + + // Not inlined, so no local of the caller's frame holds the harness or its world. + [MethodImpl(MethodImplOptions.NoInlining)] + private static (WeakReference Map, WeakReference Maps, WeakReference Manager) CreateAndDisposeWildernessHarness() + { + using var harness = WildernessRuntimeTestHarness.Create(); + harness.SpawnWorld(); + for (var tick = 0; tick < 8; tick++) + harness.Tick(); + return (new WeakReference(harness.Map), new WeakReference(harness.Maps), + new WeakReference(harness.Manager)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static (WeakReference Map, WeakReference Maps) CreateAndDisposeBootcampHarness() + { + using var harness = BootcampRuntimeTestHarness.Create(useWorldContent: true); + return (new WeakReference(harness.BootcampMap), new WeakReference(harness.Maps)); + } + + private static void AssertCollected(WeakReference reference, string what) + { + for (var attempt = 0; attempt < 3 && reference.IsAlive; attempt++) + { + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + } + Assert.IsFalse(reference.IsAlive, $"A disposed harness's {what} is still reachable."); + } + } +} diff --git a/src/Rasa.Test/Missions/Wilderness/WildernessRuntimeTestHarness.cs b/src/Rasa.Test/Missions/Wilderness/WildernessRuntimeTestHarness.cs index c7bae06f..f3535e49 100644 --- a/src/Rasa.Test/Missions/Wilderness/WildernessRuntimeTestHarness.cs +++ b/src/Rasa.Test/Missions/Wilderness/WildernessRuntimeTestHarness.cs @@ -29,16 +29,9 @@ internal sealed class WildernessRuntimeTestHarness : IGameUnitOfWorkFactory, IDi private readonly string _directory; private readonly List _restoreServices = new(); private readonly HashSet _originalCreatures = EntityManager.Instance.Creatures.Keys.ToHashSet(); + private readonly HashSet _originalObjects = EntityManager.Instance.DynamicObjects.Keys.ToHashSet(); private bool _disposed; - /// - /// The Wilderness navmesh, read once for the test run: read for every harness, each - /// one's stayed reachable after Dispose, about 50 MB a test. A harness has its own - /// NavMeshQuery over this one's mesh - a query is not thread safe, the mesh is only read. - /// - private static readonly Lazy WildernessNavMesh = new(() => new NavMeshQuery(NavMeshFile.Read( - NavMeshFile.PathFor(Path.Combine(RepositoryRoot(), "navmesh"), "adv_foreas_concordia_wilderness")))); - private WildernessRuntimeTestHarness() { _directory = Path.Combine(AppContext.BaseDirectory, "TestDatabases", Guid.NewGuid().ToString("N")); @@ -291,6 +284,12 @@ public void Dispose() .Where(creature => !_originalCreatures.Contains(creature.EntityId) && Map != null && ReferenceEquals(creature.RuntimeMapChannel, Map)).ToArray()) CellManager.Instance.RemoveCreatureFromWorld(Map, creature); + // The map's objects too: EntityManager outlives the harness, and its registered + // logos, teleporters and other objects each hold this map and so its whole world. + foreach (var dynamicObject in EntityManager.Instance.DynamicObjects.Values + .Where(dynamicObject => !_originalObjects.Contains(dynamicObject.EntityId) && + Map != null && ReferenceEquals(dynamicObject.RuntimeMapChannel, Map)).ToArray()) + CellManager.Instance.RemoveFromWorld(Map, dynamicObject); foreach (var restore in _restoreServices.AsEnumerable().Reverse()) restore(); World?.Dispose(); diff --git a/src/Rasa.Test/Rasa.Test.csproj b/src/Rasa.Test/Rasa.Test.csproj index 307220d2..43363837 100644 --- a/src/Rasa.Test/Rasa.Test.csproj +++ b/src/Rasa.Test/Rasa.Test.csproj @@ -9,10 +9,10 @@ - - - - + + + + all diff --git a/src/Rasa.Test/World/ClientWaypointIdTests.cs b/src/Rasa.Test/World/ClientWaypointIdTests.cs index cea4ed7c..31a7a080 100644 --- a/src/Rasa.Test/World/ClientWaypointIdTests.cs +++ b/src/Rasa.Test/World/ClientWaypointIdTests.cs @@ -171,7 +171,7 @@ public void TheCharacterMigrationMovesWhatEachCharacterHasGained() (2, 575, 2), (2, 622, 2), (2, 583, 2), (2, 135, 2), (3, 582, 2), (3, 607, 2), (3, 534, 2), (3, 624, 2), (3, 541, 2), (3, 576, 2), (3, 613, 5), (3, 583, 2), (3, 57, 2) }) - context.Database.ExecuteSqlRaw($"insert into character_teleporter (character_id, waypointId, waypoint_type) values ({character}, {waypoint}, {type});"); + context.Database.ExecuteSql($"insert into character_teleporter (character_id, waypointId, waypoint_type) values ({character}, {waypoint}, {type});"); migrator.Migrate(); diff --git a/src/Rasa.Utils/Rasa.Utils.csproj b/src/Rasa.Utils/Rasa.Utils.csproj index 75c3c860..4ef6515f 100644 --- a/src/Rasa.Utils/Rasa.Utils.csproj +++ b/src/Rasa.Utils/Rasa.Utils.csproj @@ -13,10 +13,10 @@ - - - - + + + +