Merge branch 'feat/ContainerHelper' into v2.1
Some checks failed
Dev Publish: Nuget / Publish Dev Packages (push) Failing after 31s

# Conflicts:
#	Moonlight.Api/Services/ApplicationService.cs
#	Moonlight.Api/Startup/Startup.Base.cs
#	Moonlight.Shared/Http/SerializationContext.cs
This commit is contained in:
2026-02-09 08:18:56 +01:00
72 changed files with 1416 additions and 606 deletions

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class ContainerHelperOptions
{
public bool IsEnabled { get; set; }
public string Url { get; set; } = "http://helper:8080";
}

View File

@@ -6,9 +6,9 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
namespace Moonlight.Api.Http.Controllers.Admin;

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared.Http.Events;
using Moonlight.Shared.Http.Requests.Admin.ContainerHelper;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/ch")]
public class ContainerHelperController : Controller
{
private readonly ContainerHelperService ContainerHelperService;
private readonly IOptions<ContainerHelperOptions> Options;
public ContainerHelperController(ContainerHelperService containerHelperService, IOptions<ContainerHelperOptions> options)
{
ContainerHelperService = containerHelperService;
Options = options;
}
[HttpGet("status")]
public async Task<ActionResult<ContainerHelperStatusDto>> GetStatusAsync()
{
if (!Options.Value.IsEnabled)
return new ContainerHelperStatusDto(false, false);
var status = await ContainerHelperService.CheckConnectionAsync();
return new ContainerHelperStatusDto(true, status);
}
[HttpPost("rebuild")]
public Task<IResult> RebuildAsync([FromBody] RequestRebuildDto request)
{
var result = ContainerHelperService.RebuildAsync(request.NoBuildCache);
var mappedResult = result.Select(ContainerHelperMapper.ToDto);
return Task.FromResult<IResult>(
TypedResults.ServerSentEvents(mappedResult)
);
}
[HttpPost("version")]
public async Task<ActionResult> SetVersionAsync([FromBody] SetVersionDto request)
{
await ContainerHelperService.SetVersionAsync(request.Version);
return NoContent();
}
}

View File

@@ -6,7 +6,7 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Http.Controllers.Admin;

View File

@@ -6,7 +6,7 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin;

View File

@@ -7,9 +7,9 @@ using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Themes;
using Moonlight.Shared.Http.Responses.Admin.Themes;
namespace Moonlight.Api.Http.Controllers.Admin;

View File

@@ -6,9 +6,9 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Http.Controllers.Admin;

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Shared.Http.Responses.Auth;
using Moonlight.Shared.Http.Responses.Admin.Auth;
namespace Moonlight.Api.Http.Controllers;

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared.Http.Responses.Frontend;
using Moonlight.Shared.Http.Responses.Admin.Frontend;
namespace Moonlight.Api.Http.Controllers;

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Moonlight.Api.Http.Controllers;
[ApiController]
[Route("api/ping")]
public class PingController : Controller
{
[HttpGet]
[AllowAnonymous]
public IActionResult Get() => Ok("Pong");
}

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 RequestRebuildDto(bool NoBuildCache);

View File

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

View File

@@ -0,0 +1,29 @@
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))]
[JsonSerializable(typeof(RequestRebuildDto))]
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

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;

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

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models;
using Moonlight.Shared.Http.Responses.Frontend;
using Moonlight.Shared.Http.Responses.Admin.Frontend;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Responses.Themes;
using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;

View File

@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Mappers;

View File

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

View File

@@ -0,0 +1,117 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Moonlight.Api.Http.Services.ContainerHelper;
using Moonlight.Api.Http.Services.ContainerHelper.Requests;
using Moonlight.Api.Http.Services.ContainerHelper.Events;
namespace Moonlight.Api.Services;
public class ContainerHelperService
{
private readonly IHttpClientFactory HttpClientFactory;
public ContainerHelperService(IHttpClientFactory httpClientFactory)
{
HttpClientFactory = httpClientFactory;
}
public async Task<bool> CheckConnectionAsync()
{
var client = HttpClientFactory.CreateClient("ContainerHelper");
try
{
var response = await client.GetAsync("api/ping");
response.EnsureSuccessStatusCode();
return true;
}
catch (Exception)
{
return false;
}
}
public async IAsyncEnumerable<RebuildEventDto> RebuildAsync(bool noBuildCache)
{
var client = HttpClientFactory.CreateClient("ContainerHelper");
var request = new HttpRequestMessage(HttpMethod.Post, "api/rebuild");
request.Content = JsonContent.Create(
new RequestRebuildDto(noBuildCache),
null,
SerializationContext.TunedOptions
);
var response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead
);
if (!response.IsSuccessStatusCode)
{
var responseText = await response.Content.ReadAsStringAsync();
yield return new RebuildEventDto()
{
Type = RebuildEventType.Failed,
Data = responseText
};
yield break;
}
await using var responseStream = await response.Content.ReadAsStreamAsync();
using var streamReader = new StreamReader(responseStream);
do
{
var line = await streamReader.ReadLineAsync();
if (line == null)
break;
if (string.IsNullOrWhiteSpace(line))
continue;
var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, SerializationContext.TunedOptions);
yield return deserializedData;
// Exit if service will go down for a clean exit
if (deserializedData is { Type: RebuildEventType.Step, Data: "ServiceDown" })
yield break;
} while (true);
yield return new RebuildEventDto()
{
Type = RebuildEventType.Succeeded,
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

@@ -9,7 +9,7 @@ using Moonlight.Api.Helpers;
using Moonlight.Api.Implementations;
using Moonlight.Api.Interfaces;
using Moonlight.Api.Services;
using SessionOptions = Moonlight.Api.Configuration.SessionOptions;
using SessionOptions = Microsoft.AspNetCore.Builder.SessionOptions;
namespace Moonlight.Api.Startup;
@@ -38,11 +38,20 @@ public partial class Startup
builder.Services.AddOptions<FrontendOptions>().BindConfiguration("Moonlight:Frontend");
builder.Services.AddScoped<FrontendService>();
builder.Services.AddHttpClient();
builder.Services.AddOptions<VersionOptions>().BindConfiguration("Moonlight:Version");
builder.Services.AddSingleton<VersionService>();
builder.Services.AddOptions<ContainerHelperOptions>().BindConfiguration("Moonlight:ContainerHelper");
builder.Services.AddSingleton<ContainerHelperService>();
builder.Services.AddHttpClient("ContainerHelper", (provider, client) =>
{
var options = provider.GetRequiredService<IOptions<ContainerHelperOptions>>();
client.BaseAddress = new Uri(options.Value.IsEnabled ? options.Value.Url : "http://you-should-fail.invalid");
});
}
private static void UseBase(WebApplication application)