Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Quick-check mods for updates on server startup using workshop item updated date #96

Merged
merged 6 commits into from
Sep 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading