diff --git a/ScadaLink.slnx b/ScadaLink.slnx index 11edc1d..a5ae9c3 100644 --- a/ScadaLink.slnx +++ b/ScadaLink.slnx @@ -18,6 +18,7 @@ + diff --git a/src/ScadaLink.CLI/CliConfig.cs b/src/ScadaLink.CLI/CliConfig.cs new file mode 100644 index 0000000..22bf13b --- /dev/null +++ b/src/ScadaLink.CLI/CliConfig.cs @@ -0,0 +1,66 @@ +using System.Text.Json; + +namespace ScadaLink.CLI; + +public class CliConfig +{ + public List 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(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? 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; + } +} diff --git a/src/ScadaLink.CLI/ClusterConnection.cs b/src/ScadaLink.CLI/ClusterConnection.cs new file mode 100644 index 0000000..89ab021 --- /dev/null +++ b/src/ScadaLink.CLI/ClusterConnection.cs @@ -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 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 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; + } + } +} diff --git a/src/ScadaLink.CLI/OutputFormatter.cs b/src/ScadaLink.CLI/OutputFormatter.cs new file mode 100644 index 0000000..4634289 --- /dev/null +++ b/src/ScadaLink.CLI/OutputFormatter.cs @@ -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 rows, string[] headers) + { + var allRows = new List { 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(); + } + } +} diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs new file mode 100644 index 0000000..99c9172 --- /dev/null +++ b/src/ScadaLink.CLI/Program.cs @@ -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("--contact-points") { Description = "Comma-separated cluster contact points", Recursive = true }; +var usernameOption = new Option("--username") { Description = "LDAP username", Recursive = true }; +var passwordOption = new Option("--password") { Description = "LDAP password", Recursive = true }; +var formatOption = new Option("--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(); diff --git a/src/ScadaLink.CLI/ScadaLink.CLI.csproj b/src/ScadaLink.CLI/ScadaLink.CLI.csproj new file mode 100644 index 0000000..bf5f8ee --- /dev/null +++ b/src/ScadaLink.CLI/ScadaLink.CLI.csproj @@ -0,0 +1,20 @@ + + + Exe + net10.0 + enable + enable + true + scadalink + + + + + + + + + + + +