feat: add JoeAppEngine OPC UA nodes, fix DCL auto-reconnect and quality push

- Add JoeAppEngine folder to OPC UA nodes.json (BTCS, AlarmCntsBySeverity, Scheduler/ScanTime)
- Fix DataConnectionActor: capture Self in PreStart for use from non-actor threads,
  preventing Self.Tell failure in Disconnected event handler
- Implement InstanceActor.HandleConnectionQualityChanged to mark attributes Bad on disconnect
- Fix LmxFakeProxy TagMapper to serialize arrays as JSON instead of "System.Int32[]"
- Allow DataType and DataSourceReference updates in TemplateService.UpdateAttributeAsync
- Update test_infra_opcua.md with JoeAppEngine documentation
This commit is contained in:
Joseph Doherty
2026-03-19 13:27:54 -04:00
parent ffdda51990
commit 7740a3bcf9
70 changed files with 2684 additions and 541 deletions

View File

@@ -1,27 +1,33 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.Management;
using ScadaLink.Security;
namespace ScadaLink.CLI.Commands;
internal static class CommandHelpers
{
internal static AuthenticatedUser PlaceholderUser { get; } =
new("cli-user", "CLI User", ["Admin", "Design", "Deployment"], Array.Empty<string>());
internal static string NewCorrelationId() => Guid.NewGuid().ToString("N");
internal static async Task<int> ExecuteCommandAsync(
ParseResult result,
Option<string> contactPointsOption,
Option<string> formatOption,
Option<string> usernameOption,
Option<string> passwordOption,
object command)
{
var contactPointsRaw = result.GetValue(contactPointsOption);
var format = result.GetValue(formatOption) ?? "json";
var config = CliConfig.Load();
if (string.IsNullOrWhiteSpace(contactPointsRaw))
{
var config = CliConfig.Load();
if (config.ContactPoints.Count > 0)
contactPointsRaw = string.Join(",", config.ContactPoints);
}
@@ -34,21 +40,97 @@ internal static class CommandHelpers
var contactPoints = contactPointsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// Authenticate via LDAP
var username = result.GetValue(usernameOption);
var password = result.GetValue(passwordOption);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username and --password options.",
"NO_CREDENTIALS");
return 1;
}
// Authenticate against LDAP
var securityOptions = new SecurityOptions
{
LdapServer = config.LdapServer ?? string.Empty,
LdapPort = config.LdapPort,
LdapUseTls = config.LdapUseTls,
AllowInsecureLdap = !config.LdapUseTls,
LdapSearchBase = config.LdapSearchBase,
LdapServiceAccountDn = config.LdapServiceAccountDn,
LdapServiceAccountPassword = config.LdapServiceAccountPassword
};
var ldapAuth = new LdapAuthService(
Options.Create(securityOptions),
NullLogger<LdapAuthService>.Instance);
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
{
OutputFormatter.WriteError(
authResult.ErrorMessage ?? "Authentication failed.",
"AUTH_FAILED");
return 1;
}
await using var connection = new ClusterConnection();
await connection.ConnectAsync(contactPoints, TimeSpan.FromSeconds(10));
var envelope = new ManagementEnvelope(PlaceholderUser, command, NewCorrelationId());
// Resolve roles server-side
var resolveEnvelope = new ManagementEnvelope(
new AuthenticatedUser(authResult.Username!, authResult.DisplayName!, Array.Empty<string>(), Array.Empty<string>()),
new ResolveRolesCommand(authResult.Groups ?? (IReadOnlyList<string>)Array.Empty<string>()),
NewCorrelationId());
var resolveResponse = await connection.AskManagementAsync(resolveEnvelope, TimeSpan.FromSeconds(30));
string[] roles;
string[] permittedSiteIds;
if (resolveResponse is ManagementSuccess resolveSuccess)
{
var rolesDoc = JsonDocument.Parse(resolveSuccess.JsonData);
roles = rolesDoc.RootElement.TryGetProperty("Roles", out var rolesEl)
? rolesEl.EnumerateArray().Select(e => e.GetString()!).ToArray()
: Array.Empty<string>();
permittedSiteIds = rolesDoc.RootElement.TryGetProperty("PermittedSiteIds", out var sitesEl)
? sitesEl.EnumerateArray().Select(e => e.GetString()!).ToArray()
: Array.Empty<string>();
}
else
{
return HandleResponse(resolveResponse, format);
}
var authenticatedUser = new AuthenticatedUser(
authResult.Username!,
authResult.DisplayName!,
roles,
permittedSiteIds);
var envelope = new ManagementEnvelope(authenticatedUser, command, NewCorrelationId());
var response = await connection.AskManagementAsync(envelope, TimeSpan.FromSeconds(30));
return HandleResponse(response);
return HandleResponse(response, format);
}
internal static int HandleResponse(object response)
internal static int HandleResponse(object response, string format)
{
switch (response)
{
case ManagementSuccess success:
Console.WriteLine(success.JsonData);
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
{
WriteAsTable(success.JsonData);
}
else
{
Console.WriteLine(success.JsonData);
}
return 0;
case ManagementError error:
@@ -64,4 +146,51 @@ internal static class CommandHelpers
return 1;
}
}
private static void WriteAsTable(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Array)
{
var items = root.EnumerateArray().ToList();
if (items.Count == 0)
{
Console.WriteLine("(no results)");
return;
}
// Extract headers from first object's property names
var headers = items[0].ValueKind == JsonValueKind.Object
? items[0].EnumerateObject().Select(p => p.Name).ToArray()
: new[] { "Value" };
var rows = items.Select(item =>
{
if (item.ValueKind == JsonValueKind.Object)
{
return headers.Select(h =>
item.TryGetProperty(h, out var val)
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
: "").ToArray();
}
return new[] { item.ToString() };
});
OutputFormatter.WriteTable(rows, headers);
}
else if (root.ValueKind == JsonValueKind.Object)
{
// Single object: render as key-value pairs
var headers = new[] { "Property", "Value" };
var rows = root.EnumerateObject().Select(p =>
new[] { p.Name, p.Value.ValueKind == JsonValueKind.Null ? "" : p.Value.ToString() });
OutputFormatter.WriteTable(rows, headers);
}
else
{
Console.WriteLine(root.ToString());
}
}
}