feat(adminui): GET /api/deployments/{id}, the status resource the 202 Location names (R04 S5)
The deploy API's 202 pointed at a route that did not exist. The GET reads the deployment (projected, no artifact blob) and its per-node apply states, and carries the POST's own gate: API key in X-Api-Key, AllowAnonymous against the cookie fallback, 503 while Security:DeployApiKey is unset. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -3,8 +3,10 @@ using System.Text;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||
@@ -31,7 +33,8 @@ public static class DeployApiEndpoints
|
||||
/// <summary>Configuration key holding the deploy API secret.</summary>
|
||||
public const string ConfigKey = "Security:DeployApiKey";
|
||||
|
||||
/// <summary>Maps <c>POST /api/deployments</c> — the headless deploy trigger. No-op-safe to call
|
||||
/// <summary>Maps <c>POST /api/deployments</c> — the headless deploy trigger — and
|
||||
/// <c>GET /api/deployments/{id}</c>, the status resource its 202 <c>Location</c> points at. No-op-safe to call
|
||||
/// on any admin-role node; the handler self-disables (503) until <c>Security:DeployApiKey</c> is set.</summary>
|
||||
/// <param name="app">The endpoint route builder.</param>
|
||||
/// <param name="config">Application configuration (read for the deploy API key).</param>
|
||||
@@ -62,9 +65,61 @@ public static class DeployApiEndpoints
|
||||
.DisableAntiforgery() // machine endpoint, not a browser form post
|
||||
.WithName("StartDeployment");
|
||||
|
||||
// The status resource the POST's 202 Location points at (R04 S5). Same gate as the POST: the
|
||||
// client that follows the Location is the automation holding the deploy key, not a browser
|
||||
// session, so it is API-key gated (AllowAnonymous + the key check) rather than cookie-policy
|
||||
// gated, and disabled (503) while the key is unconfigured.
|
||||
app.MapGet("/api/deployments/{deploymentId:guid}", async (
|
||||
HttpContext http,
|
||||
Guid deploymentId,
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(configuredKey))
|
||||
return Results.Problem(
|
||||
"Deploy API is disabled. Set Security:DeployApiKey to enable it.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
if (!IsAuthorized(http.Request.Headers[ApiKeyHeader].ToString(), configuredKey))
|
||||
return Results.Unauthorized();
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var status = await GetDeploymentStatusAsync(db, deploymentId, ct);
|
||||
return status is null ? Results.NotFound() : Results.Ok(status);
|
||||
})
|
||||
.AllowAnonymous() // gated by the API key, not the cookie auth fallback
|
||||
.WithName("GetDeployment");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Reads one deployment and its per-node apply states, or null when the id is unknown.</summary>
|
||||
/// <param name="db">The config DB context.</param>
|
||||
/// <param name="deploymentId">The deployment id from the POST's response / Location.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The status body, or null.</returns>
|
||||
public static async Task<DeploymentStatusResponse?> GetDeploymentStatusAsync(
|
||||
OtOpcUaConfigDbContext db, Guid deploymentId, CancellationToken ct = default)
|
||||
{
|
||||
// Projected: the deployment row also carries the (large) artifact blob, which a status poll never needs.
|
||||
var deployment = await db.Deployments.AsNoTracking()
|
||||
.Where(d => d.DeploymentId == deploymentId)
|
||||
.Select(d => new { d.DeploymentId, d.RevisionHash, d.Status, d.CreatedBy, d.CreatedAtUtc, d.SealedAtUtc, d.FailureReason })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (deployment is null) return null;
|
||||
|
||||
var nodes = await db.NodeDeploymentStates.AsNoTracking()
|
||||
.Where(n => n.DeploymentId == deploymentId)
|
||||
.OrderBy(n => n.NodeId)
|
||||
.Select(n => new NodeDeploymentStatusResponse(
|
||||
n.NodeId, n.Status.ToString(), n.StartedAtUtc, n.AppliedAtUtc, n.FailureReason))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new DeploymentStatusResponse(
|
||||
deployment.DeploymentId, deployment.RevisionHash, deployment.Status.ToString(), deployment.CreatedBy,
|
||||
deployment.CreatedAtUtc, deployment.SealedAtUtc, deployment.FailureReason, nodes);
|
||||
}
|
||||
|
||||
/// <summary>Fixed-time compare of the supplied key against the configured secret. False when
|
||||
/// either is empty (so an unconfigured key never authorizes).</summary>
|
||||
/// <param name="provided">The caller-supplied key (from the <c>X-Api-Key</c> header).</param>
|
||||
@@ -106,4 +161,26 @@ public static class DeployApiEndpoints
|
||||
/// <param name="DeploymentId">The new deployment id when one was created; otherwise null.</param>
|
||||
/// <param name="RevisionHash">The config revision hash, when known.</param>
|
||||
public sealed record DeployResponse(string Outcome, Guid? DeploymentId, string? RevisionHash);
|
||||
|
||||
/// <summary>Response body for <c>GET /api/deployments/{id}</c>.</summary>
|
||||
/// <param name="DeploymentId">The deployment id.</param>
|
||||
/// <param name="RevisionHash">The config revision hash the deployment carries.</param>
|
||||
/// <param name="Status">The deployment's <c>DeploymentStatus</c> name.</param>
|
||||
/// <param name="CreatedBy">Who/what initiated it.</param>
|
||||
/// <param name="CreatedAtUtc">When it was created.</param>
|
||||
/// <param name="SealedAtUtc">When it sealed; null while still dispatching/applying.</param>
|
||||
/// <param name="FailureReason">The deployment-level failure reason, when any.</param>
|
||||
/// <param name="Nodes">Per-node apply states, ordered by node id.</param>
|
||||
public sealed record DeploymentStatusResponse(
|
||||
Guid DeploymentId, string RevisionHash, string Status, string CreatedBy, DateTime CreatedAtUtc,
|
||||
DateTime? SealedAtUtc, string? FailureReason, IReadOnlyList<NodeDeploymentStatusResponse> Nodes);
|
||||
|
||||
/// <summary>One node's apply state within a <see cref="DeploymentStatusResponse"/>.</summary>
|
||||
/// <param name="NodeId">The cluster node id.</param>
|
||||
/// <param name="Status">The node's <c>NodeDeploymentStatus</c> name.</param>
|
||||
/// <param name="StartedAtUtc">When the node started applying.</param>
|
||||
/// <param name="AppliedAtUtc">When the node acked; null while applying.</param>
|
||||
/// <param name="FailureReason">The node's failure (or "Applied degraded: …") reason, when any.</param>
|
||||
public sealed record NodeDeploymentStatusResponse(
|
||||
string NodeId, string Status, DateTime StartedAtUtc, DateTime? AppliedAtUtc, string? FailureReason);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Api;
|
||||
|
||||
/// <summary>
|
||||
/// R04 S5: the POST's 202 <c>Location</c> points at <c>GET /api/deployments/{id}</c>, which now exists,
|
||||
/// carries the same API-key gate as the POST, and reports the deployment plus its per-node states.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DeploymentStatusEndpointTests
|
||||
{
|
||||
private static OtOpcUaConfigDbContext NewDb(string name) =>
|
||||
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeploymentStatusAsync_returns_the_deployment_and_its_node_states()
|
||||
{
|
||||
var name = Guid.NewGuid().ToString("N");
|
||||
var id = Guid.NewGuid();
|
||||
await using (var seed = NewDb(name))
|
||||
{
|
||||
seed.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id, RevisionHash = "rev1", CreatedBy = "ci", Status = DeploymentStatus.Dispatching,
|
||||
ArtifactBlob = new byte[1024],
|
||||
});
|
||||
seed.Deployments.Add(new Deployment { DeploymentId = Guid.NewGuid(), RevisionHash = "other", CreatedBy = "x" });
|
||||
seed.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = "node-b", DeploymentId = id, Status = NodeDeploymentStatus.Applying });
|
||||
seed.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = "node-a", DeploymentId = id, Status = NodeDeploymentStatus.Applied, AppliedAtUtc = DateTime.UtcNow });
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var db = NewDb(name);
|
||||
var status = await DeployApiEndpoints.GetDeploymentStatusAsync(db, id);
|
||||
|
||||
status.ShouldNotBeNull();
|
||||
status!.DeploymentId.ShouldBe(id);
|
||||
status.RevisionHash.ShouldBe("rev1");
|
||||
status.Status.ShouldBe(nameof(DeploymentStatus.Dispatching));
|
||||
status.Nodes.Select(n => (n.NodeId, n.Status)).ShouldBe(
|
||||
[("node-a", nameof(NodeDeploymentStatus.Applied)), ("node-b", nameof(NodeDeploymentStatus.Applying))]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeploymentStatusAsync_returns_null_for_an_unknown_id()
|
||||
{
|
||||
await using var db = NewDb(Guid.NewGuid().ToString("N"));
|
||||
(await DeployApiEndpoints.GetDeploymentStatusAsync(db, Guid.NewGuid())).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_route_exists_and_is_gated_like_the_post()
|
||||
{
|
||||
var endpoints = MapDeployApi();
|
||||
|
||||
var get = endpoints.Single(e => NameOf(e) == "GetDeployment");
|
||||
get.RoutePattern.RawText.ShouldBe("/api/deployments/{deploymentId:guid}");
|
||||
get.Metadata.GetMetadata<IHttpMethodMetadata>()!.HttpMethods.ShouldBe(["GET"]);
|
||||
|
||||
var post = endpoints.Single(e => NameOf(e) == "StartDeployment");
|
||||
|
||||
// Both are API-key gated in the handler: AllowAnonymous so the cookie fallback policy does not 401
|
||||
// the automation client, and no cookie policy of their own.
|
||||
foreach (var e in new[] { get, post })
|
||||
{
|
||||
e.Metadata.GetMetadata<IAllowAnonymous>().ShouldNotBeNull(NameOf(e));
|
||||
e.Metadata.GetOrderedMetadata<IAuthorizeData>().Where(a => !string.IsNullOrEmpty(a.Policy)).ShouldBeEmpty(NameOf(e));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepted_location_matches_the_get_route()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var result = DeployApiEndpoints.ToResult(new StartDeploymentResult(
|
||||
StartDeploymentOutcome.Accepted, new DeploymentId(id), new RevisionHash("rev"), null, CorrelationId.NewId()));
|
||||
|
||||
var accepted = result.ShouldBeOfType<Microsoft.AspNetCore.Http.HttpResults.Accepted<DeployApiEndpoints.DeployResponse>>();
|
||||
accepted.Location.ShouldBe($"/api/deployments/{id}");
|
||||
Guid.TryParse(accepted.Location!["/api/deployments/".Length..], out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static string? NameOf(Endpoint e) => e.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName;
|
||||
|
||||
private static IReadOnlyList<RouteEndpoint> MapDeployApi()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
// Registered only so the handlers' service parameters are inferred as services; the metadata
|
||||
// scan below never invokes a handler, so nothing is resolved.
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Commons.Interfaces.IAdminOperationsClient>(_ => null!);
|
||||
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("deploy-api-metadata"));
|
||||
var app = builder.Build();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { [DeployApiEndpoints.ConfigKey] = "k" })
|
||||
.Build();
|
||||
app.MapOtOpcUaDeployApi(config);
|
||||
return ((IEndpointRouteBuilder)app).DataSources
|
||||
.SelectMany(d => d.Endpoints)
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user