Feature/gitea deployment workflow #1
+10
@@ -68,6 +68,16 @@ public class MasterAvailabilityServiceTests : IDisposable
|
|||||||
await _repo.Received(1).SaveChangesAsync();
|
await _repo.Received(1).SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A slave that already belongs to a master refuses any other key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Do not relax this.</b> The master's integrity check registers automatically when a slave
|
||||||
|
/// rejects its key (<c>CmsInstanceService.VerifyIntegrityAsync</c>), which is safe only because
|
||||||
|
/// this refusal holds: registration then succeeds exactly for a slave that has no registration
|
||||||
|
/// yet, and fails for one that belongs to someone else. Weaken it and that automatic
|
||||||
|
/// registration becomes a way for one master to take over another master's slave.
|
||||||
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RegisterAsync_ReturnsFalse_WhenKeyMismatch()
|
public async Task RegisterAsync_ReturnsFalse_WhenKeyMismatch()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -258,7 +258,7 @@ public class CmsInstanceServiceTests
|
|||||||
var instance = ActiveInstance();
|
var instance = ActiveInstance();
|
||||||
_repo.GetActiveAsync().Returns([instance]);
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
_protector.Unprotect("encrypted-key").Returns("plain");
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns((string?)null);
|
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns(RegisteredMasterUrlResult.Unreachable);
|
||||||
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
||||||
|
|
||||||
await CreateSut().VerifyIntegrityAsync();
|
await CreateSut().VerifyIntegrityAsync();
|
||||||
@@ -272,7 +272,7 @@ public class CmsInstanceServiceTests
|
|||||||
var instance = ActiveInstance();
|
var instance = ActiveInstance();
|
||||||
_repo.GetActiveAsync().Returns([instance]);
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
_protector.Unprotect("encrypted-key").Returns("plain");
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns("https://old-master.test");
|
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns(RegisteredMasterUrlResult.Ok("https://old-master.test"));
|
||||||
_slaveClient.RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()).Returns(true);
|
_slaveClient.RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()).Returns(true);
|
||||||
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ public class CmsInstanceServiceTests
|
|||||||
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow.AddHours(-1);
|
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow.AddHours(-1);
|
||||||
_repo.GetActiveAsync().Returns([instance]);
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
_protector.Unprotect("encrypted-key").Returns("plain");
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns("https://master.test");
|
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns(RegisteredMasterUrlResult.Ok("https://master.test"));
|
||||||
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
||||||
|
|
||||||
await CreateSut().VerifyIntegrityAsync();
|
await CreateSut().VerifyIntegrityAsync();
|
||||||
@@ -304,7 +304,7 @@ public class CmsInstanceServiceTests
|
|||||||
instance.DisableMessage = "Onderhoud";
|
instance.DisableMessage = "Onderhoud";
|
||||||
_repo.GetActiveAsync().Returns([instance]);
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
_protector.Unprotect("encrypted-key").Returns("plain");
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns("https://master.test");
|
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns(RegisteredMasterUrlResult.Ok("https://master.test"));
|
||||||
_slaveClient.PushStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
_slaveClient.PushStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
||||||
|
|
||||||
@@ -314,6 +314,85 @@ public class CmsInstanceServiceTests
|
|||||||
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastStatusPushedAt.HasValue));
|
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastStatusPushedAt.HasValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The state this branch exists for: the slave answers but does not recognise us, which
|
||||||
|
/// happens when the original registration call went to the wrong URL. It used to be reported
|
||||||
|
/// as "unreachable" and never repaired, so the instance stayed broken until someone edited the
|
||||||
|
/// database by hand.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyIntegrityAsync_Registers_WhenSlaveDoesNotRecogniseThisMaster()
|
||||||
|
{
|
||||||
|
var instance = ActiveInstance();
|
||||||
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
|
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(RegisteredMasterUrlResult.Unauthorized);
|
||||||
|
_slaveClient.RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()).Returns(true);
|
||||||
|
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
||||||
|
|
||||||
|
await CreateSut().VerifyIntegrityAsync();
|
||||||
|
|
||||||
|
await _slaveClient.Received(1).RegisterMasterAsync("https://slave.test", "plain", "https://master.test");
|
||||||
|
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastIntegrityCheckFailedAt == null && i.LastContactedAt.HasValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A slave that belongs to a different master refuses the registration, and the master must
|
||||||
|
/// accept that rather than keep hammering. The refusal itself is the slave's job — see
|
||||||
|
/// MasterAvailabilityServiceTests — this asserts the master honours it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyIntegrityAsync_StaysFailed_WhenRegistrationIsRefused()
|
||||||
|
{
|
||||||
|
var instance = ActiveInstance();
|
||||||
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
|
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(RegisteredMasterUrlResult.Unauthorized);
|
||||||
|
_slaveClient.RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()).Returns(false);
|
||||||
|
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
||||||
|
|
||||||
|
await CreateSut().VerifyIntegrityAsync();
|
||||||
|
|
||||||
|
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastIntegrityCheckFailedAt.HasValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A URL pointing at a frontend rather than an instance's API. Registering again would 404
|
||||||
|
/// just the same, so it must not be attempted — the URL is wrong and only a human can fix it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyIntegrityAsync_DoesNotRegister_WhenHostDoesNotSpeakTheProtocol()
|
||||||
|
{
|
||||||
|
var instance = ActiveInstance();
|
||||||
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
|
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(RegisteredMasterUrlResult.NotAProtocolEndpoint);
|
||||||
|
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
||||||
|
|
||||||
|
await CreateSut().VerifyIntegrityAsync();
|
||||||
|
|
||||||
|
await _slaveClient.DidNotReceive().RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastIntegrityCheckFailedAt.HasValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyIntegrityAsync_DoesNotRegister_WhenSlaveIsUnreachable()
|
||||||
|
{
|
||||||
|
var instance = ActiveInstance();
|
||||||
|
_repo.GetActiveAsync().Returns([instance]);
|
||||||
|
_protector.Unprotect("encrypted-key").Returns("plain");
|
||||||
|
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(RegisteredMasterUrlResult.Unreachable);
|
||||||
|
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
|
||||||
|
|
||||||
|
await CreateSut().VerifyIntegrityAsync();
|
||||||
|
|
||||||
|
await _slaveClient.DidNotReceive().RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
private static CmsInstance ActiveInstance() => new()
|
private static CmsInstance ActiveInstance() => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using SlpModularCms.Modules.Master.Services;
|
using SlpModularCms.Modules.Master.Services;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -71,7 +71,7 @@ public class SlaveApiClientTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetRegisteredMasterUrlAsync_ReturnsMasterUrl_WhenResponseIsSuccess()
|
public async Task GetRegisteredMasterUrlAsync_ReturnsOkWithMasterUrl_WhenResponseIsSuccess()
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(new { MasterUrl = MasterUrl });
|
var json = JsonSerializer.Serialize(new { MasterUrl = MasterUrl });
|
||||||
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
|
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
|
||||||
@@ -79,18 +79,50 @@ public class SlaveApiClientTests
|
|||||||
|
|
||||||
var result = await sut.GetRegisteredMasterUrlAsync(SlaveUrl, ApiKey);
|
var result = await sut.GetRegisteredMasterUrlAsync(SlaveUrl, ApiKey);
|
||||||
|
|
||||||
result.Should().Be(MasterUrl);
|
result.Outcome.Should().Be(SlaveContactOutcome.Ok);
|
||||||
|
result.MasterUrl.Should().Be(MasterUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The slave answered and refused the key. Distinct from unreachable, because it is the one
|
||||||
|
/// failure the integrity check can repair on its own.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetRegisteredMasterUrlAsync_ReturnsNull_WhenResponseIsFailure()
|
public async Task GetRegisteredMasterUrlAsync_ReturnsUnauthorized_WhenSlaveRejectsTheKey()
|
||||||
|
{
|
||||||
|
var handler = new FakeHttpMessageHandler(HttpStatusCode.Unauthorized);
|
||||||
|
var sut = CreateSut(handler);
|
||||||
|
|
||||||
|
var result = await sut.GetRegisteredMasterUrlAsync(SlaveUrl, ApiKey);
|
||||||
|
|
||||||
|
result.Outcome.Should().Be(SlaveContactOutcome.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Something is listening but does not serve /api/v1/master/*. Almost always a URL pointing at
|
||||||
|
/// a frontend instead of an instance's API — worth its own outcome, because reporting it as a
|
||||||
|
/// generic contact failure hides the actual mistake.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRegisteredMasterUrlAsync_ReturnsNotAProtocolEndpoint_OnNotFound()
|
||||||
{
|
{
|
||||||
var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound);
|
var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound);
|
||||||
var sut = CreateSut(handler);
|
var sut = CreateSut(handler);
|
||||||
|
|
||||||
var result = await sut.GetRegisteredMasterUrlAsync(SlaveUrl, ApiKey);
|
var result = await sut.GetRegisteredMasterUrlAsync(SlaveUrl, ApiKey);
|
||||||
|
|
||||||
result.Should().BeNull();
|
result.Outcome.Should().Be(SlaveContactOutcome.NotAProtocolEndpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRegisteredMasterUrlAsync_ReturnsUnreachable_OnServerError()
|
||||||
|
{
|
||||||
|
var handler = new FakeHttpMessageHandler(HttpStatusCode.InternalServerError);
|
||||||
|
var sut = CreateSut(handler);
|
||||||
|
|
||||||
|
var result = await sut.GetRegisteredMasterUrlAsync(SlaveUrl, ApiKey);
|
||||||
|
|
||||||
|
result.Outcome.Should().Be(SlaveContactOutcome.Unreachable);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -157,20 +157,56 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var plainKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
|
var plainKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
|
||||||
var registeredUrl = await deps.SlaveClient.GetRegisteredMasterUrlAsync(instance.Url, plainKey);
|
var contact = await deps.SlaveClient.GetRegisteredMasterUrlAsync(instance.Url, plainKey);
|
||||||
|
|
||||||
if (registeredUrl is null)
|
if (contact.Outcome is SlaveContactOutcome.Unreachable or SlaveContactOutcome.NotAProtocolEndpoint)
|
||||||
{
|
{
|
||||||
deps.Logger.LogWarning("Slave {SlaveUrl} unreachable during integrity check.", instance.Url);
|
if (contact.Outcome == SlaveContactOutcome.NotAProtocolEndpoint)
|
||||||
|
{
|
||||||
|
// Distinct from "down": something is listening but does not serve
|
||||||
|
// /api/v1/master/*. Almost always a URL pointing at a frontend or an
|
||||||
|
// unrelated site rather than a CMS instance's API — which is invisible if
|
||||||
|
// this is reported as a generic contact failure.
|
||||||
|
deps.Logger.LogWarning(
|
||||||
|
"Slave {SlaveUrl} responded but does not serve the master/slave protocol (404). Is this the instance's API URL?",
|
||||||
|
instance.Url);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
deps.Logger.LogWarning("Slave {SlaveUrl} unreachable during integrity check.", instance.Url);
|
||||||
|
}
|
||||||
|
|
||||||
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow;
|
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow;
|
||||||
deps.Repository.Update(instance);
|
deps.Repository.Update(instance);
|
||||||
await deps.Repository.SaveChangesAsync();
|
await deps.Repository.SaveChangesAsync();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(registeredUrl, masterUrl, StringComparison.OrdinalIgnoreCase))
|
// Two states need the same repair — the slave does not recognise us, or it
|
||||||
|
// recognises us under a stale master URL.
|
||||||
|
//
|
||||||
|
// Registering on a rejected key cannot hijack a slave that belongs to someone
|
||||||
|
// else: the slave accepts a registration only when it has none yet, and refuses
|
||||||
|
// any key that does not match an existing one (MasterAvailabilityService.
|
||||||
|
// RegisterAsync). So this succeeds exactly in the case worth recovering — a slave
|
||||||
|
// that was never registered, typically because the original registration call went
|
||||||
|
// to the wrong URL — and fails harmlessly otherwise. That guarantee lives on the
|
||||||
|
// slave, and MasterAvailabilityServiceTests locks it down.
|
||||||
|
var needsRegistration = contact.Outcome == SlaveContactOutcome.Unauthorized
|
||||||
|
|| !string.Equals(contact.MasterUrl, masterUrl, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (needsRegistration)
|
||||||
{
|
{
|
||||||
deps.Logger.LogWarning("Slave {SlaveUrl} has wrong master URL '{RegisteredUrl}'; re-registering.", instance.Url, registeredUrl);
|
if (contact.Outcome == SlaveContactOutcome.Unauthorized)
|
||||||
|
{
|
||||||
|
deps.Logger.LogWarning(
|
||||||
|
"Slave {SlaveUrl} does not recognise this master; attempting registration.", instance.Url);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
deps.Logger.LogWarning("Slave {SlaveUrl} has wrong master URL '{RegisteredUrl}'; re-registering.", instance.Url, contact.MasterUrl);
|
||||||
|
}
|
||||||
|
|
||||||
var reRegistered = await deps.SlaveClient.RegisterMasterAsync(instance.Url, plainKey, masterUrl);
|
var reRegistered = await deps.SlaveClient.RegisterMasterAsync(instance.Url, plainKey, masterUrl);
|
||||||
if (reRegistered)
|
if (reRegistered)
|
||||||
{
|
{
|
||||||
@@ -179,6 +215,8 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// A slave registered to a different master lands here, and stays here.
|
||||||
|
deps.Logger.LogWarning("Registration with slave {SlaveUrl} was refused.", instance.Url);
|
||||||
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow;
|
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,13 @@ public interface ISlaveApiClient
|
|||||||
{
|
{
|
||||||
Task<bool> RegisterMasterAsync(string slaveUrl, string plainApiKey, string masterUrl);
|
Task<bool> RegisterMasterAsync(string slaveUrl, string plainApiKey, string masterUrl);
|
||||||
Task<bool> PushStatusAsync(string slaveUrl, string plainApiKey, bool isAvailable, string? disableMessage);
|
Task<bool> PushStatusAsync(string slaveUrl, string plainApiKey, bool isAvailable, string? disableMessage);
|
||||||
Task<string?> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey);
|
/// <summary>
|
||||||
|
/// Asks the slave which master it is registered to.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// A result carrying <b>why</b> the call ended as it did, not just the URL. The integrity
|
||||||
|
/// check needs the distinction: an unreachable slave can only be retried, whereas one that
|
||||||
|
/// rejects our key may simply have no registration yet and can be recovered.
|
||||||
|
/// </returns>
|
||||||
|
Task<RegisteredMasterUrlResult> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ public class SlaveApiClient(HttpClient httpClient) : ISlaveApiClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey)
|
public async Task<RegisteredMasterUrlResult> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -49,15 +50,33 @@ public class SlaveApiClient(HttpClient httpClient) : ISlaveApiClient
|
|||||||
request.Headers.Add("X-Master-Api-Key", plainApiKey);
|
request.Headers.Add("X-Master-Api-Key", plainApiKey);
|
||||||
|
|
||||||
var response = await httpClient.SendAsync(request);
|
var response = await httpClient.SendAsync(request);
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
// The slave answered and refused the key. Recoverable when it simply has no
|
||||||
|
// registration yet, so the caller gets to decide rather than seeing "unreachable".
|
||||||
|
return RegisteredMasterUrlResult.Unauthorized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
// Something is listening, but it does not serve /api/v1/master/*. Nearly always a
|
||||||
|
// URL pointing at a frontend or an unrelated site instead of a CMS instance's API.
|
||||||
|
return RegisteredMasterUrlResult.NotAProtocolEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return null;
|
{
|
||||||
|
return RegisteredMasterUrlResult.Unreachable;
|
||||||
|
}
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<RegisteredMasterUrlResponse>(JsonOptions);
|
var result = await response.Content.ReadFromJsonAsync<RegisteredMasterUrlResponse>(JsonOptions);
|
||||||
return result?.MasterUrl;
|
return RegisteredMasterUrlResult.Ok(result?.MasterUrl);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return null;
|
// No HTTP response at all: host down, DNS, TLS or timeout.
|
||||||
|
return RegisteredMasterUrlResult.Unreachable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace SlpModularCms.Modules.Master.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How a call to a slave ended.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// These four cases used to collapse into a single <c>null</c>, which meant the integrity check
|
||||||
|
/// could not tell "the slave is down" from "the slave does not recognise this master" — and only
|
||||||
|
/// the second is recoverable. It also meant a host that does not speak this protocol at all (a
|
||||||
|
/// frontend dev server, say) reported exactly the same as an unreachable one.
|
||||||
|
/// </remarks>
|
||||||
|
public enum SlaveContactOutcome
|
||||||
|
{
|
||||||
|
/// <summary>No HTTP response at all: host down, wrong host, DNS or TLS failure, timeout.</summary>
|
||||||
|
Unreachable,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The host answered, but not with this protocol — a 404 on <c>/api/v1/master/*</c>. Almost
|
||||||
|
/// always a URL pointing at something other than a CMS instance's API.
|
||||||
|
/// </summary>
|
||||||
|
NotAProtocolEndpoint,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The slave answered and rejected our API key. Either it has no registration yet, or it
|
||||||
|
/// belongs to a different master.
|
||||||
|
/// </summary>
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
/// <summary>The slave answered and accepted our key.</summary>
|
||||||
|
Ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of asking a slave which master it is registered to.</summary>
|
||||||
|
/// <param name="Outcome">How the call ended.</param>
|
||||||
|
/// <param name="MasterUrl">The registered master URL; only meaningful when <see cref="SlaveContactOutcome.Ok"/>.</param>
|
||||||
|
public readonly record struct RegisteredMasterUrlResult(SlaveContactOutcome Outcome, string? MasterUrl)
|
||||||
|
{
|
||||||
|
public static RegisteredMasterUrlResult Unreachable { get; } = new(SlaveContactOutcome.Unreachable, null);
|
||||||
|
|
||||||
|
public static RegisteredMasterUrlResult NotAProtocolEndpoint { get; } = new(SlaveContactOutcome.NotAProtocolEndpoint, null);
|
||||||
|
|
||||||
|
public static RegisteredMasterUrlResult Unauthorized { get; } = new(SlaveContactOutcome.Unauthorized, null);
|
||||||
|
|
||||||
|
public static RegisteredMasterUrlResult Ok(string? masterUrl) => new(SlaveContactOutcome.Ok, masterUrl);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user