feat: scaffold CLI project with ClusterClient connection and System.CommandLine

This commit is contained in:
Joseph Doherty
2026-03-17 14:51:43 -04:00
parent 1942544769
commit 229287cfd2
6 changed files with 215 additions and 0 deletions

View File

@@ -18,6 +18,7 @@
<Project Path="src/ScadaLink.InboundAPI/ScadaLink.InboundAPI.csproj" />
<Project Path="src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
<Project Path="src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj" />
<Project Path="src/ScadaLink.CLI/ScadaLink.CLI.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj" />

View File

@@ -0,0 +1,66 @@
using System.Text.Json;
namespace ScadaLink.CLI;
public class CliConfig
{
public List<string> ContactPoints { get; set; } = new();
public string? LdapServer { get; set; }
public int LdapPort { get; set; } = 636;
public bool LdapUseTls { get; set; } = true;
public string DefaultFormat { get; set; } = "json";
public static CliConfig Load()
{
var config = new CliConfig();
// Load from config file
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".scadalink", "config.json");
if (File.Exists(configPath))
{
var json = File.ReadAllText(configPath);
var fileConfig = JsonSerializer.Deserialize<CliConfigFile>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (fileConfig != null)
{
if (fileConfig.ContactPoints?.Count > 0) config.ContactPoints = fileConfig.ContactPoints;
if (fileConfig.Ldap != null)
{
config.LdapServer = fileConfig.Ldap.Server;
config.LdapPort = fileConfig.Ldap.Port;
config.LdapUseTls = fileConfig.Ldap.UseTls;
}
if (!string.IsNullOrEmpty(fileConfig.DefaultFormat)) config.DefaultFormat = fileConfig.DefaultFormat;
}
}
// Override from environment variables
var envContacts = Environment.GetEnvironmentVariable("SCADALINK_CONTACT_POINTS");
if (!string.IsNullOrEmpty(envContacts))
config.ContactPoints = envContacts.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
var envLdap = Environment.GetEnvironmentVariable("SCADALINK_LDAP_SERVER");
if (!string.IsNullOrEmpty(envLdap)) config.LdapServer = envLdap;
var envFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
if (!string.IsNullOrEmpty(envFormat)) config.DefaultFormat = envFormat;
return config;
}
private class CliConfigFile
{
public List<string>? ContactPoints { get; set; }
public LdapConfig? Ldap { get; set; }
public string? DefaultFormat { get; set; }
}
private class LdapConfig
{
public string? Server { get; set; }
public int Port { get; set; } = 636;
public bool UseTls { get; set; } = true;
}
}

View File

@@ -0,0 +1,63 @@
using System.Collections.Immutable;
using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.Configuration;
using ScadaLink.Commons.Messages.Management;
namespace ScadaLink.CLI;
public class ClusterConnection : IAsyncDisposable
{
private ActorSystem? _system;
private IActorRef? _clusterClient;
public async Task ConnectAsync(IReadOnlyList<string> contactPoints, TimeSpan timeout)
{
var seedNodes = string.Join(",", contactPoints.Select(cp => $"\"{cp}\""));
var config = ConfigurationFactory.ParseString($@"
akka {{
actor.provider = remote
remote.dot-netty.tcp {{
hostname = ""127.0.0.1""
port = 0
}}
}}
");
_system = ActorSystem.Create("scadalink-cli", config);
var initialContacts = contactPoints
.Select(cp => $"{cp}/system/receptionist")
.Select(path => ActorPath.Parse(path))
.ToImmutableHashSet();
var clientSettings = ClusterClientSettings.Create(_system)
.WithInitialContacts(initialContacts);
_clusterClient = _system.ActorOf(ClusterClient.Props(clientSettings), "cluster-client");
// Wait for connection by sending a ping
// ClusterClient doesn't have a direct "connected" signal, so we rely on the first Ask succeeding
await Task.CompletedTask;
}
public async Task<object> AskManagementAsync(ManagementEnvelope envelope, TimeSpan timeout)
{
if (_clusterClient == null) throw new InvalidOperationException("Not connected");
var response = await _clusterClient.Ask(
new ClusterClient.Send("/user/management", envelope),
timeout);
return response;
}
public async ValueTask DisposeAsync()
{
if (_system != null)
{
await CoordinatedShutdown.Get(_system).Run(CoordinatedShutdown.ClrExitReason.Instance);
_system = null;
}
}
}

View File

@@ -0,0 +1,41 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ScadaLink.CLI;
public static class OutputFormatter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static void WriteJson(object? data)
{
Console.WriteLine(JsonSerializer.Serialize(data, JsonOptions));
}
public static void WriteError(string message, string code)
{
Console.Error.WriteLine(JsonSerializer.Serialize(new { error = message, code }, JsonOptions));
}
public static void WriteTable(IEnumerable<string[]> rows, string[] headers)
{
var allRows = new List<string[]> { headers };
allRows.AddRange(rows);
var widths = new int[headers.Length];
foreach (var row in allRows)
for (int i = 0; i < Math.Min(row.Length, widths.Length); i++)
widths[i] = Math.Max(widths[i], (row[i] ?? "").Length);
foreach (var row in allRows)
{
for (int i = 0; i < headers.Length; i++)
Console.Write((i < row.Length ? row[i] ?? "" : "").PadRight(widths[i] + 2));
Console.WriteLine();
}
}
}

View File

@@ -0,0 +1,24 @@
using System.CommandLine;
using System.CommandLine.Parsing;
var rootCommand = new RootCommand("ScadaLink CLI — manage the ScadaLink SCADA system");
var contactPointsOption = new Option<string>("--contact-points") { Description = "Comma-separated cluster contact points", Recursive = true };
var usernameOption = new Option<string>("--username") { Description = "LDAP username", Recursive = true };
var passwordOption = new Option<string>("--password") { Description = "LDAP password", Recursive = true };
var formatOption = new Option<string>("--format") { Description = "Output format (json or table)", Recursive = true };
formatOption.DefaultValueFactory = _ => "json";
rootCommand.Add(contactPointsOption);
rootCommand.Add(usernameOption);
rootCommand.Add(passwordOption);
rootCommand.Add(formatOption);
// Placeholder — command groups will be added in Task 6
rootCommand.SetAction(_ =>
{
Console.WriteLine("Use --help to see available commands.");
});
var parseResult = CommandLineParser.Parse(rootCommand, args);
return await parseResult.InvokeAsync();

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyName>scadalink</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka" Version="1.5.62" />
<PackageReference Include="Akka.Remote" Version="1.5.62" />
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
<PackageReference Include="System.CommandLine" Version="2.0.5" />
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
</ItemGroup>
</Project>