220 lines
9.2 KiB
C#
220 lines
9.2 KiB
C#
using System.Text.Json;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
|
|
|
/// <summary>
|
|
/// <see cref="IAsyncLifetime"/> fixture that provisions the shared, ephemeral
|
|
/// scaffolding (a template + an area on <c>site-a</c>) the deploy-action E2E test
|
|
/// class consumes via <c>IClassFixture<DeploymentFixture></c>, and mints
|
|
/// per-test instances on demand via <see cref="CreateInstanceAsync"/>.
|
|
///
|
|
/// <para>
|
|
/// <b>Why <c>site-a</c> and not a throwaway site:</b> the deploy / enable / disable
|
|
/// actions relay to the owning site over the Akka <c>ClusterClient</c>. An unknown
|
|
/// site identifier has no registered <c>ClusterClient</c>, so the relay only
|
|
/// resolves on a slow 10-second timeout (and never surfaces a fast failure toast).
|
|
/// To exercise the real action path, the ephemeral instances must therefore live
|
|
/// on a <em>real, running</em> site — <c>site-a</c> — rather than a fixture-created
|
|
/// throwaway site.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Provisioning is gated on <see cref="ClusterAvailability.IsAvailableAsync"/>:
|
|
/// when the dev cluster is down, <see cref="InitializeAsync"/> sets
|
|
/// <see cref="Available"/> to <see langword="false"/> and returns early without
|
|
/// touching the cluster, so the consuming tests can skip (via <c>Skip.IfNot</c>)
|
|
/// and teardown becomes a no-op.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Cleanup is best-effort and swallows every error: <see cref="DisposeAsync"/>
|
|
/// lists the instances on <c>site-a</c>, deletes any whose name/unique name carries
|
|
/// the <c>zztest-inst-</c> fixture prefix, then deletes the fixture's area and
|
|
/// template. The <c>zztest-</c> prefix marks entities as test-owned so teardown
|
|
/// never touches cluster-owned data.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class DeploymentFixture : IAsyncLifetime
|
|
{
|
|
private const string SiteAIdentifier = "site-a";
|
|
|
|
/// <summary>
|
|
/// Fixture-name prefix for the per-test instances minted by
|
|
/// <see cref="CreateInstanceAsync"/>; teardown deletes <c>site-a</c> instances
|
|
/// carrying this prefix.
|
|
/// </summary>
|
|
private const string InstanceNamePrefix = "zztest-inst-";
|
|
|
|
/// <summary>Numeric id of the real, running <c>site-a</c> the fixture provisions onto.</summary>
|
|
public int SiteAId { get; private set; }
|
|
|
|
/// <summary>Id of the ephemeral template instances are created from.</summary>
|
|
public int TemplateId { get; private set; }
|
|
|
|
/// <summary>Id of the ephemeral area on <c>site-a</c> instances are placed under.</summary>
|
|
public int AreaId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Whether the dev cluster was reachable at setup. When <see langword="false"/>,
|
|
/// nothing was provisioned: consuming tests should skip and teardown is a no-op.
|
|
/// </summary>
|
|
public bool Available { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Probes cluster availability and, when reachable, provisions the shared
|
|
/// scaffolding: resolves <c>site-a</c>'s id, creates an ephemeral template with a
|
|
/// single <c>Double</c> <c>Value</c> attribute (so the template validates and is
|
|
/// deployable), and creates an ephemeral area on <c>site-a</c>. When the cluster
|
|
/// is unavailable, sets <see cref="Available"/> to <see langword="false"/> and
|
|
/// returns without touching the cluster.
|
|
/// </summary>
|
|
public async Task InitializeAsync()
|
|
{
|
|
Available = await ClusterAvailability.IsAvailableAsync();
|
|
if (!Available)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
SiteAId = await CliRunner.ResolveSiteIdAsync(SiteAIdentifier);
|
|
TemplateId = await CliRunner.CreateTemplateAsync(CliRunner.UniqueName("deploytmpl"));
|
|
await CliRunner.AddAttributeAsync(TemplateId, "Value", "Double");
|
|
AreaId = await CliRunner.CreateAreaAsync(SiteAId, CliRunner.UniqueName("area"));
|
|
}
|
|
catch
|
|
{
|
|
// Partial-init guard: best-effort cleanup of whatever was created before
|
|
// the failure; DeleteAreaAsync/DeleteTemplateAsync are no-ops for id 0.
|
|
await CliRunner.DeleteAreaAsync(AreaId);
|
|
await CliRunner.DeleteTemplateAsync(TemplateId);
|
|
Available = false;
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a fresh ephemeral instance from the fixture's template, on
|
|
/// <c>site-a</c>, under the fixture's area, and returns both its numeric
|
|
/// <c>id</c> and its server-assigned <c>uniqueName</c>.
|
|
///
|
|
/// <para>
|
|
/// The <c>uniqueName</c> is read straight off the <c>instance create</c> response
|
|
/// because it can differ from the <c>--name</c> passed in (the server may
|
|
/// area-/path-qualify it). The Topology tree renders this <c>uniqueName</c> in
|
|
/// <c>span.tv-label</c>, so deploy tests must locate the instance row by the
|
|
/// returned <c>uniqueName</c>, not by the name they requested.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <returns>The new instance's <c>id</c> and server-assigned <c>uniqueName</c>.</returns>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// The CLI failed, or the response did not carry an integer <c>id</c> and a
|
|
/// string <c>uniqueName</c>.
|
|
/// </exception>
|
|
public async Task<(int Id, string UniqueName)> CreateInstanceAsync()
|
|
{
|
|
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
|
// "inst" must match the InstanceNamePrefix ("zztest-inst-") used by the teardown sweep.
|
|
var name = CliRunner.UniqueName("inst");
|
|
|
|
using var doc = await CliRunner.RunJsonAsync(
|
|
"instance", "create",
|
|
"--name", name,
|
|
"--template-id", TemplateId.ToString(inv),
|
|
"--site-id", SiteAId.ToString(inv),
|
|
"--area-id", AreaId.ToString(inv));
|
|
|
|
var root = doc.RootElement;
|
|
if (root.ValueKind != JsonValueKind.Object
|
|
|| !root.TryGetProperty("id", out var idElement)
|
|
|| !idElement.TryGetInt32(out var id)
|
|
|| !root.TryGetProperty("uniqueName", out var uniqueNameElement)
|
|
|| uniqueNameElement.ValueKind != JsonValueKind.String
|
|
|| uniqueNameElement.GetString() is not { } uniqueName)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"'instance create' response did not contain an integer 'id' and a string "
|
|
+ $"'uniqueName': {root.GetRawText()}");
|
|
}
|
|
|
|
return (id, uniqueName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Best-effort teardown: when the fixture provisioned anything, lists the
|
|
/// instances on <c>site-a</c> and deletes any whose name/unique name starts with
|
|
/// <see cref="InstanceNamePrefix"/>, then deletes the fixture's area and
|
|
/// template. Swallows every error so a teardown hiccup never fails the suite.
|
|
/// </summary>
|
|
public async Task DisposeAsync()
|
|
{
|
|
if (!Available)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
foreach (var instanceId in await ListFixtureInstanceIdsAsync())
|
|
{
|
|
await CliRunner.DeleteInstanceAsync(instanceId);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort teardown — never fail the suite on a cleanup hiccup.
|
|
}
|
|
|
|
try { await CliRunner.DeleteAreaAsync(AreaId); } catch { }
|
|
try { await CliRunner.DeleteTemplateAsync(TemplateId); } catch { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists <c>site-a</c> via <c>instance list</c> and returns the ids of every
|
|
/// instance whose <c>name</c> or <c>uniqueName</c> starts with
|
|
/// <see cref="InstanceNamePrefix"/> — the fixture-owned instances to delete.
|
|
/// </summary>
|
|
private async Task<IReadOnlyList<int>> ListFixtureInstanceIdsAsync()
|
|
{
|
|
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
|
using var doc = await CliRunner.RunJsonAsync(
|
|
"instance", "list",
|
|
"--site-id", SiteAId.ToString(inv));
|
|
|
|
var ids = new List<int>();
|
|
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var instance in doc.RootElement.EnumerateArray())
|
|
{
|
|
if (instance.ValueKind == JsonValueKind.Object
|
|
&& HasFixturePrefix(instance)
|
|
&& instance.TryGetProperty("id", out var id)
|
|
&& id.TryGetInt32(out var instanceId))
|
|
{
|
|
ids.Add(instanceId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return ids;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns whether an instance JSON object's <c>name</c> or <c>uniqueName</c>
|
|
/// carries the fixture's <see cref="InstanceNamePrefix"/>.
|
|
/// </summary>
|
|
private static bool HasFixturePrefix(JsonElement instance) =>
|
|
StartsWithPrefix(instance, "name") || StartsWithPrefix(instance, "uniqueName");
|
|
|
|
/// <summary>
|
|
/// Returns whether the named string property of <paramref name="instance"/>
|
|
/// starts with <see cref="InstanceNamePrefix"/> (ordinal comparison).
|
|
/// </summary>
|
|
private static bool StartsWithPrefix(JsonElement instance, string property) =>
|
|
instance.TryGetProperty(property, out var value)
|
|
&& value.ValueKind == JsonValueKind.String
|
|
&& (value.GetString()?.StartsWith(InstanceNamePrefix, StringComparison.Ordinal) ?? false);
|
|
}
|