diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs
index dc969979..cfaaab6d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs
@@ -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
/// Configuration key holding the deploy API secret.
public const string ConfigKey = "Security:DeployApiKey";
- /// Maps POST /api/deployments — the headless deploy trigger. No-op-safe to call
+ /// Maps POST /api/deployments — the headless deploy trigger — and
+ /// GET /api/deployments/{id}, the status resource its 202 Location points at. No-op-safe to call
/// on any admin-role node; the handler self-disables (503) until Security:DeployApiKey is set.
/// The endpoint route builder.
/// Application configuration (read for the deploy API key).
@@ -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 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;
}
+ /// Reads one deployment and its per-node apply states, or null when the id is unknown.
+ /// The config DB context.
+ /// The deployment id from the POST's response / Location.
+ /// Cancellation token.
+ /// The status body, or null.
+ public static async Task 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);
+ }
+
/// Fixed-time compare of the supplied key against the configured secret. False when
/// either is empty (so an unconfigured key never authorizes).
/// The caller-supplied key (from the X-Api-Key header).
@@ -106,4 +161,26 @@ public static class DeployApiEndpoints
/// The new deployment id when one was created; otherwise null.
/// The config revision hash, when known.
public sealed record DeployResponse(string Outcome, Guid? DeploymentId, string? RevisionHash);
+
+ /// Response body for GET /api/deployments/{id}.
+ /// The deployment id.
+ /// The config revision hash the deployment carries.
+ /// The deployment's DeploymentStatus name.
+ /// Who/what initiated it.
+ /// When it was created.
+ /// When it sealed; null while still dispatching/applying.
+ /// The deployment-level failure reason, when any.
+ /// Per-node apply states, ordered by node id.
+ public sealed record DeploymentStatusResponse(
+ Guid DeploymentId, string RevisionHash, string Status, string CreatedBy, DateTime CreatedAtUtc,
+ DateTime? SealedAtUtc, string? FailureReason, IReadOnlyList Nodes);
+
+ /// One node's apply state within a .
+ /// The cluster node id.
+ /// The node's NodeDeploymentStatus name.
+ /// When the node started applying.
+ /// When the node acked; null while applying.
+ /// The node's failure (or "Applied degraded: …") reason, when any.
+ public sealed record NodeDeploymentStatusResponse(
+ string NodeId, string Status, DateTime StartedAtUtc, DateTime? AppliedAtUtc, string? FailureReason);
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Api/DeploymentStatusEndpointTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Api/DeploymentStatusEndpointTests.cs
new file mode 100644
index 00000000..bf305d96
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Api/DeploymentStatusEndpointTests.cs
@@ -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;
+
+///
+/// R04 S5: the POST's 202 Location points at GET /api/deployments/{id}, which now exists,
+/// carries the same API-key gate as the POST, and reports the deployment plus its per-node states.
+///
+[Trait("Category", "Unit")]
+public sealed class DeploymentStatusEndpointTests
+{
+ private static OtOpcUaConfigDbContext NewDb(string name) =>
+ new(new DbContextOptionsBuilder().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()!.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().ShouldNotBeNull(NameOf(e));
+ e.Metadata.GetOrderedMetadata().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>();
+ accepted.Location.ShouldBe($"/api/deployments/{id}");
+ Guid.TryParse(accepted.Location!["/api/deployments/".Length..], out _).ShouldBeTrue();
+ }
+
+ private static string? NameOf(Endpoint e) => e.Metadata.GetMetadata()?.EndpointName;
+
+ private static IReadOnlyList 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(_ => null!);
+ builder.Services.AddDbContextFactory(o => o.UseInMemoryDatabase("deploy-api-metadata"));
+ var app = builder.Build();
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { [DeployApiEndpoints.ConfigKey] = "k" })
+ .Build();
+ app.MapOtOpcUaDeployApi(config);
+ return ((IEndpointRouteBuilder)app).DataSources
+ .SelectMany(d => d.Endpoints)
+ .OfType()
+ .ToList();
+ }
+}