feat: scaffold CLI project with ClusterClient connection and System.CommandLine
This commit is contained in:
@@ -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" />
|
||||
|
||||
66
src/ScadaLink.CLI/CliConfig.cs
Normal file
66
src/ScadaLink.CLI/CliConfig.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
63
src/ScadaLink.CLI/ClusterConnection.cs
Normal file
63
src/ScadaLink.CLI/ClusterConnection.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/ScadaLink.CLI/OutputFormatter.cs
Normal file
41
src/ScadaLink.CLI/OutputFormatter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/ScadaLink.CLI/Program.cs
Normal file
24
src/ScadaLink.CLI/Program.cs
Normal 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();
|
||||
20
src/ScadaLink.CLI/ScadaLink.CLI.csproj
Normal file
20
src/ScadaLink.CLI/ScadaLink.CLI.csproj
Normal 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>
|
||||
Reference in New Issue
Block a user