Skip to content

Commit

Permalink
Quick-check mods for updates on server startup using workshop item up…
Browse files Browse the repository at this point in the history
…dated date (#96)

* Implement quick mods update to run before server startup

* Fix incorrect media type error when making request without JSON body

* Remove verifyMods from status

* Perform mods verification during preparation for upcoming missions

* Fix missing mods not returned as needing update
  • Loading branch information
3Mydlo3 authored Sep 14, 2024
1 parent 37a2361 commit 4381c46
Show file tree
Hide file tree
Showing 17 changed files with 379 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
Expand All @@ -8,6 +9,7 @@
using ArmaForces.ArmaServerManager.Features.Mods;
using ArmaForces.ArmaServerManager.Features.Steam.Content;
using ArmaForces.ArmaServerManager.Features.Steam.Content.DTOs;
using ArmaForces.ArmaServerManager.Features.Steam.RemoteStorage;
using AutoFixture;
using CSharpFunctionalExtensions;
using FluentAssertions;
Expand All @@ -24,17 +26,20 @@ public class ModsManagerUnitTests
private readonly Mock<IModsCache> _modsCacheMock;
private readonly Mock<IContentVerifier> _contentVerifierMock;
private readonly Mock<IContentDownloader> _downloaderMock;
private readonly Mock<ISteamRemoteStorage> _steamRemoteStorageMock;
private readonly ModsManager _modsManager;

public ModsManagerUnitTests()
{
_modsCacheMock = CreateModsCacheMock();
_contentVerifierMock = CreateContentVerifierMock();
_downloaderMock = CreateContentDownloaderMock();
_steamRemoteStorageMock = CreateSteamRemoteStorageMock();
_modsManager = new ModsManager(
_downloaderMock.Object,
_contentVerifierMock.Object,
_modsCacheMock.Object,
_steamRemoteStorageMock.Object,
new NullLogger<ModsManager>());
}

Expand Down Expand Up @@ -84,14 +89,25 @@ public async Task PrepareModset_SomeModsOutdated_UpdatedOutdatedMods()

var outdatedMods = modset.Mods
.Take(5)
.Select(x => {
x.LastUpdatedAt = DateTime.Now.AddDays(-10);
return x;
})
.ToList();
var upToDateMods = modset.Mods
.Except(outdatedMods)
.Select(x => {
x.LastUpdatedAt = DateTime.Now.AddDays(10);
return x;
})
.ToList();

modset.Mods = outdatedMods.Concat(upToDateMods).ToHashSet();

AddModsToModsCache(modset.Mods.ToList());
SetModsAsUpToDate(upToDateMods);
SetupPublishedFileDetails(modset.Mods);
SetupContentDownloader(outdatedMods);
AddModsToModsCache(modset.Mods.ToList());

await _modsManager.PrepareModset(modset, cancellationToken);

Expand Down Expand Up @@ -160,6 +176,24 @@ private void SetModsAsUpToDate(IReadOnlyCollection<Mod> mods)
.Returns(Task.FromResult(Result.Success(contentItem)));
}
}

private void SetupPublishedFileDetails(ISet<Mod> mods)
{
var modsIds = mods.Select(x => (ulong)x.WorkshopId!);

var details = mods
.Select(x => new PublishedFileDetails
{
Title = x.Name,
PublishedFileId = x.WorkshopId!.Value,
LastUpdatedAt = DateTime.Now
})
.ToArray();

_steamRemoteStorageMock
.Setup(x => x.GetPublishedFileDetails(modsIds, It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(Result.Success(details)));
}

private void AddModsToModsCache(IReadOnlyCollection<Mod> mods)
{
Expand All @@ -169,6 +203,11 @@ private void AddModsToModsCache(IReadOnlyCollection<Mod> mods)
.Setup(x => x.ModExists(mod))
.Returns(Task.FromResult(true));
}

var modsInCache = _modsCacheMock.Object.Mods;
_modsCacheMock
.Setup(x => x.Mods)
.Returns(modsInCache.Concat(mods).ToList());
}

private Mock<IModsCache> CreateModsCacheMock()
Expand All @@ -178,6 +217,10 @@ private Mock<IModsCache> CreateModsCacheMock()
mock
.Setup(x => x.ModExists(It.IsAny<Mod>()))
.Returns(Task.FromResult(false));

mock
.Setup(x => x.Mods)
.Returns(new List<Mod>());

return mock;
}
Expand All @@ -203,5 +246,16 @@ private Mock<IContentDownloader> CreateContentDownloaderMock()

return mock;
}

private Mock<ISteamRemoteStorage> CreateSteamRemoteStorageMock()
{
var mock = new Mock<ISteamRemoteStorage>();

mock
.Setup(x => x.GetPublishedFileDetails(It.IsAny<IReadOnlyCollection<ulong>>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(Result.Success(Array.Empty<PublishedFileDetails>())));

return mock;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class JobScheduleRequestDto
/// Time when job should be processed.
/// Exact start time will depend on other jobs processing and enqueued at this time.
/// </summary>
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
[JsonProperty(Required = Required.DisallowNull, DefaultValueHandling = DefaultValueHandling.Ignore)]
public DateTime? ScheduleAt { get; set; }
}
}
19 changes: 15 additions & 4 deletions ArmaForces.ArmaServerManager/Api/Mods/ModsController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using ArmaForces.ArmaServerManager.Api.Jobs.DTOs;
using ArmaForces.ArmaServerManager.Api.Mods.DTOs;
Expand Down Expand Up @@ -93,14 +93,25 @@ public IActionResult UpdateModset(string modsetName, [FromBody] ModsetUpdateRequ
}

/// <summary>Verify Modset</summary>
/// <remarks>Triggers or schedules verification of given modset. <b>Not implemented.</b></remarks>
/// <remarks>Triggers or schedules verification of given modset.</remarks>
/// <param name="modsetName">Name of modset to verify.</param>
/// <param name="jobScheduleRequestDto">Optional job schedule details.</param>
[HttpPost("{modsetName}/verify", Name = nameof(VerifyModset))]
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
[ProducesResponseType(typeof(int), StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
public IActionResult VerifyModset(string modsetName, [FromBody] JobScheduleRequestDto jobScheduleRequestDto)
{
throw new NotImplementedException("Modset verification is not implemented yet.");
var result = _jobsScheduler
.ScheduleJob<ServerStartupService>(
x => x.ShutdownAllServers(false, CancellationToken.None),
jobScheduleRequestDto.ScheduleAt)
.Bind(shutdownJobId => _jobsScheduler.ContinueJobWith<ModsVerificationService>(
shutdownJobId,
x => x.VerifyModset(modsetName, CancellationToken.None)));

return result.Match(
onSuccess: JobAccepted,
onFailure: TooEarly);
}
}
}
5 changes: 5 additions & 0 deletions ArmaForces.ArmaServerManager/Api/Status/DTOs/AppStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ public enum AppStatus
/// </summary>
UpdatingMods,

/// <summary>
/// Mods are being verified.
/// </summary>
VerifyingMods,

/// <summary>
/// Server is starting.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using ArmaForces.ArmaServerManager.Features.Modsets.DependencyInjection;
using ArmaForces.ArmaServerManager.Features.Steam;
using ArmaForces.ArmaServerManager.Features.Steam.Content;
using ArmaForces.ArmaServerManager.Features.Steam.RemoteStorage;
using Microsoft.Extensions.DependencyInjection;

namespace ArmaForces.ArmaServerManager.Features.Mods.DependencyInjection
Expand All @@ -23,6 +24,7 @@ public static IServiceCollection AddMods(this IServiceCollection services)
private static IServiceCollection AddContent(this IServiceCollection services)
=> services
.AddScoped<ISteamClient, SteamClient>()
.AddScoped<ISteamRemoteStorage, SteamRemoteStorage>()
.AddScoped<IManifestDownloader, ManifestDownloader>()
.AddScoped<IContentDownloader, ContentDownloader>()
.AddScoped<IContentVerifier, ContentVerifier>()
Expand Down
15 changes: 13 additions & 2 deletions ArmaForces.ArmaServerManager/Features/Mods/IModsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
using ArmaForces.Arma.Server.Features.Modsets;
using CSharpFunctionalExtensions;

namespace ArmaForces.ArmaServerManager.Features.Mods {
namespace ArmaForces.ArmaServerManager.Features.Mods
{
/// <summary>
/// Prepares modset by downloading missing mods and updating outdated mods.
/// </summary>
public interface IModsManager {
public interface IModsManager
{
/// <inheritdoc cref="IModsManager"/>
/// <param name="modset">Modset to prepare.</param>
/// <param name="cancellationToken"></param>
Expand All @@ -27,6 +29,7 @@ public interface IModsManager {
/// Checks if all mods from given list are up to date.
/// </summary>
/// <param name="modsList">List of mods to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns><see cref="Result{T}"/> with outdated mods.</returns>
Task<Result<List<Mod>>> CheckModsUpdated(IReadOnlyCollection<Mod> modsList, CancellationToken cancellationToken);

Expand All @@ -42,5 +45,13 @@ public interface IModsManager {
/// </summary>
/// <param name="cancellationToken"><see cref="CancellationToken"/> used for task cancellation.</param>
Task UpdateAllMods(CancellationToken cancellationToken);

/// <summary>
/// Verifies all mods from given <paramref name="modsList"/>.
/// </summary>
/// <param name="modsList">List of mods to verify.</param>
/// <param name="cancellationToken">Cancellation Token</param>
/// <returns><see cref="Result{T}"/> with mods which failed verification.</returns>
Task<Result<List<Mod>>> VerifyMods(IReadOnlyCollection<Mod> modsList, CancellationToken cancellationToken);
}
}
40 changes: 40 additions & 0 deletions ArmaForces.ArmaServerManager/Features/Mods/ModsManager.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
Expand All @@ -8,6 +9,8 @@
using ArmaForces.Arma.Server.Features.Modsets;
using ArmaForces.ArmaServerManager.Extensions;
using ArmaForces.ArmaServerManager.Features.Steam.Content;
using ArmaForces.ArmaServerManager.Features.Steam.Content.DTOs;
using ArmaForces.ArmaServerManager.Features.Steam.RemoteStorage;
using CSharpFunctionalExtensions;
using Microsoft.Extensions.Logging;

Expand All @@ -19,22 +22,26 @@ internal class ModsManager : IModsManager
private readonly IContentDownloader _contentDownloader;
private readonly IContentVerifier _contentVerifier;
private readonly IModsCache _modsCache;
private readonly ISteamRemoteStorage _steamRemoteStorage;
private readonly ILogger<ModsManager> _logger;

/// <inheritdoc cref="ModsManager" />
/// <param name="contentDownloader">Client for mods download and updating.</param>
/// <param name="contentVerifier">Client for verifying whether mods are up to date and correct.</param>
/// <param name="modsCache">Installed mods cache.</param>
/// <param name="steamRemoteStorage">Steam remote storage for quick access to mods metadata on Workshop.</param>
/// <param name="logger">Logger.</param>
public ModsManager(
IContentDownloader contentDownloader,
IContentVerifier contentVerifier,
IModsCache modsCache,
ISteamRemoteStorage steamRemoteStorage,
ILogger<ModsManager> logger)
{
_contentDownloader = contentDownloader;
_contentVerifier = contentVerifier;
_modsCache = modsCache;
_steamRemoteStorage = steamRemoteStorage;
_logger = logger;
}

Expand Down Expand Up @@ -71,6 +78,39 @@ public async Task<Result<List<Mod>>> CheckModsUpdated(IReadOnlyCollection<Mod> m
return Result.Success(new List<Mod>());
}

var workshopMods = modsList
.Where(x => x.Source == ModSource.SteamWorkshop)
.ToList();

var workshopModIds = workshopMods
.Select(x => x.WorkshopId)
.Where(x => x.HasValue)
.Select(x => (ulong)x!.Value)
.ToList();

var publishedFileDetails = await _steamRemoteStorage.GetPublishedFileDetails(workshopModIds, cancellationToken);

var modsToUpdate = _modsCache.Mods
.Join(publishedFileDetails.Value, mod => mod.WorkshopId, fileDetails => fileDetails.PublishedFileId,
(mod, details) => new {mod, details})
.Where(x => x.mod.LastUpdatedAt < x.details.LastUpdatedAt)
.Select(x => x.mod)
.ToList();

var modsNotInCache = workshopMods
.Where(x => _modsCache.Mods.NotContains(x));

return Result.Success(modsNotInCache.Concat(modsToUpdate).ToList());
}

/// <inheritdoc />
public async Task<Result<List<Mod>>> VerifyMods(IReadOnlyCollection<Mod> modsList, CancellationToken cancellationToken)
{
if (modsList.IsEmpty())
{
return Result.Success(new List<Mod>());
}

var modsRequireUpdate = new ConcurrentBag<Mod>();

await foreach (var mod in modsList.Where(x => x.Source == ModSource.SteamWorkshop)
Expand Down
57 changes: 57 additions & 0 deletions ArmaForces.ArmaServerManager/Features/Status/AppStatusStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System;
using ArmaForces.ArmaServerManager.Api.Status.DTOs;
using ArmaForces.ArmaServerManager.Features.Status.Models;

namespace ArmaForces.ArmaServerManager.Features.Status;

/// <summary>
/// TODO: Consider doing this in a better way. For now it's fine.
/// </summary>
public class AppStatusStore : IAppStatusStore
{
public AppStatusDetails? StatusDetails { get; private set; }

public IDisposable SetAppStatus(AppStatus appStatus, string? longStatus = null)
{
var previousStatus = StatusDetails;

StatusDetails = new AppStatusDetails
{
Status = appStatus,
LongStatus = longStatus
};

return new AppStatusDisposable(this, previousStatus);
}

public void ClearAppStatus()
{
StatusDetails = null;
}

private class AppStatusDisposable : IDisposable
{
private readonly AppStatusStore _appStatusStore;
private readonly AppStatusDetails? _previousStatus;

public AppStatusDisposable(AppStatusStore appStatusStore, AppStatusDetails? previousStatus)
{
_appStatusStore = appStatusStore;
_previousStatus = previousStatus;
}

public void Dispose()
{
_appStatusStore.StatusDetails = _previousStatus;
}
}
}

public interface IAppStatusStore
{
AppStatusDetails? StatusDetails { get; }

IDisposable SetAppStatus(AppStatus appStatus, string? longStatus = null);

void ClearAppStatus();
}
Loading

0 comments on commit 4381c46

Please sign in to comment.