From eea50014de292fe42259e4e857f50cd306237a36 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 17 Mar 2026 18:17:47 -0400 Subject: [PATCH] fix: resolve CLI serialization failures and add README Two Akka.NET deserialization bugs prevented CLI commands from reaching ManagementActor: IReadOnlyList in AuthenticatedUser serialized as a compiler-generated internal type unknown to the server, and ManagementSuccess.Data carried server-side assembly types the CLI couldn't resolve on receipt. Fixed by using string[] for roles and pre-serializing response data to JSON in ManagementActor before sending. Adds full CLI reference documentation covering all 10 command groups. --- src/ScadaLink.CLI/Commands/CommandHelpers.cs | 16 +- src/ScadaLink.CLI/README.md | 558 ++++++++++++++++++ .../Messages/Management/ManagementEnvelope.cs | 4 +- .../ManagementActor.cs | 4 +- 4 files changed, 566 insertions(+), 16 deletions(-) create mode 100644 src/ScadaLink.CLI/README.md diff --git a/src/ScadaLink.CLI/Commands/CommandHelpers.cs b/src/ScadaLink.CLI/Commands/CommandHelpers.cs index 070b7c8..add7c2a 100644 --- a/src/ScadaLink.CLI/Commands/CommandHelpers.cs +++ b/src/ScadaLink.CLI/Commands/CommandHelpers.cs @@ -18,7 +18,6 @@ internal static class CommandHelpers object command) { var contactPointsRaw = result.GetValue(contactPointsOption); - var format = result.GetValue(formatOption) ?? "json"; if (string.IsNullOrWhiteSpace(contactPointsRaw)) { @@ -41,18 +40,15 @@ internal static class CommandHelpers var envelope = new ManagementEnvelope(PlaceholderUser, command, NewCorrelationId()); var response = await connection.AskManagementAsync(envelope, TimeSpan.FromSeconds(30)); - return HandleResponse(response, format); + return HandleResponse(response); } - internal static int HandleResponse(object response, string format) + internal static int HandleResponse(object response) { switch (response) { case ManagementSuccess success: - if (format == "table") - WriteAsTable(success.Data); - else - OutputFormatter.WriteJson(success.Data); + Console.WriteLine(success.JsonData); return 0; case ManagementError error: @@ -68,10 +64,4 @@ internal static class CommandHelpers return 1; } } - - private static void WriteAsTable(object? data) - { - // For table format, delegate to JSON if data shape is unknown - OutputFormatter.WriteJson(data); - } } diff --git a/src/ScadaLink.CLI/README.md b/src/ScadaLink.CLI/README.md new file mode 100644 index 0000000..295d129 --- /dev/null +++ b/src/ScadaLink.CLI/README.md @@ -0,0 +1,558 @@ +# ScadaLink CLI + +Command-line tool for managing the ScadaLink SCADA system. Connects to a Central node via Akka.NET ClusterClient and routes commands through the ManagementActor. + +## Installation + +```sh +dotnet build src/ScadaLink.CLI +``` + +The output binary is `scadalink` (or `scadalink.exe` on Windows). + +## Connection + +Every command requires a connection to a running Central node. Contact points can be supplied three ways, evaluated in this priority order: + +1. `--contact-points` flag on the command line +2. `SCADALINK_CONTACT_POINTS` environment variable +3. `contactPoints` array in `~/.scadalink/config.json` + +```sh +scadalink --contact-points akka.tcp://scadalink@central-host:8081 +``` + +For a two-node HA cluster, supply both nodes comma-separated: + +```sh +scadalink --contact-points akka.tcp://scadalink@node1:8081,akka.tcp://scadalink@node2:8082 +``` + +## Global Options + +These options are accepted by the root command and inherited by all subcommands. + +| Option | Description | +|--------|-------------| +| `--contact-points ` | Comma-separated Akka cluster contact point URIs | +| `--username ` | LDAP username (reserved for future auth integration) | +| `--password ` | LDAP password (reserved for future auth integration) | +| `--format ` | Output format (default: `json`) | + +## Configuration File + +`~/.scadalink/config.json` is loaded at startup. All fields are optional. + +```json +{ + "contactPoints": ["akka.tcp://scadalink@central-host:8081"], + "ldap": { + "server": "ldap.company.com", + "port": 636, + "useTls": true + }, + "defaultFormat": "json" +} +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `SCADALINK_CONTACT_POINTS` | Comma-separated contact point URIs (overrides config file) | +| `SCADALINK_LDAP_SERVER` | LDAP server hostname (overrides config file) | +| `SCADALINK_FORMAT` | Default output format (overrides config file) | + +## Output + +All commands write JSON to stdout on success. Errors are written as JSON to stderr: + +```json +{ "error": "Human-readable message", "code": "ERROR_CODE" } +``` + +Exit codes: + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Command error | +| `2` | Authorization failure | + +--- + +## Command Reference + +### `template` — Manage templates + +#### `template list` + +List all templates with their full attribute, alarm, script, and composition definitions. + +```sh +scadalink --contact-points template list +``` + +#### `template get` + +Get a single template by ID. + +```sh +scadalink --contact-points template get --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Template ID | + +#### `template create` + +Create a new template, optionally inheriting from a parent. + +```sh +scadalink --contact-points template create --name [--description ] [--parent-id ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--name` | yes | Template name | +| `--description` | no | Template description | +| `--parent-id` | no | Parent template ID for inheritance | + +#### `template delete` + +Delete a template by ID. + +```sh +scadalink --contact-points template delete --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Template ID | + +--- + +### `instance` — Manage instances + +#### `instance list` + +List instances, with optional filters. + +```sh +scadalink --contact-points instance list [--site-id ] [--template-id ] [--search ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--site-id` | no | Filter by site ID | +| `--template-id` | no | Filter by template ID | +| `--search` | no | Search term matched against instance name | + +#### `instance create` + +Create a new instance of a template at a site. + +```sh +scadalink --contact-points instance create --name --template-id --site-id [--area-id ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--name` | yes | Unique instance name | +| `--template-id` | yes | Template to instantiate | +| `--site-id` | yes | Site where the instance will run | +| `--area-id` | no | Area within the site | + +#### `instance deploy` + +Deploy an instance to its site. Acquires the per-instance operation lock. + +```sh +scadalink --contact-points instance deploy --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Instance ID | + +#### `instance enable` + +Enable a previously disabled instance. + +```sh +scadalink --contact-points instance enable --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Instance ID | + +#### `instance disable` + +Disable a running instance without deleting it. + +```sh +scadalink --contact-points instance disable --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Instance ID | + +#### `instance delete` + +Delete an instance. The instance must be disabled first. + +```sh +scadalink --contact-points instance delete --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Instance ID | + +--- + +### `site` — Manage sites + +#### `site list` + +List all registered sites. + +```sh +scadalink --contact-points site list +``` + +#### `site create` + +Register a new site. + +```sh +scadalink --contact-points site create --name --identifier [--description ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--name` | yes | Human-readable site name | +| `--identifier` | yes | Unique machine identifier used for cluster routing (e.g. `site-a`) | +| `--description` | no | Site description | + +#### `site delete` + +Delete a site. Fails if any instances are assigned to it. + +```sh +scadalink --contact-points site delete --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Site ID | + +#### `site deploy-artifacts` + +Push compiled artifacts to one or all sites. + +```sh +scadalink --contact-points site deploy-artifacts [--site-id ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--site-id` | no | Target site ID; omit to deploy to all sites | + +--- + +### `deploy` — Deployment operations + +#### `deploy instance` + +Deploy a single instance (same as `instance deploy`). + +```sh +scadalink --contact-points deploy instance --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Instance ID | + +#### `deploy artifacts` + +Deploy compiled artifacts to one or all sites (same as `site deploy-artifacts`). + +```sh +scadalink --contact-points deploy artifacts [--site-id ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--site-id` | no | Target site ID; omit for all sites | + +#### `deploy status` + +Query deployment records, with optional filters and pagination. + +```sh +scadalink --contact-points deploy status [--instance-id ] [--status ] [--page ] [--page-size ] +``` + +| Option | Required | Default | Description | +|--------|----------|---------|-------------| +| `--instance-id` | no | — | Filter by instance ID | +| `--status` | no | — | Filter by deployment status string | +| `--page` | no | `1` | Page number | +| `--page-size` | no | `50` | Results per page | + +--- + +### `data-connection` — Manage data connections + +#### `data-connection list` + +List all configured data connections. + +```sh +scadalink --contact-points data-connection list +``` + +#### `data-connection create` + +Create a new data connection definition. + +```sh +scadalink --contact-points data-connection create --name --protocol [--configuration ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--name` | yes | Connection name | +| `--protocol` | yes | Protocol identifier (e.g. `OpcUa`) | +| `--configuration` | no | Protocol-specific configuration as a JSON string | + +#### `data-connection delete` + +Delete a data connection. + +```sh +scadalink --contact-points data-connection delete --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Data connection ID | + +#### `data-connection assign` + +Assign a data connection to a site. + +```sh +scadalink --contact-points data-connection assign --connection-id --site-id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--connection-id` | yes | Data connection ID | +| `--site-id` | yes | Site ID | + +--- + +### `external-system` — Manage external HTTP systems + +#### `external-system list` + +List all external system definitions. + +```sh +scadalink --contact-points external-system list +``` + +#### `external-system create` + +Register an external HTTP system that scripts can call. + +```sh +scadalink --contact-points external-system create --name --endpoint-url --auth-type [--auth-config ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--name` | yes | Display name | +| `--endpoint-url` | yes | Base URL of the external system | +| `--auth-type` | yes | Authentication type: `ApiKey` or `BasicAuth` | +| `--auth-config` | no | Auth credentials as a JSON string | + +#### `external-system delete` + +Delete an external system definition. + +```sh +scadalink --contact-points external-system delete --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | External system ID | + +--- + +### `notification` — Manage notification lists + +#### `notification list` + +List all notification lists. + +```sh +scadalink --contact-points notification list +``` + +#### `notification create` + +Create a notification list with one or more recipients. + +```sh +scadalink --contact-points notification create --name --emails +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--name` | yes | Notification list name | +| `--emails` | yes | Comma-separated list of recipient email addresses | + +#### `notification delete` + +Delete a notification list. + +```sh +scadalink --contact-points notification delete --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Notification list ID | + +--- + +### `security` — Security settings + +#### `security api-key list` + +List all inbound API keys. + +```sh +scadalink --contact-points security api-key list +``` + +#### `security api-key create` + +Create a new inbound API key. The generated key value is returned in the response and not stored in plaintext — save it immediately. + +```sh +scadalink --contact-points security api-key create --name +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--name` | yes | Descriptive label for the key | + +#### `security api-key delete` + +Revoke and delete an API key. + +```sh +scadalink --contact-points security api-key delete --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | API key ID | + +#### `security role-mapping list` + +List all LDAP group → role mappings. + +```sh +scadalink --contact-points security role-mapping list +``` + +#### `security role-mapping create` + +Map an LDAP group to a ScadaLink role. + +```sh +scadalink --contact-points security role-mapping create --ldap-group --role +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--ldap-group` | yes | LDAP group distinguished name or CN | +| `--role` | yes | ScadaLink role: `Admin`, `Design`, or `Deployment` | + +#### `security role-mapping delete` + +Remove an LDAP role mapping. + +```sh +scadalink --contact-points security role-mapping delete --id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Mapping ID | + +--- + +### `health` — Health monitoring + +#### `health summary` + +Return the current health state for all known sites as a JSON object keyed by site identifier. + +```sh +scadalink --contact-points health summary +``` + +#### `health site` + +Return the health state for a single site. + +```sh +scadalink --contact-points health site --identifier +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--identifier` | yes | Site identifier (e.g. `site-a`) | + +--- + +### `audit-log` — Audit log queries + +#### `audit-log query` + +Query the central audit log with optional filters and pagination. + +```sh +scadalink --contact-points audit-log query [options] +``` + +| Option | Required | Default | Description | +|--------|----------|---------|-------------| +| `--user` | no | — | Filter by username | +| `--entity-type` | no | — | Filter by entity type (e.g. `Template`, `Instance`) | +| `--action` | no | — | Filter by action (e.g. `Create`, `Delete`) | +| `--from` | no | — | Start timestamp in ISO 8601 format | +| `--to` | no | — | End timestamp in ISO 8601 format | +| `--page` | no | `1` | Page number | +| `--page-size` | no | `50` | Results per page | + +--- + +## Architecture Notes + +The CLI connects to the Central cluster using Akka.NET's `ClusterClient`. It does not join the cluster — it contacts the `ClusterClientReceptionist` on one of the configured Central nodes and sends commands to the `ManagementActor` at path `/user/management`. + +The connection is established per-command invocation and torn down cleanly via `CoordinatedShutdown` when the command completes. + +Role enforcement is applied by the ManagementActor on the server side. The current CLI placeholder user carries `Admin`, `Design`, and `Deployment` roles; production use will integrate LDAP authentication via `--username` / `--password`. diff --git a/src/ScadaLink.Commons/Messages/Management/ManagementEnvelope.cs b/src/ScadaLink.Commons/Messages/Management/ManagementEnvelope.cs index db6eaa4..09d1b5d 100644 --- a/src/ScadaLink.Commons/Messages/Management/ManagementEnvelope.cs +++ b/src/ScadaLink.Commons/Messages/Management/ManagementEnvelope.cs @@ -2,10 +2,10 @@ namespace ScadaLink.Commons.Messages.Management; public record AuthenticatedUser( string Username, string DisplayName, - IReadOnlyList Roles, IReadOnlyList PermittedSiteIds); + string[] Roles, string[] PermittedSiteIds); public record ManagementEnvelope(AuthenticatedUser User, object Command, string CorrelationId); -public record ManagementSuccess(string CorrelationId, object? Data); +public record ManagementSuccess(string CorrelationId, string JsonData); public record ManagementError(string CorrelationId, string Error, string ErrorCode); public record ManagementUnauthorized(string CorrelationId, string Message); diff --git a/src/ScadaLink.ManagementService/ManagementActor.cs b/src/ScadaLink.ManagementService/ManagementActor.cs index e1c1702..cbd9d7d 100644 --- a/src/ScadaLink.ManagementService/ManagementActor.cs +++ b/src/ScadaLink.ManagementService/ManagementActor.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using Akka.Actor; +using Newtonsoft.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.ExternalSystems; @@ -56,7 +57,8 @@ public class ManagementActor : ReceiveActor try { var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user.Username); - sender.Tell(new ManagementSuccess(correlationId, result)); + var json = JsonConvert.SerializeObject(result, Formatting.None); + sender.Tell(new ManagementSuccess(correlationId, json)); } catch (Exception ex) {