Refactored container helper service. Cleaned up event models. Implemented version changing. Added security questions before rebuild

This commit is contained in:
2026-01-29 11:23:07 +01:00
parent 97a676ccd7
commit e1207b8d9b
15 changed files with 242 additions and 55 deletions

View File

@@ -2,19 +2,21 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration; using Moonlight.Api.Configuration;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services; using Moonlight.Api.Services;
using Moonlight.Shared.Http.Requests.Admin.ContainerHelper;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController] [ApiController]
[Route("api/admin/ch")] [Route("api/admin/ch")]
public class ChController : Controller public class ContainerHelperController : Controller
{ {
private readonly ContainerHelperService ContainerHelperService; private readonly ContainerHelperService ContainerHelperService;
private readonly IOptions<ContainerHelperOptions> Options; private readonly IOptions<ContainerHelperOptions> Options;
public ChController(ContainerHelperService containerHelperService, IOptions<ContainerHelperOptions> options) public ContainerHelperController(ContainerHelperService containerHelperService, IOptions<ContainerHelperOptions> options)
{ {
ContainerHelperService = containerHelperService; ContainerHelperService = containerHelperService;
Options = options; Options = options;
@@ -35,9 +37,17 @@ public class ChController : Controller
public Task<IResult> RebuildAsync() public Task<IResult> RebuildAsync()
{ {
var result = ContainerHelperService.RebuildAsync(); var result = ContainerHelperService.RebuildAsync();
var mappedResult = result.Select(ContainerHelperMapper.ToDto);
return Task.FromResult<IResult>( return Task.FromResult<IResult>(
TypedResults.ServerSentEvents(result) TypedResults.ServerSentEvents(mappedResult)
); );
} }
[HttpPost("version")]
public async Task<ActionResult> SetVersionAsync([FromBody] SetVersionDto request)
{
await ContainerHelperService.SetVersionAsync(request.Version);
return NoContent();
}
} }

View File

@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace Moonlight.Api.Http.Services.ContainerHelper.Events;
public struct RebuildEventDto
{
[JsonPropertyName("type")]
public RebuildEventType Type { get; set; }
[JsonPropertyName("data")]
public string Data { get; set; }
}
public enum RebuildEventType
{
Log = 0,
Failed = 1,
Succeeded = 2,
Step = 3
}

View File

@@ -0,0 +1,10 @@
namespace Moonlight.Api.Http.Services.ContainerHelper;
public class ProblemDetails
{
public string Type { get; set; }
public string Title { get; set; }
public int Status { get; set; }
public string? Detail { get; set; }
public Dictionary<string, string[]>? Errors { get; set; }
}

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Api.Http.Services.ContainerHelper.Requests;
public record SetVersionDto(string Version);

View File

@@ -0,0 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Moonlight.Api.Http.Services.ContainerHelper.Events;
using Moonlight.Api.Http.Services.ContainerHelper.Requests;
namespace Moonlight.Api.Http.Services.ContainerHelper;
[JsonSerializable(typeof(SetVersionDto))]
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(RebuildEventDto))]
public partial class SerializationContext : JsonSerializerContext
{
private static JsonSerializerOptions? InternalTunedOptions;
public static JsonSerializerOptions TunedOptions
{
get
{
if (InternalTunedOptions != null)
return InternalTunedOptions;
InternalTunedOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
InternalTunedOptions.TypeInfoResolverChain.Add(Default);
return InternalTunedOptions;
}
}
}

View File

@@ -0,0 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Events;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class ContainerHelperMapper
{
public static partial RebuildEventDto ToDto(Http.Services.ContainerHelper.Events.RebuildEventDto rebuildEventDto);
}

View File

@@ -35,4 +35,8 @@
<Visible Condition="'%(NuGetItemType)' == 'Content'">false</Visible> <Visible Condition="'%(NuGetItemType)' == 'Content'">false</Visible>
</Content> </Content>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Http\Services\ContainerHelper\Responses\" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,6 +1,8 @@
using System.Text.Json; using System.Net.Http.Json;
using Moonlight.Shared.Http; using System.Text.Json;
using Moonlight.Shared.Http.Events; using Moonlight.Api.Http.Services.ContainerHelper;
using Moonlight.Api.Http.Services.ContainerHelper.Requests;
using Moonlight.Api.Http.Services.ContainerHelper.Events;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Services;
@@ -30,15 +32,8 @@ public class ContainerHelperService
} }
} }
public async IAsyncEnumerable<RebuildEvent> RebuildAsync() public async IAsyncEnumerable<RebuildEventDto> RebuildAsync()
{ {
var options = new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = true
};
options.TypeInfoResolverChain.Add(SerializationContext.Default);
var client = HttpClientFactory.CreateClient("ContainerHelper"); var client = HttpClientFactory.CreateClient("ContainerHelper");
var response = await client.GetAsync("api/rebuild", HttpCompletionOption.ResponseHeadersRead); var response = await client.GetAsync("api/rebuild", HttpCompletionOption.ResponseHeadersRead);
@@ -47,7 +42,7 @@ public class ContainerHelperService
{ {
var responseText = await response.Content.ReadAsStringAsync(); var responseText = await response.Content.ReadAsStringAsync();
yield return new RebuildEvent() yield return new RebuildEventDto()
{ {
Type = RebuildEventType.Failed, Type = RebuildEventType.Failed,
Data = responseText Data = responseText
@@ -63,28 +58,47 @@ public class ContainerHelperService
{ {
var line = await streamReader.ReadLineAsync(); var line = await streamReader.ReadLineAsync();
if(line == null) if (line == null)
break; break;
if(string.IsNullOrWhiteSpace(line)) if (string.IsNullOrWhiteSpace(line))
continue; continue;
var data = line.Trim("data: "); var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, SerializationContext.TunedOptions);
var deserializedData = JsonSerializer.Deserialize<RebuildEvent>(data, options);
yield return deserializedData; yield return deserializedData;
// Exit if service will go down for a clean exit // Exit if service will go down for a clean exit
if(deserializedData is {Type: RebuildEventType.Step, Data: "ServiceDown"}) if (deserializedData is { Type: RebuildEventType.Step, Data: "ServiceDown" })
yield break; yield break;
} while (true); } while (true);
yield return new RebuildEvent() yield return new RebuildEventDto()
{ {
Type = RebuildEventType.Succeeded, Type = RebuildEventType.Succeeded,
Data = string.Empty Data = string.Empty
}; };
} }
public async Task SetVersionAsync(string version)
{
var client = HttpClientFactory.CreateClient("ContainerHelper");
var response = await client.PostAsJsonAsync(
"api/configuration/version",
new SetVersionDto(version),
SerializationContext.TunedOptions
);
if(response.IsSuccessStatusCode)
return;
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.TunedOptions);
if(problemDetails == null)
throw new HttpRequestException($"Failed to set version: {response.ReasonPhrase}");
throw new HttpRequestException($"Failed to set version: {problemDetails.Detail ?? problemDetails.Title}");
}
} }

View File

@@ -19,7 +19,7 @@ public partial class Startup
{ {
builder.Services.AddControllers().AddJsonOptions(options => builder.Services.AddControllers().AddJsonOptions(options =>
{ {
options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default); options.JsonSerializerOptions.TypeInfoResolverChain.Add(SharedSerializationContext.Default);
}); });
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();

View File

@@ -18,7 +18,7 @@ public static class Constants
}; };
// Add source generated options from shared project // Add source generated options from shared project
InternalOptions.TypeInfoResolverChain.Add(SerializationContext.Default); InternalOptions.TypeInfoResolverChain.Add(SharedSerializationContext.Default);
return InternalOptions; return InternalOptions;
} }

View File

@@ -3,6 +3,7 @@
@using System.Text.Json @using System.Text.Json
@using LucideBlazor @using LucideBlazor
@using Moonlight.Shared.Http.Events @using Moonlight.Shared.Http.Events
@using Moonlight.Shared.Http.Requests.Admin.ContainerHelper
@using ShadcnBlazor.Buttons @using ShadcnBlazor.Buttons
@using ShadcnBlazor.Dialogs @using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Progresses @using ShadcnBlazor.Progresses
@@ -110,7 +111,10 @@ else
Progress = 20; Progress = 20;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
await Task.Delay(2000); await HttpClient.PostAsJsonAsync("api/admin/ch/version", new SetVersionDto()
{
Version = Version
});
// Starting rebuild task // Starting rebuild task
CurrentStep = 2; CurrentStep = 2;
@@ -138,7 +142,7 @@ else
continue; continue;
var data = line.Trim("data: "); var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEvent>(data, Constants.SerializerOptions); var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, Constants.SerializerOptions);
switch (deserializedData.Type) switch (deserializedData.Type)
{ {

View File

@@ -1,3 +1,4 @@
@using System.Text.RegularExpressions
@using LucideBlazor @using LucideBlazor
@using Moonlight.Frontend.UI.Admin.Modals @using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared.Http.Responses.Admin @using Moonlight.Shared.Http.Responses.Admin
@@ -39,14 +40,15 @@
</SelectTrigger> </SelectTrigger>
<SelectContent ClassName="w-64"> <SelectContent ClassName="w-64">
<SelectItem Value="v2.1">v2.1</SelectItem> <SelectItem Value="v2.1">v2.1</SelectItem>
<SelectItem Value="v2.1.1">v2.1.1</SelectItem> <SelectItem Value="feat/ContainerHelper">feat/ContainerHelper
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FieldContent> </FieldContent>
</Field> </Field>
</FieldSet> </FieldSet>
<Field Orientation="FieldOrientation.Horizontal"> <Field Orientation="FieldOrientation.Horizontal">
<Button @onclick="ApplyAsync">Apply</Button> <Button @onclick="AskApplyAsync">Apply</Button>
</Field> </Field>
</FieldGroup> </FieldGroup>
</CardContent> </CardContent>
@@ -115,11 +117,6 @@
} }
private async Task ApplyAsync() private async Task ApplyAsync()
{
await AlertDialogService.ConfirmDangerAsync(
"Moonlight Rebuild",
"If you continue the moonlight instance will become unavailable during the rebuild process. This will impact users on this instance",
async () =>
{ {
await DialogService.LaunchAsync<UpdateInstanceModal>( await DialogService.LaunchAsync<UpdateInstanceModal>(
parameters => { parameters[nameof(UpdateInstanceModal.Version)] = SelectedVersion; }, parameters => { parameters[nameof(UpdateInstanceModal.Version)] = SelectedVersion; },
@@ -130,6 +127,64 @@
} }
); );
} }
private async Task AskApplyAsync()
{
if(string.IsNullOrWhiteSpace(SelectedVersion))
return;
var shouldContinue = await ConfirmRiskyVersionAsync(
"Moonlight Rebuild",
"If you continue the moonlight instance will become unavailable during the rebuild process. This will impact users on this instance"
);
if(!shouldContinue)
return;
if (!Regex.IsMatch(SelectedVersion, @"^v\d+(\.\d+)*b?$"))
{
shouldContinue = await ConfirmRiskyVersionAsync(
"Development Version",
"You are about to install development a version. This can break your instance. Continue at your own risk"
); );
} }
else
{
if (SelectedVersion.EndsWith('b'))
{
shouldContinue = await ConfirmRiskyVersionAsync(
"Beta / Pre-Release Version",
"You are about to install a version marked as pre-release / beta. This can break your instance. Continue at your own risk"
);
}
else
shouldContinue = true;
}
if (!shouldContinue)
return;
await ApplyAsync();
}
private async Task<bool> ConfirmRiskyVersionAsync(string title, string message)
{
var tcs = new TaskCompletionSource();
var confirmed = false;
await AlertDialogService.ConfirmDangerAsync(
title,
message,
() =>
{
confirmed = true;
tcs.SetResult();
return Task.CompletedTask;
}
);
await tcs.Task;
return confirmed;
}
} }

View File

@@ -2,7 +2,7 @@
namespace Moonlight.Shared.Http.Events; namespace Moonlight.Shared.Http.Events;
public struct RebuildEvent public struct RebuildEventDto
{ {
[JsonPropertyName("type")] [JsonPropertyName("type")]
public RebuildEventType Type { get; set; } public RebuildEventType Type { get; set; }

View File

@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Admin.ContainerHelper;
public class SetVersionDto
{
[Required]
[RegularExpression(@"^(?!\/|.*\/\/|.*\.\.|.*\/$)[A-Za-z0-9._/-]+$", ErrorMessage = "Invalid version format")]
public string Version { get; set; }
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json;
using System.Text.Json.Serialization;
using Moonlight.Shared.Http.Events; using Moonlight.Shared.Http.Events;
using Moonlight.Shared.Http.Requests.ApiKeys; using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Requests.Roles; using Moonlight.Shared.Http.Requests.Roles;
@@ -46,13 +47,28 @@ namespace Moonlight.Shared.Http;
[JsonSerializable(typeof(ThemeDto))] [JsonSerializable(typeof(ThemeDto))]
// Events // Events
[JsonSerializable(typeof(RebuildEvent))] [JsonSerializable(typeof(RebuildEventDto))]
// Container Helper // Container Helper
[JsonSerializable(typeof(ContainerHelperStatusDto))] [JsonSerializable(typeof(ContainerHelperStatusDto))]
// Misc // Misc
[JsonSerializable(typeof(ProblemDetails))] [JsonSerializable(typeof(ProblemDetails))]
public partial class SerializationContext : JsonSerializerContext public partial class SharedSerializationContext : JsonSerializerContext
{ {
private static JsonSerializerOptions? InternalTunedOptions;
public static JsonSerializerOptions TunedOptions
{
get
{
if (InternalTunedOptions != null)
return InternalTunedOptions;
InternalTunedOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
InternalTunedOptions.TypeInfoResolverChain.Add(Default);
return InternalTunedOptions;
}
}
} }