diff --git a/UA.slnx b/UA.slnx index 5f8b02fbd5..05d586755a 100644 --- a/UA.slnx +++ b/UA.slnx @@ -20,6 +20,10 @@ + + + + diff --git a/docs/GDS.md b/docs/GDS.md index 2763330342..e7cd1f3848 100644 --- a/docs/GDS.md +++ b/docs/GDS.md @@ -18,9 +18,9 @@ control. | NuGet Package | Contents | |---------------|----------| -| `Opc.Ua.Gds.Common` | Model types, encodeable types, design files | -| `Opc.Ua.Gds.Server.Common` | Server-side node managers, providers, authorization | -| `Opc.Ua.Gds.Client.Common` | Client proxies for GDS, push, KeyCredential, AuthorizationService | +| `Opc.Ua.Gds.Common` | Part 12 and Part 21 model types, node states, identifiers, encodeable types | +| `Opc.Ua.Gds.Server.Common` | Server-side node managers, providers, authorization, onboarding ticket-store binding | +| `Opc.Ua.Gds.Client.Common` | Client proxies for GDS, push, KeyCredential, AuthorizationService, onboarding | --- @@ -96,6 +96,49 @@ await pushClient.CreateSelfSignedCertificateAsync( "CN=NewSubject", domainNames); ``` +### OPC 10000-21 OnboardingClient + +`Opc.Ua.Gds.Common` ships the generated `Opc.Ua.Onboarding` model, +including the well-known `DeviceRegistrar` instance and the typed +`DeviceRegistrarAdminTypeClient`. `OnboardingClient` is the DI-friendly +facade for loading and removing encoded tickets: + +```csharp +NodeId registrarId = ExpandedNodeId.ToNodeId( + Opc.Ua.Onboarding.ObjectIds.DeviceRegistrar_Administration, + session.NamespaceUris); + +var onboarding = new OnboardingClient(session, registrarId, telemetry); +ArrayOf results = await onboarding.RegisterTicketsAsync( +[ + new ByteString(firstTicket), + new ByteString(secondTicket) +]); +``` + +The public contract follows Part 21: tickets are +`ArrayOf` (`EncodedTicket` is a ByteString subtype), and +the per-ticket results are `ArrayOf`. + +On the server, load the generated model and bind the standard +administration object to an injected ticket store: + +```csharp +NodeStateCollection nodes = new NodeStateCollection() + .AddOpcUaGds(context) + .AddOpcUaOnboarding(context); +DeviceRegistrarState registrar = + nodes.OfType().Single(); + +registrar.Administration!.BindToTicketStore(ticketStore); +``` + +The handlers are asynchronous, pass request cancellation to +`ITicketStore`, and populate the single declared `Results` output. +See the runnable +[`samples/Gds` onboarding demo](../samples/Gds/README.md) for a secure +registrar/client round trip. + --- ## 2. Server-Side: Building a GDS @@ -638,7 +681,7 @@ The following optional Part 12 members are **not** implemented (or only partiall | `ManagedApplications` / `ApplicationConfigurationType` proxy (§7.10.14–§7.10.16) | ⚠️ partial | `DefaultManagedApplicationsNodeManager` creates the nodes with an `IConfigurationDataStore`; the full GDS-as-proxy update/confirm flow to *remote* managed applications is not complete. | | KeyCredential authentication `ProfileUris` — AMQP SASL Plain / MQTT UserName / UA transport UserName (§8, optional CUs) | 🔲 provider-defined | The push binding is implemented; the concrete ProfileUri set advertised is defined by the credential provider, not fixed by the stack. | | A & C CertificateExpiration optional CUs — *Comment* / *Confirm* / *Shelving* | 🔲 inherited | Available through the standard OPC 10000-9 condition model on the alarm instances; not separately exercised by the certificate-alarm tests. | -| Onboarding / OPC 10000-21 device setup interplay with `ResetToServerDefaults` / `InApplicationSetup` (§7.10.13, Annex G) | 🔲 provider-gated | The stack exposes `InApplicationSetup` and defers the reset to `IServerConfigurationResetProvider`; it does not itself implement an OPC 10000-21 onboarding state machine. | +| Onboarding / OPC 10000-21 device setup interplay with `ResetToServerDefaults` / `InApplicationSetup` (§7.10.13, Annex G) | 🔲 provider-gated | Registrar ticket administration is implemented through `OnboardingClient`, `ITicketStore`, and `BindToTicketStore`; the stack does not itself implement the complete device-facing onboarding state machine. | ## LDS / LDS-ME (§4–5) diff --git a/docs/README.md b/docs/README.md index 8355b7979b..2384f64a63 100644 --- a/docs/README.md +++ b/docs/README.md @@ -84,7 +84,7 @@ Starting with version 1.5.375.XX the Windows Forms reference client & reference ## Global Discovery Server (GDS) -* [GDS Developer Guide](GDS.md) — Application registration, certificate management (pull & push models), roles and authorization, provider implementation, end-to-end examples, and a per-requirement [OPC UA Part 12 conformance matrix](GDS.md#conformance-matrix) that links every implemented ServerConfiguration/PushManagement, TrustList, certificate-alarm, KeyCredentialService and AuthorizationService requirement to its source and automated tests (with applicable profiles/conformance units and honest complete/partial/optional/unsupported marks). +* [GDS Developer Guide](GDS.md) — Application registration, certificate management (pull & push models), roles and authorization, OPC 10000-21 registrar ticket administration, provider implementation, the runnable [`samples/Gds`](../samples/Gds/README.md) onboarding demo, and a per-requirement [OPC UA Part 12 conformance matrix](GDS.md#conformance-matrix). * [KeyCredentialService](KeyCredentialService.md) — Credential issuance for non-OPC UA services (MQTT, REST), IKeyCredentialRequestStore provider guide, ISecretStore integration. * [AuthorizationService](AuthorizationService.md) — OAuth2-style access token issuance, IAccessTokenProvider implementation guide. * [Role-Based Security](RoleBasedUserManagement.md) — Part 18 roles and claim-based identity-mapping rules. diff --git a/docs/WhatsNewIn2.0.md b/docs/WhatsNewIn2.0.md index 2fcf34bb43..49aa4f0b96 100644 --- a/docs/WhatsNewIn2.0.md +++ b/docs/WhatsNewIn2.0.md @@ -31,7 +31,8 @@ If you are migrating an existing application, the companion `IEncodeable` data types from model design XML**, removing hand-written boilerplate while staying AOT-clean. - **GDS is now Part 12 full-compliance**, with arbitrary certificate groups, - custom group support, and modernised Push/Pull APIs. + custom group support, modernised Push/Pull APIs, and OPC 10000-21 + registrar ticket administration backed by the generated Onboarding model. - **OPC UA Part 4 §6.6 redundancy plus opt-in distributed high availability.** Server and client redundancy (`Server.ServerRedundancy` / `ServiceLevel`, `ManagedSession.WithServerRedundancy()`) ship in the box, and new diff --git a/samples/Gds/OnboardingClient/OnboardingClient.csproj b/samples/Gds/OnboardingClient/OnboardingClient.csproj new file mode 100644 index 0000000000..a79865a587 --- /dev/null +++ b/samples/Gds/OnboardingClient/OnboardingClient.csproj @@ -0,0 +1,23 @@ + + + net10.0 + Exe + false + OnboardingClient + OnboardingClientDemo + OPC 10000-21 onboarding client sample. + enable + true + true + + + + + + + + + + + + diff --git a/samples/Gds/OnboardingClient/Program.cs b/samples/Gds/OnboardingClient/Program.cs new file mode 100644 index 0000000000..1677e39675 --- /dev/null +++ b/samples/Gds/OnboardingClient/Program.cs @@ -0,0 +1,225 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Gds.Client; +using Opc.Ua.Identity; + +try +{ + HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(); + builder.Logging.SetMinimumLevel(LogLevel.Error); + + string endpoint = builder.Configuration["endpoint"] ?? + "opc.tcp://localhost:62560/OnboardingRegistrar"; + string pkiRoot = builder.Configuration["pkiRoot"] ?? + Path.Combine( + Path.GetTempPath(), + "opcua-onboarding-demo", + "client-pki"); + bool useAnonymous = bool.TryParse( + builder.Configuration["anonymous"], + out bool anonymous) && anonymous; + IClientIdentityProvider? identityProvider = null; + if (!useAnonymous) + { + string userName = GetRequiredEnvironmentVariable("ONBOARDING_DEMO_USER"); + string password = GetRequiredEnvironmentVariable("ONBOARDING_DEMO_PASSWORD"); + var passwordStore = new InMemorySecretStore(); + var passwordId = new SecretIdentifier( + "onboarding-demo-password", + passwordStore.StoreType); + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + try + { + await passwordStore.SetAsync( + passwordId, + passwordBytes, + CancellationToken.None) + .ConfigureAwait(false); + } + finally + { + CryptographicOperations.ZeroMemory(passwordBytes); + } + identityProvider = new UserNamePasswordIdentityProvider( + userName, + new SecretRegistry(passwordStore), + passwordId); + } + + IOpcUaClientBuilder clientBuilder = builder.Services + .AddOpcUa() + .AddClient(options => + { + options.ApplicationName = "OnboardingClient"; + options.ApplicationUri = + "urn:localhost:OPCFoundation:OnboardingClient"; + options.ProductUri = + "uri:opcfoundation.org:UA-.NETStandard:OnboardingClient"; + options.PkiRoot = pkiRoot; + options.AutoAcceptUntrustedCertificates = true; + options.RejectSHA1SignedCertificates = true; + options.MinimumCertificateKeySize = 2048; + options.Session = new ManagedSessionOptions + { + SessionName = "OnboardingClient", + SessionTimeout = TimeSpan.FromSeconds(60) + }; + }) + .AddDiscoveryAndConnect(options => + { + options.DiscoveryUrl = endpoint; + options.SecurityMode = MessageSecurityMode.SignAndEncrypt; + options.SecurityPolicyUri = SecurityPolicies.Basic256Sha256; + }); + if (identityProvider != null) + { + clientBuilder.AddIdentityProvider(identity => identity.Add(identityProvider)); + } + clientBuilder + .AddGdsClient() + .AddOnboardingClient(); + + using IHost host = builder.Build(); + await host.StartAsync(CancellationToken.None).ConfigureAwait(false); + try + { + Func> connect = + host.Services.GetRequiredService< + Func>>(); + ManagedSession session = await connect(CancellationToken.None) + .ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + Console.WriteLine("Server namespaces:"); + for (int i = 0; i < session.NamespaceUris.Count; i++) + { + Console.WriteLine($" ns={i}: {session.NamespaceUris.GetString((uint)i)}"); + } + NodeId registrarId = ExpandedNodeId.ToNodeId( + Opc.Ua.Onboarding.ObjectIds.DeviceRegistrar_Administration, + session.NamespaceUris); + if (registrarId.IsNull) + { + throw new ServiceResultException( + StatusCodes.BadNodeIdUnknown, + "The server did not publish the OPC 10000-21 namespace."); + } + + Func> + createOnboardingClient = host.Services.GetRequiredService< + Func>>(); + OnboardingClient onboarding = await createOnboardingClient( + registrarId, + CancellationToken.None) + .ConfigureAwait(false); + ArrayOf tickets = + [ + new ByteString(Encoding.UTF8.GetBytes("demo-ticket-one")), + new ByteString(Encoding.UTF8.GetBytes("demo-ticket-two")) + ]; + + ArrayOf registered = await onboarding + .RegisterTicketsAsync(tickets) + .ConfigureAwait(false); + RequireStatus(registered, 0, StatusCodes.Good, "register ticket one"); + RequireStatus(registered, 1, StatusCodes.Good, "register ticket two"); + Console.WriteLine( + $"REGISTER {registered[0]} {registered[1]}"); + + ArrayOf removed = await onboarding + .UnregisterTicketsAsync([tickets[0]]) + .ConfigureAwait(false); + RequireStatus(removed, 0, StatusCodes.Good, "unregister ticket one"); + Console.WriteLine($"UNREGISTER {removed[0]}"); + + ArrayOf removedAgain = await onboarding + .UnregisterTicketsAsync([tickets[0]]) + .ConfigureAwait(false); + RequireStatus( + removedAgain, + 0, + StatusCodes.BadNotFound, + "unregister ticket one again"); + Console.WriteLine($"UNREGISTER_AGAIN {removedAgain[0]}"); + Console.WriteLine("ONBOARDING_DEMO_OK"); + } + } + finally + { + await host.StopAsync(CancellationToken.None).ConfigureAwait(false); + } + return 0; +} +catch (Exception ex) +{ + Console.Error.WriteLine(ex); + return 1; +} + +static string GetRequiredEnvironmentVariable(string name) +{ + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"Set the {name} environment variable before starting the demo."); + } + return value; +} + +static void RequireStatus( + ArrayOf statuses, + int index, + StatusCode expected, + string operation) +{ + if (statuses.Count <= index || statuses[index] != expected) + { + StatusCode actual = statuses.Count > index + ? statuses[index] + : StatusCodes.BadNoData; + throw new ServiceResultException( + StatusCodes.BadUnexpectedError, + $"{operation} returned {actual}; expected {expected}."); + } +} diff --git a/samples/Gds/OnboardingClient/Properties/AssemblyInfo.cs b/samples/Gds/OnboardingClient/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/samples/Gds/OnboardingClient/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +[assembly: CLSCompliant(false)] diff --git a/samples/Gds/OnboardingRegistrar/OnboardingRegistrar.csproj b/samples/Gds/OnboardingRegistrar/OnboardingRegistrar.csproj new file mode 100644 index 0000000000..784a10c9bb --- /dev/null +++ b/samples/Gds/OnboardingRegistrar/OnboardingRegistrar.csproj @@ -0,0 +1,23 @@ + + + net10.0 + Exe + false + OnboardingRegistrar + OnboardingRegistrar + OPC 10000-21 onboarding registrar sample. + enable + true + true + + + + + + + + + + + + diff --git a/samples/Gds/OnboardingRegistrar/OnboardingRegistrarNodeManager.cs b/samples/Gds/OnboardingRegistrar/OnboardingRegistrarNodeManager.cs new file mode 100644 index 0000000000..248ec6327a --- /dev/null +++ b/samples/Gds/OnboardingRegistrar/OnboardingRegistrarNodeManager.cs @@ -0,0 +1,238 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.Gds; +using Opc.Ua.Gds.Server.Onboarding; +using Opc.Ua.Identity; +using Opc.Ua.Onboarding; +using Opc.Ua.Server; +using Opc.Ua.Server.Hosting; + +namespace OnboardingRegistrar +{ + /// + /// Creates the Part 21 onboarding registrar node manager. + /// + public sealed class OnboardingRegistrarNodeManagerFactory : IAsyncNodeManagerFactory + { + /// + /// Initializes a new factory. + /// + public OnboardingRegistrarNodeManagerFactory(ITicketStore ticketStore) + { + m_ticketStore = ticketStore ?? + throw new ArgumentNullException(nameof(ticketStore)); + } + + /// + public ArrayOf NamespacesUris => + [ + Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding, + Opc.Ua.Gds.Namespaces.OpcUaGds + ]; + + /// + public ValueTask CreateAsync( + IServerInternal server, + ApplicationConfiguration configuration, + CancellationToken cancellationToken = default) + { +#pragma warning disable CA2000 // Ownership is transferred to the server. + return new ValueTask( + new OnboardingRegistrarNodeManager( + server, + configuration, + m_ticketStore)); +#pragma warning restore CA2000 + } + + private readonly ITicketStore m_ticketStore; + } + + /// + /// Loads the generated OPC 10000-21 model and binds its standard + /// registrar administration instance to an injected ticket store. + /// + public sealed class OnboardingRegistrarNodeManager : AsyncCustomNodeManager + { + /// + /// Initializes the node manager. + /// + public OnboardingRegistrarNodeManager( + IServerInternal server, + ApplicationConfiguration configuration, + ITicketStore ticketStore) + : base( + server, + configuration, + server.Telemetry.CreateLogger()) + { + m_ticketStore = ticketStore ?? + throw new ArgumentNullException(nameof(ticketStore)); + NamespaceUris = + [ + Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding, + Opc.Ua.Gds.Namespaces.OpcUaGds + ]; + + Server.Factory.Builder + .AddOpcUaGds() + .AddOpcUaOnboarding() + .Commit(); + Server.MessageContext.Factory.Builder + .AddOpcUaGds() + .AddOpcUaOnboarding() + .Commit(); + } + + /// + protected override ValueTask LoadPredefinedNodesAsync( + ISystemContext context, + CancellationToken cancellationToken = default) + { + NodeStateCollection nodes = new NodeStateCollection() + .AddOpcUaGds(context) + .AddOpcUaOnboarding(context); + DeviceRegistrarState registrar = + nodes.OfType().Single(); + DeviceRegistrarAdminState administration = registrar.Administration ?? + throw new InvalidOperationException( + "The generated DeviceRegistrar has no Administration child."); + ConfigurePermissions(context, administration); + administration.BindToTicketStore(m_ticketStore); + return new ValueTask(nodes); + } + + private static void ConfigurePermissions( + ISystemContext context, + DeviceRegistrarAdminState administration) + { + NodeId roleId = ExpandedNodeId.ToNodeId( + Opc.Ua.Onboarding.ObjectIds.WellKnownRole_RegistrarAdmin, + context.NamespaceUris); + administration.RolePermissions = + [ + new RolePermissionType + { + RoleId = roleId, + Permissions = (uint)PermissionType.Browse + } + ]; + ArrayOf methodPermissions = + [ + new RolePermissionType + { + RoleId = roleId, + Permissions = (uint)(PermissionType.Browse | PermissionType.Call) + } + ]; + administration.RegisterTickets!.RolePermissions = methodPermissions; + administration.UnregisterTickets!.RolePermissions = methodPermissions; + } + + private readonly ITicketStore m_ticketStore; + } + + /// + /// Emits the demo readiness marker after the OPC UA server reaches its + /// running state. + /// + public sealed class OnboardingReadyStartupTask : IServerStartupTask + { + /// + /// Initializes the startup task. + /// + public OnboardingReadyStartupTask(string endpoint) + { + m_endpoint = endpoint ?? + throw new ArgumentNullException(nameof(endpoint)); + } + + /// + public ValueTask OnServerStartedAsync( + IServerContext server, + CancellationToken cancellationToken = default) + { + Console.WriteLine($"ONBOARDING_REGISTRAR_READY {m_endpoint}"); + return default; + } + + private readonly string m_endpoint; + } + + /// + /// Grants the generated RegistrarAdmin role to the one ephemeral demo + /// account after its username/password token has been authenticated. + /// + public sealed class OnboardingRegistrarAdminAugmenter : IIdentityAugmenter + { + /// + /// Initializes the augmenter. + /// + public OnboardingRegistrarAdminAugmenter(string userName) + { + m_userName = userName ?? + throw new ArgumentNullException(nameof(userName)); + } + + /// + public ValueTask AugmentAsync( + IUserIdentity identity, + AuthenticationContext context, + CancellationToken ct = default) + { + if (context.TokenHandler is not UserNameIdentityTokenHandler userNameToken || + !string.Equals( + userNameToken.UserName, + m_userName, + StringComparison.Ordinal)) + { + return new ValueTask( + AuthenticationResult.NotHandled); + } + var registrarAdmin = new Role( + Opc.Ua.Onboarding.ObjectIds.WellKnownRole_RegistrarAdmin, + Opc.Ua.Onboarding.BrowseNames.WellKnownRole_RegistrarAdmin); + var augmented = new RoleBasedIdentity( + identity, + [registrarAdmin], + context.MessageContext.NamespaceUris); + return new ValueTask( + AuthenticationResult.Accept(augmented)); + } + + private readonly string m_userName; + } +} diff --git a/samples/Gds/OnboardingRegistrar/Program.cs b/samples/Gds/OnboardingRegistrar/Program.cs new file mode 100644 index 0000000000..739efe6b31 --- /dev/null +++ b/samples/Gds/OnboardingRegistrar/Program.cs @@ -0,0 +1,143 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.Gds.Server.Onboarding; +using Opc.Ua.Server; +using Opc.Ua.Server.Hosting; +using Opc.Ua.Server.UserDatabase; +using Opc.Ua.Server.UserManagement; +using OnboardingRegistrar; + +try +{ + HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(); + + int port = int.TryParse(builder.Configuration["port"], out int configuredPort) + ? configuredPort + : 62560; + string pkiRoot = builder.Configuration["pkiRoot"] ?? + Path.Combine( + Path.GetTempPath(), + "opcua-onboarding-demo", + "registrar-pki"); + string endpoint = $"opc.tcp://localhost:{port}/OnboardingRegistrar"; + string userName = GetRequiredEnvironmentVariable("ONBOARDING_DEMO_USER"); + string password = GetRequiredEnvironmentVariable("ONBOARDING_DEMO_PASSWORD"); + var userDatabase = new LinqUserDatabase(); + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + try + { + if (!userDatabase.CreateUser( + userName, + passwordBytes, + [Role.AuthenticatedUser])) + { + throw new InvalidOperationException( + "Could not create the onboarding demo administrator."); + } + } + finally + { + CryptographicOperations.ZeroMemory(passwordBytes); + } + builder.Services.AddSingleton(_ => new MemoryTicketStore()); + builder.Services.AddSingleton(userDatabase); + builder.Services.AddSingleton(services => + new UserManagement(services.GetRequiredService())); + builder.Services.AddSingleton( + new OnboardingReadyStartupTask(endpoint)); + builder.Services + .AddOpcUa() + .AddServer(options => + { + options.ApplicationName = "OnboardingRegistrar"; + options.ApplicationUri = + "urn:localhost:OPCFoundation:OnboardingRegistrar"; + options.ProductUri = + "uri:opcfoundation.org:UA-.NETStandard:OnboardingRegistrar"; + options.PkiRoot = pkiRoot; + options.AutoAcceptUntrustedCertificates = true; + options.RejectSHA1Certificates = true; + options.MinCertificateKeySize = 2048; + options.IncludeSignAndEncryptPolicies = true; + options.IncludeUnsecurePolicyNone = false; + options.IncludeEccPolicies = false; + options.UserTokenPolicies.Add(new OpcUaUserTokenPolicy + { + TokenType = UserTokenType.Anonymous + }); + options.UserTokenPolicies.Add(new OpcUaUserTokenPolicy + { + TokenType = UserTokenType.UserName + }); + options.EndpointUrls.Add(endpoint); + }) + .AddDefaultIdentityAuthenticators(options => + { + options.EnableAnonymous = true; + options.EnableUserNamePassword = true; + options.EnableX509 = false; + options.EnableJwt = false; + }) + .AddIdentityAugmenter(_ => + new OnboardingRegistrarAdminAugmenter(userName)) + .AddNodeManager(); + + using IHost host = builder.Build(); + await host.StartAsync(CancellationToken.None).ConfigureAwait(false); + await host.WaitForShutdownAsync(CancellationToken.None).ConfigureAwait(false); + return 0; +} +catch (Exception ex) +{ + Console.Error.WriteLine(ex); + return 1; +} + +static string GetRequiredEnvironmentVariable(string name) +{ + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"Set the {name} environment variable before starting the demo."); + } + return value; +} diff --git a/samples/Gds/OnboardingRegistrar/Properties/AssemblyInfo.cs b/samples/Gds/OnboardingRegistrar/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/samples/Gds/OnboardingRegistrar/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +[assembly: CLSCompliant(false)] diff --git a/samples/Gds/README.md b/samples/Gds/README.md new file mode 100644 index 0000000000..a39aa7c279 --- /dev/null +++ b/samples/Gds/README.md @@ -0,0 +1,104 @@ +# OPC 10000-21 Onboarding Demo + +This sample demonstrates the registrar-administration part of OPC +10000-21 end to end over a real OPC UA connection. It uses only the +model and runtime APIs shipped by this repository: + +- `OnboardingRegistrar` loads the generated `Opc.Ua.Onboarding` model, + exposes the standard `DeviceRegistrar_Administration` object, and binds + `RegisterTickets` / `UnregisterTickets` to an injected + `MemoryTicketStore`. +- `OnboardingClient` connects with a managed session, resolves the + well-known registrar NodeId, and uses the DI-provided + `Opc.Ua.Gds.Client.OnboardingClient`. +- `run-onboarding-demo.ps1` builds both applications, waits until the + OPC UA server reaches its running state, proves an anonymous caller is + denied, runs the authenticated client, and stops the exact registrar + process in a `finally` block. + +The demo covers ticket administration. It does not implement the +device-facing `ProvideIdentities` flow or a complete production GDS. + +## Run the complete demo + +From the repository root: + +```powershell +pwsh samples/Gds/run-onboarding-demo.ps1 +``` + +The script uses +`opc.tcp://localhost:62560/OnboardingRegistrar` by default. Select a +different port with: + +```powershell +pwsh samples/Gds/run-onboarding-demo.ps1 -Port 62660 +``` + +Successful output ends with: + +```text +REGISTER Good Good +UNREGISTER Good +UNREGISTER_AGAIN BadNotFound +ONBOARDING_DEMO_OK +``` + +Pass `-Keep` to retain the temporary PKI stores and registrar logs for +diagnostics. + +## Run the applications manually + +Start the registrar: + +```powershell +dotnet run --project samples/Gds/OnboardingRegistrar -f net10.0 -- ` + --port 62560 ` + --pkiRoot "$env:TEMP\opcua-onboarding-registrar" +``` + +After `ONBOARDING_REGISTRAR_READY` is printed, run the client: + +```powershell +dotnet run --project samples/Gds/OnboardingClient -f net10.0 -- ` + --endpoint "opc.tcp://localhost:62560/OnboardingRegistrar" ` + --pkiRoot "$env:TEMP\opcua-onboarding-client" +``` + +## Wire contract + +The client API uses the Part 21 types directly: + +```csharp +ArrayOf results = await onboarding.RegisterTicketsAsync( +[ + new ByteString(firstTicket), + new ByteString(secondTicket) +]); +``` + +`EncodedTicket` is a subtype of `ByteString`. The server returns one +`StatusCode` for every supplied ticket. + +## Security + +The demo selects `SignAndEncrypt` with `Basic256Sha256`. For a +self-contained local run, both applications automatically accept the +other application's newly created certificate and store it under an +isolated temporary PKI root. The script also generates a one-run random +username and password, grants that user the generated `RegistrarAdmin` +role, and restricts the two ticket methods to that role. The endpoint +also permits an anonymous bootstrap session because `ManagedSession` +activates its configured identity provider after creating the session; +anonymous users have no permission to call the registrar methods. + +Automatic certificate acceptance is a sample-only convenience. A +production registrar must use managed trust lists, authenticate and +authorize registrar administrators, persist tickets in a protected +store, and apply the audit/redaction requirements described in the +[GDS developer guide](../../docs/GDS.md). + +For a manual run, set `ONBOARDING_DEMO_USER` and +`ONBOARDING_DEMO_PASSWORD` to the same non-empty values in both +terminals before starting either application. Do not reuse demo +credentials in a production deployment. diff --git a/samples/Gds/run-onboarding-demo.ps1 b/samples/Gds/run-onboarding-demo.ps1 new file mode 100644 index 0000000000..b4150c0514 --- /dev/null +++ b/samples/Gds/run-onboarding-demo.ps1 @@ -0,0 +1,149 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Builds and runs the OPC 10000-21 onboarding registrar/client demo. + +.PARAMETER Port + Local OPC UA TCP port used by the registrar. + +.PARAMETER Keep + Keeps the isolated PKI and captured server logs after the run. + +.EXAMPLE + pwsh samples/Gds/run-onboarding-demo.ps1 +#> +[CmdletBinding()] +param( + [int]$Port = 62560, + [switch]$Keep +) + +$ErrorActionPreference = 'Stop' +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$serverProject = Join-Path $here 'OnboardingRegistrar\OnboardingRegistrar.csproj' +$clientProject = Join-Path $here 'OnboardingClient\OnboardingClient.csproj' +$runRoot = Join-Path ([IO.Path]::GetTempPath()) "opcua-onboarding-demo-$PID" +$serverPki = Join-Path $runRoot 'registrar-pki' +$clientPki = Join-Path $runRoot 'client-pki' +$serverOut = Join-Path $runRoot 'registrar.out.log' +$serverErr = Join-Path $runRoot 'registrar.err.log' +$endpoint = "opc.tcp://localhost:$Port/OnboardingRegistrar" +$serverDll = Join-Path $here 'OnboardingRegistrar\bin\Debug\net10.0\OnboardingRegistrar.dll' +$clientDll = Join-Path $here 'OnboardingClient\bin\Debug\net10.0\OnboardingClient.dll' +$server = $null +$previousUser = $env:ONBOARDING_DEMO_USER +$previousPassword = $env:ONBOARDING_DEMO_PASSWORD + +function Step([string]$message) { + Write-Host '' + Write-Host "==> $message" -ForegroundColor Cyan +} + +try { + New-Item -ItemType Directory -Force -Path $runRoot | Out-Null + $env:ONBOARDING_DEMO_USER = "onboarding-admin-$PID" + $env:ONBOARDING_DEMO_PASSWORD = [Convert]::ToBase64String( + [Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) + + Step 'Building the onboarding registrar and client' + dotnet build $serverProject -f net10.0 --verbosity quiet + if ($LASTEXITCODE -ne 0) { + throw 'The onboarding registrar build failed.' + } + dotnet build $clientProject -f net10.0 --verbosity quiet + if ($LASTEXITCODE -ne 0) { + throw 'The onboarding client build failed.' + } + + Step "Starting the registrar at $endpoint" + $server = Start-Process dotnet ` + -ArgumentList @( + "`"$serverDll`"", + '--port', $Port, + '--pkiRoot', $serverPki + ) ` + -RedirectStandardOutput $serverOut ` + -RedirectStandardError $serverErr ` + -PassThru + + $deadline = [DateTime]::UtcNow.AddSeconds(60) + $ready = $false + while ([DateTime]::UtcNow -lt $deadline) { + if ($server.HasExited) { + break + } + if ((Test-Path $serverOut) -and + (Get-Content -Raw $serverOut) -match 'ONBOARDING_REGISTRAR_READY') { + $ready = $true + break + } + Start-Sleep -Milliseconds 250 + } + if (-not $ready) { + throw 'The onboarding registrar did not become ready within 60 seconds.' + } + + Step 'Verifying anonymous ticket administration is denied' + $anonymousOutput = & dotnet $clientDll ` + --endpoint $endpoint ` + --pkiRoot $clientPki ` + --anonymous true 2>&1 + $anonymousExitCode = $LASTEXITCODE + Write-Host ($anonymousOutput | Out-String) + if ($anonymousExitCode -eq 0 -or + ($anonymousOutput | Out-String) -notmatch 'BadUserAccessDenied') { + throw 'Anonymous ticket administration was not rejected.' + } + + Step 'Running the onboarding client' + & dotnet $clientDll ` + --endpoint $endpoint ` + --pkiRoot $clientPki 2>&1 | + Tee-Object -Variable capturedClientOutput + if ($LASTEXITCODE -ne 0) { + throw 'The onboarding client failed.' + } + if (($capturedClientOutput | Out-String) -notmatch 'ONBOARDING_DEMO_OK') { + throw 'The onboarding client did not report a successful round trip.' + } + + Write-Host '' + Write-Host 'Onboarding registrar/client round trip succeeded.' -ForegroundColor Green +} +catch { + if (Test-Path $serverOut) { + Write-Host '' + Write-Host 'Registrar output:' -ForegroundColor Yellow + Get-Content $serverOut + } + if (Test-Path $serverErr) { + Write-Host '' + Write-Host 'Registrar errors:' -ForegroundColor Yellow + Get-Content $serverErr + } + throw +} +finally { + if ($server -ne $null -and -not $server.HasExited) { + Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue + $server.WaitForExit() + } + if ($previousUser -eq $null) { + Remove-Item Env:\ONBOARDING_DEMO_USER -ErrorAction SilentlyContinue + } + else { + $env:ONBOARDING_DEMO_USER = $previousUser + } + if ($previousPassword -eq $null) { + Remove-Item Env:\ONBOARDING_DEMO_PASSWORD -ErrorAction SilentlyContinue + } + else { + $env:ONBOARDING_DEMO_PASSWORD = $previousPassword + } + if ($Keep) { + Write-Host "Demo artifacts kept at $runRoot" -ForegroundColor Yellow + } + elseif (Test-Path $runRoot) { + Remove-Item -LiteralPath $runRoot -Recurse -Force + } +} diff --git a/samples/README.md b/samples/README.md index cf63f26d3a..478f6cd09a 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,5 +1,12 @@ # OPC Foundation UA .NET Standard Reference Server +## Additional samples + +- [OPC 10000-21 onboarding registrar/client](Gds/README.md) — loads the + generated Onboarding companion model, binds the standard registrar + administration methods to an injected ticket store, and drives a secure + register/unregister round trip with a PowerShell script. + ## Introduction This document is referring to the CTT version 1.04.9.398. diff --git a/src/Opc.Ua.Core/Schema/Opc.Ua.NodeSet.xml b/src/Opc.Ua.Core/Schema/Opc.Ua.NodeSet.xml index 24e98a20c9..bdfc3667c1 100644 --- a/src/Opc.Ua.Core/Schema/Opc.Ua.NodeSet.xml +++ b/src/Opc.Ua.Core/Schema/Opc.Ua.NodeSet.xml @@ -56432,7 +56432,7 @@ Onboarding Ticket Reader https://reference.opcfoundation.org/v105/Core/docs/Part21/8.2.1 - i=12 + i=15 diff --git a/src/Opc.Ua.Gds.Client.Common/NugetREADME.md b/src/Opc.Ua.Gds.Client.Common/NugetREADME.md index f96c4d17cc..9200765a78 100644 --- a/src/Opc.Ua.Gds.Client.Common/NugetREADME.md +++ b/src/Opc.Ua.Gds.Client.Common/NugetREADME.md @@ -14,6 +14,11 @@ Reference this package from any application or tool that needs to talk to a GDS — typically a provisioning utility, a certificate authority bridge, or a server that uses GDS-driven push management. +For OPC 10000-21 registrar administration, `OnboardingClient` calls the +standard `DeviceRegistrarAdminType` instance with specification-native +`ArrayOf` ticket inputs and returns `ArrayOf`. +Register it through `.AddGdsClient().AddOnboardingClient()`. + ## Target frameworks `net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`, diff --git a/src/Opc.Ua.Gds.Client.Common/OnboardingClient.cs b/src/Opc.Ua.Gds.Client.Common/OnboardingClient.cs index 2d4651fa3c..a4cc648dbe 100644 --- a/src/Opc.Ua.Gds.Client.Common/OnboardingClient.cs +++ b/src/Opc.Ua.Gds.Client.Common/OnboardingClient.cs @@ -1,5 +1,5 @@ /* ======================================================================== - * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * @@ -28,20 +28,21 @@ * ======================================================================*/ using System; -using System.Threading; -using System.Threading.Tasks; using Opc.Ua.Client; +using Opc.Ua.Onboarding; namespace Opc.Ua.Gds.Client { /// - /// Client-side wrapper for the OPC 10000-100 Part 21 - /// DeviceRegistrarAdminType facet. Calls - /// RegisterTickets / UnregisterTickets on the - /// server-side registrar and returns the per-ticket status - /// codes. + /// Client-side wrapper for the OPC 10000-21 + /// DeviceRegistrarAdminType facet. /// - public sealed class OnboardingClient + /// + /// The specification-native RegisterTickets and + /// UnregisterTickets methods are inherited from + /// . + /// + public sealed class OnboardingClient : DeviceRegistrarAdminTypeClient { /// /// Creates a new onboarding client rooted at the supplied @@ -51,166 +52,73 @@ namespace Opc.Ua.Gds.Client ISession session, NodeId registrarNodeId, ITelemetryContext telemetry) + : base( + PrepareSession(session), + ValidateRegistrarNodeId(registrarNodeId), + telemetry ?? throw new ArgumentNullException(nameof(telemetry))) { - if (session is null) - { - throw new ArgumentNullException(nameof(session)); - } - if (registrarNodeId.IsNull) - { - throw new ArgumentException( - "Registrar NodeId is required.", nameof(registrarNodeId)); - } - if (telemetry is null) - { - throw new ArgumentNullException(nameof(telemetry)); - } - Session = session; - RegistrarNodeId = registrarNodeId; - Telemetry = telemetry; } /// /// The owning session. /// - public ISession Session { get; } + public new ISession Session => (ISession)base.Session; /// - /// The NodeId of the registrar instance. + /// The NodeId of the registrar administration instance. /// - public NodeId RegistrarNodeId { get; } + public NodeId RegistrarNodeId => ObjectId; - /// - /// Telemetry context. - /// - public ITelemetryContext Telemetry { get; } - - /// - /// Invokes RegisterTickets. Returns the per-ticket - /// status array reported by the server. - /// - /// is null. - public ValueTask RegisterTicketsAsync( - byte[][] tickets, CancellationToken ct = default) + private static ISession PrepareSession(ISession session) { - if (tickets == null) + if (session is null) { - throw new ArgumentNullException(nameof(tickets)); + throw new ArgumentNullException(nameof(session)); } - return CallTicketArrayMethodAsync("RegisterTickets", tickets, ct); + NamespaceTable messageContextNamespaces = + session.MessageContext.NamespaceUris; + NamespaceTable sessionNamespaces = session.NamespaceUris; + if (!NamespaceTablesMatch(messageContextNamespaces, sessionNamespaces)) + { + messageContextNamespaces.Update(sessionNamespaces.ToArray()); + } + return session; } - /// - /// Invokes UnregisterTickets. Returns the per-ticket - /// status array. - /// - /// is null. - public ValueTask UnregisterTicketsAsync( - byte[][] tickets, CancellationToken ct = default) + private static bool NamespaceTablesMatch( + NamespaceTable first, + NamespaceTable second) { - if (tickets == null) + if (ReferenceEquals(first, second)) { - throw new ArgumentNullException(nameof(tickets)); + return true; } - return CallTicketArrayMethodAsync("UnregisterTickets", tickets, ct); - } - - private async ValueTask CallTicketArrayMethodAsync( - string methodBrowseName, - byte[][] tickets, - CancellationToken ct) - { - NodeId methodId = await ResolveMethodAsync(methodBrowseName, ct) - .ConfigureAwait(false); - - var bs = new ByteString[tickets.Length]; - for (int i = 0; i < tickets.Length; i++) + if (first.Count != second.Count) { - bs[i] = new ByteString(tickets[i] ?? []); + return false; } - - var request = new CallMethodRequest + for (int i = 0; i < first.Count; i++) { - ObjectId = RegistrarNodeId, - MethodId = methodId, - InputArguments = - new Variant[] { new(bs.ToArrayOf()) }.ToArrayOf() - }; - - CallResponse response = await Session - .CallAsync( - requestHeader: null, - methodsToCall: new[] { request }.ToArrayOf(), - ct: ct) - .ConfigureAwait(false); - - if (response.Results.Count == 0) - { - throw new ServiceResultException( - StatusCodes.BadUnexpectedError, - "Registrar call returned no results."); - } - - CallMethodResult result = response.Results[0]; - if (StatusCode.IsBad(result.StatusCode)) - { - throw new ServiceResultException( - result.StatusCode, - $"Registrar call returned bad status {result.StatusCode}."); - } - - if (result.OutputArguments.Count == 0) - - { - return []; - } - object? boxed = result.OutputArguments[0].AsBoxedObject(); - return boxed switch - { - int[] arr => arr, - ArrayOf ai => ai.ToArray() ?? [], - _ => [] - }; - } - - private async ValueTask ResolveMethodAsync( - string browseName, CancellationToken ct) - { - var path = new BrowsePath - { - StartingNode = RegistrarNodeId, - RelativePath = new RelativePath + if (!string.Equals( + first.GetString((uint)i), + second.GetString((uint)i), + StringComparison.Ordinal)) { - Elements = new[] - { - new RelativePathElement - { - ReferenceTypeId = ReferenceTypeIds.HasComponent, - IsInverse = false, - IncludeSubtypes = true, - TargetName = new QualifiedName(browseName) - } - } + return false; } - }; - - TranslateBrowsePathsToNodeIdsResponse translate = await Session - .TranslateBrowsePathsToNodeIdsAsync( - null, new[] { path }.ToArrayOf(), ct) - .ConfigureAwait(false); - - if (translate.Results.Count == 0 || - StatusCode.IsBad(translate.Results[0].StatusCode) || - translate.Results[0].Targets.Count == 0) - { - throw new ServiceResultException( - StatusCodes.BadNotFound, - $"Could not resolve method '{browseName}' under registrar."); } + return true; + } - return ExpandedNodeId.ToNodeId( - translate.Results[0].Targets[0].TargetId, - Session.NamespaceUris); + private static NodeId ValidateRegistrarNodeId(NodeId registrarNodeId) + { + if (registrarNodeId.IsNull) + { + throw new ArgumentException( + "Registrar NodeId is required.", + nameof(registrarNodeId)); + } + return registrarNodeId; } } } diff --git a/src/Opc.Ua.Gds.Common/NugetREADME.md b/src/Opc.Ua.Gds.Common/NugetREADME.md index dc7f55e637..c4942db8a1 100644 --- a/src/Opc.Ua.Gds.Common/NugetREADME.md +++ b/src/Opc.Ua.Gds.Common/NugetREADME.md @@ -2,9 +2,10 @@ `OPCFoundation.NetStandard.Opc.Ua.Gds.Common` contains the shared information model and DataType definitions used by the OPC UA Global -Discovery Server (GDS) — OPC 10000-12 (Part 12). It is the type-only -contract that the client (`Opc.Ua.Gds.Client.Common`) and server -(`Opc.Ua.Gds.Server.Common`) GDS packages depend on. +Discovery Server (GDS) — OPC 10000-12 (Part 12) — and the OPC 10000-21 +Onboarding companion model. It is the type-only contract that the client +(`Opc.Ua.Gds.Client.Common`) and server (`Opc.Ua.Gds.Server.Common`) GDS +packages depend on. ## Overview @@ -12,6 +13,12 @@ Reference this package directly only when you need the GDS proxies without the client / server-side helpers — typically for a tool that inspects or transcodes GDS payloads outside a full GDS pipeline. +The package ships generated `Opc.Ua.Onboarding` constants, node states, +encodeable types, and typed ObjectType clients. This includes +`DeviceRegistrarState`, `DeviceRegistrarAdminState`, +`DeviceRegistrarAdminTypeClient`, and the well-known +`ObjectIds.DeviceRegistrar_Administration` NodeId. + ## Target frameworks `net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`, diff --git a/src/Opc.Ua.Gds.Server.Common/NugetREADME.md b/src/Opc.Ua.Gds.Server.Common/NugetREADME.md index 5d6df88727..35f93847c4 100644 --- a/src/Opc.Ua.Gds.Server.Common/NugetREADME.md +++ b/src/Opc.Ua.Gds.Server.Common/NugetREADME.md @@ -15,6 +15,13 @@ applications-database provider ships an in-process LINQ implementation; production deployments usually plug a real database back-end behind the same `IApplicationsDatabase` surface. +The package also provides the OPC 10000-21 registrar administration +surface. Load the generated `Opc.Ua.Onboarding` nodes from +`Opc.Ua.Gds.Common`, then bind `DeviceRegistrarAdminState` to an +`ITicketStore` with `BindToTicketStore`. `MemoryTicketStore` is provided +for samples and tests; production hosts should inject a persistent, +concurrency-safe implementation. + ## Target frameworks `net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`, diff --git a/src/Opc.Ua.Gds.Server.Common/Onboarding/DeviceRegistrarAdminExtensions.cs b/src/Opc.Ua.Gds.Server.Common/Onboarding/DeviceRegistrarAdminExtensions.cs index 1ee634d431..3b277fd069 100644 --- a/src/Opc.Ua.Gds.Server.Common/Onboarding/DeviceRegistrarAdminExtensions.cs +++ b/src/Opc.Ua.Gds.Server.Common/Onboarding/DeviceRegistrarAdminExtensions.cs @@ -29,16 +29,17 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; namespace Opc.Ua.Gds.Server.Onboarding { /// - /// Convenience extensions that wire an - /// + - /// pair to the OPC 10000-100 Part 21 DeviceRegistrarAdminType + /// Convenience extensions that wire an + /// to the OPC 10000-21 DeviceRegistrarAdminType /// method children — RegisterTickets and /// UnregisterTickets. Uses - /// so the extension works + /// so the extension works /// against any concrete state derived from /// that carries the expected /// method children. @@ -88,99 +89,115 @@ namespace Opc.Ua.Gds.Server.Onboarding ITicketStore capturedStore = store; - register.OnCallMethod2 = (ctx, m, objectId, inputs, outputs) => - HandleRegister(ctx, capturedStore, inputs, outputs); + register.OnCallMethod2Async = (ctx, m, objectId, inputs, outputs, ct) => + HandleRegisterAsync(capturedStore, inputs, outputs, ct); - unregister.OnCallMethod2 = (ctx, m, objectId, inputs, outputs) => - HandleUnregister(ctx, capturedStore, inputs, outputs); + unregister.OnCallMethod2Async = (ctx, m, objectId, inputs, outputs, ct) => + HandleUnregisterAsync(capturedStore, inputs, outputs, ct); } - private static ServiceResult HandleRegister( - ISystemContext context, + private static async ValueTask HandleRegisterAsync( ITicketStore store, ArrayOf inputs, - List outputs) + List outputs, + CancellationToken cancellationToken) { if (inputs.Count < 1) { return StatusCodes.BadArgumentsMissing; } - byte[][] tickets = ExtractTicketArray(inputs[0]); - int[] results = new int[tickets.Length]; - - for (int i = 0; i < tickets.Length; i++) + if (!inputs[0].TryGetValue(out ArrayOf tickets)) { - string ticketId = ComputeTicketId(tickets[i]); + return StatusCodes.BadTypeMismatch; + } + if (outputs.Count < 1) + { + return StatusCodes.BadUnexpectedError; + } + var results = new StatusCode[tickets.Count]; + + for (int i = 0; i < tickets.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + byte[] ticket = tickets[i].ToArray(); + string ticketId = ComputeTicketId(ticket); try { - store.AddAsync( - ticketId, - tickets[i], - new TicketMetadata( - Kind: TicketKind.Unspecified, - ManufacturerName: string.Empty, - ModelName: string.Empty, - SerialNumber: string.Empty, - ProductInstanceUri: string.Empty)) - .AsTask().GetAwaiter().GetResult(); - results[i] = (int)(uint)StatusCodes.Good; + await store.AddAsync( + ticketId, + ticket, + new TicketMetadata( + Kind: TicketKind.Unspecified, + ManufacturerName: string.Empty, + ModelName: string.Empty, + SerialNumber: string.Empty, + ProductInstanceUri: string.Empty), + cancellationToken) + .ConfigureAwait(false); + results[i] = StatusCodes.Good; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch (Exception ex) { - results[i] = (int)new ServiceResult(ex).StatusCode.Code; + results[i] = new ServiceResult(ex).StatusCode; } } - outputs.Add(new Variant(results.ToArrayOf())); + outputs[0] = Variant.From(results.ToArrayOf()); return ServiceResult.Good; } - private static ServiceResult HandleUnregister( - ISystemContext context, + private static async ValueTask HandleUnregisterAsync( ITicketStore store, ArrayOf inputs, - List outputs) + List outputs, + CancellationToken cancellationToken) { if (inputs.Count < 1) { return StatusCodes.BadArgumentsMissing; } - byte[][] tickets = ExtractTicketArray(inputs[0]); - int[] results = new int[tickets.Length]; - - for (int i = 0; i < tickets.Length; i++) + if (!inputs[0].TryGetValue(out ArrayOf tickets)) { - string ticketId = ComputeTicketId(tickets[i]); + return StatusCodes.BadTypeMismatch; + } + if (outputs.Count < 1) + { + return StatusCodes.BadUnexpectedError; + } + var results = new StatusCode[tickets.Count]; + + for (int i = 0; i < tickets.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + byte[] ticket = tickets[i].ToArray(); + string ticketId = ComputeTicketId(ticket); try { - bool removed = store.RemoveAsync(ticketId).AsTask().GetAwaiter().GetResult(); + bool removed = await store + .RemoveAsync(ticketId, cancellationToken) + .ConfigureAwait(false); results[i] = removed - ? (int)(uint)StatusCodes.Good - : (int)(uint)StatusCodes.BadNotFound; + ? StatusCodes.Good + : StatusCodes.BadNotFound; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch (Exception ex) { - results[i] = (int)new ServiceResult(ex).StatusCode.Code; + results[i] = new ServiceResult(ex).StatusCode; } } - outputs.Add(new Variant(results.ToArrayOf())); + outputs[0] = Variant.From(results.ToArrayOf()); return ServiceResult.Good; } - private static byte[][] ExtractTicketArray(Variant variant) - { - object? boxed = variant.AsBoxedObject(); - return boxed switch - { - byte[][] arr => arr, - ByteString[] bs => Array.ConvertAll(bs, b => b.ToArray()), - ArrayOf abs => abs.ToArray() - is { } abu ? Array.ConvertAll(abu, b => b.ToArray()) : [], - _ => [] - }; - } - private static string ComputeTicketId(byte[] ticket) { // Stable identifier — SHA-256 hash of the encoded ticket. diff --git a/src/Opc.Ua.Gds.Server.Common/Onboarding/ITicketStore.cs b/src/Opc.Ua.Gds.Server.Common/Onboarding/ITicketStore.cs index 715d4a8869..5a1686e612 100644 --- a/src/Opc.Ua.Gds.Server.Common/Onboarding/ITicketStore.cs +++ b/src/Opc.Ua.Gds.Server.Common/Onboarding/ITicketStore.cs @@ -35,8 +35,8 @@ using System.Threading.Tasks; namespace Opc.Ua.Gds.Server.Onboarding { /// - /// Storage abstraction for OPC 10000-100 Onboarding tickets - /// (Part 21, OpcUaOnboardingModel.xml). A ticket is the encoded + /// Storage abstraction for OPC 10000-21 Onboarding tickets. + /// A ticket is the encoded /// identifier-and-vendor data block a device presents during /// provisioning; the registrar stores them and matches incoming /// device certificates against the bundle. @@ -46,9 +46,9 @@ namespace Opc.Ua.Gds.Server.Onboarding /// This is the application-facing facade; the source-generated /// BaseTicketType / DeviceIdentityTicketType / /// CompositeIdentityTicketType proxies in - /// Opc.Ua.Di remain the wire-level encoding. The store - /// works with the encoded ticket bytes (the spec's - /// EncodedTicket type — a [] alias). + /// Opc.Ua.Onboarding remain the wire-level encoding. The store + /// persists the bytes carried by the specification's + /// EncodedTicket ByteString subtype. /// /// /// All implementations must be safe for concurrent calls. @@ -67,8 +67,8 @@ namespace Opc.Ua.Gds.Server.Onboarding /// ticket). /// /// - /// Encoded ticket bytes (DataType EncodedTicket, a - /// [] alias). + /// Encoded ticket bytes carried on the wire as the + /// EncodedTicket ByteString subtype. /// /// /// Application-supplied metadata for the ticket (kind, diff --git a/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs b/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs index 7414b7a942..aa724d5963 100644 --- a/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs +++ b/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs @@ -397,10 +397,14 @@ namespace Opc.Ua.Server.Historian } // Part 11 v1.05.07 §6.5.4.2: the request domain is defined by StartTime, EndTime and - // ProcessingInterval, all of which shall be specified. If StartTime equals EndTime there - // is no meaningful way to interpret the (zero-width) time domain, so the Server shall - // return Bad_InvalidArgument. - if (details.StartTime == details.EndTime) + // ProcessingInterval, all of which shall be specified. A zero ProcessingInterval is + // valid and requests one aggregate over the entire range; negative or non-finite + // durations are invalid. If StartTime equals EndTime there is no meaningful way to + // interpret the zero-width time domain. + if (details.StartTime == details.EndTime || + details.ProcessingInterval < 0 || + double.IsNaN(details.ProcessingInterval) || + double.IsInfinity(details.ProcessingInterval)) { result.StatusCode = StatusCodes.BadInvalidArgument; return StatusCodes.BadInvalidArgument; @@ -545,13 +549,10 @@ namespace Opc.Ua.Server.Historian foreach (HistoricalDataValue sample in page.Values) { - if (!calculator.QueueRawValue(sample.Value)) + if (!calculator.QueueRawValue(sample.Value) && + !TryFlushCalculator(calculator, values, partial: false)) { - FlushCalculator(calculator, values, partial: false); - if (values.Count > kMaxProcessedBufferedOutputs) - { - return StatusCodes.BadTooManyOperations; - } + return StatusCodes.BadTooManyOperations; } } @@ -562,8 +563,7 @@ namespace Opc.Ua.Server.Historian token2 = page.NextToken; } - FlushCalculator(calculator, values, partial: true); - if (values.Count > kMaxProcessedBufferedOutputs) + if (!TryFlushCalculator(calculator, values, partial: true)) { return StatusCodes.BadTooManyOperations; } @@ -2018,12 +2018,22 @@ namespace Opc.Ua.Server.Historian _ = nodeId; } - private static void FlushCalculator(IAggregateCalculator calculator, List output, bool partial) + private static bool TryFlushCalculator( + IAggregateCalculator calculator, + List output, + bool partial) { while (calculator.TryGetProcessedValue(partial, out DataValue computed)) { + if (output.Count >= kMaxProcessedBufferedOutputs) + { + return false; + } + output.Add(computed); } + + return true; } private static async ValueTask> CollectAllRawAsync( diff --git a/tests/Opc.Ua.Gds.Tests/Onboarding/DeviceRegistrarAdminExtensionsTests.cs b/tests/Opc.Ua.Gds.Tests/Onboarding/DeviceRegistrarAdminExtensionsTests.cs index 5cb6c72043..eb9e634c86 100644 --- a/tests/Opc.Ua.Gds.Tests/Onboarding/DeviceRegistrarAdminExtensionsTests.cs +++ b/tests/Opc.Ua.Gds.Tests/Onboarding/DeviceRegistrarAdminExtensionsTests.cs @@ -1,5 +1,5 @@ /* ======================================================================== - * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * @@ -29,150 +29,281 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; +using Moq; using NUnit.Framework; using Opc.Ua.Gds.Server.Onboarding; - -#nullable enable -#pragma warning disable CA2000 +using Opc.Ua.Onboarding; +using Opc.Ua.Tests; namespace Opc.Ua.Gds.Tests.Onboarding { /// - /// Tests for — - /// wires the RegisterTickets / UnregisterTickets methods of a - /// DeviceRegistrarAdminType instance through an - /// . + /// Tests for . /// [TestFixture] [Category("DI")] [Category("Onboarding")] public sealed class DeviceRegistrarAdminExtensionsTests { - private const ushort kNs = 2; - - private static (BaseObjectState Registrar, MethodState Register, MethodState Unregister) - CreateRegistrar() - { - var registrar = new BaseObjectState(parent: null) - { - NodeId = new NodeId("Registrar", kNs), - BrowseName = new QualifiedName("DeviceRegistrar", kNs), - DisplayName = new LocalizedText("DeviceRegistrar") - }; - var register = new MethodState(registrar) - { - NodeId = new NodeId("Register", kNs), - BrowseName = new QualifiedName("RegisterTickets", kNs), - DisplayName = new LocalizedText("RegisterTickets") - }; - var unregister = new MethodState(registrar) - { - NodeId = new NodeId("Unregister", kNs), - BrowseName = new QualifiedName("UnregisterTickets", kNs), - DisplayName = new LocalizedText("UnregisterTickets") - }; - registrar.AddChild(register); - registrar.AddChild(unregister); - return (registrar, register, unregister); - } - [Test] public void BindRejectsNodeMissingMethods() { var bare = new BaseObjectState(parent: null) { - NodeId = new NodeId("Bare", kNs), - BrowseName = new QualifiedName("Bare", kNs) + NodeId = new NodeId("Bare", 2), + BrowseName = new QualifiedName("Bare", 2) }; - var store = new MemoryTicketStore(); Assert.Throws( - () => bare.BindToTicketStore(store)); + () => bare.BindToTicketStore(new MemoryTicketStore())); } [Test] public void BindRejectsNullArgs() { - (BaseObjectState reg, _, _) = CreateRegistrar(); + (_, DeviceRegistrarAdminState registrar) = CreateRegistrar(); Assert.Throws( - () => reg.BindToTicketStore(null!)); + () => registrar.BindToTicketStore(null!)); BaseObjectState? nullState = null; Assert.Throws( () => nullState!.BindToTicketStore(new MemoryTicketStore())); } [Test] - public void RegisterTicketsCallStoresTickets() + public async Task RegisterTicketsStoresTicketsAndReplacesSeededOutput() { - (BaseObjectState reg, MethodState register, _) = CreateRegistrar(); + (SystemContext context, DeviceRegistrarAdminState registrar) = CreateRegistrar(); var store = new MemoryTicketStore(); - reg.BindToTicketStore(store); - - byte[][] tickets = + registrar.BindToTicketStore(store); + ArrayOf tickets = [ - [1, 2, 3], - [4, 5, 6] + new ByteString(new byte[] { 1, 2, 3 }), + new ByteString(new byte[] { 4, 5, 6 }) ]; - ByteString[] ticketsBs = Array.ConvertAll(tickets, b => new ByteString(b)); - ArrayOf inputs = new[] { new Variant(ticketsBs.ToArrayOf()) }.ToArrayOf(); - List outputs = []; - ServiceResult result = register.OnCallMethod2!( - context: new SystemContext(telemetry: null!), - method: register, - objectId: reg.NodeId, - inputArguments: inputs, - outputArguments: outputs); + (ServiceResult result, List argumentErrors, List outputs) = + await CallAsync( + registrar.RegisterTickets!, + context, + registrar.NodeId, + Variant.From(tickets)) + .ConfigureAwait(false); - Assert.That(ServiceResult.IsGood(result)); + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(argumentErrors, Has.All.Matches(ServiceResult.IsGood)); Assert.That(outputs, Has.Count.EqualTo(1)); - int[] statuses = ((ArrayOf)outputs[0].AsBoxedObject()!).ToArray()!; - Assert.That(statuses, Has.Length.EqualTo(2)); - Assert.That(statuses[0], Is.EqualTo((int)(uint)StatusCodes.Good)); - Assert.That(statuses[1], Is.EqualTo((int)(uint)StatusCodes.Good)); + Assert.That( + outputs[0].TryGetValue(out ArrayOf statuses), + Is.True); + Assert.That( + statuses, + Is.EqualTo(new StatusCode[] + { + StatusCodes.Good, + StatusCodes.Good + }.ToArrayOf())); + + var stored = new List(); + await foreach (TicketRecord ticket in store.ListAsync().ConfigureAwait(false)) + { + stored.Add(ticket); + } + Assert.That(stored, Has.Count.EqualTo(2)); } [Test] public async Task UnregisterTicketsRemovesPreviouslyStored() { - (BaseObjectState reg, MethodState register, MethodState unregister) = CreateRegistrar(); + (SystemContext context, DeviceRegistrarAdminState registrar) = CreateRegistrar(); var store = new MemoryTicketStore(); - reg.BindToTicketStore(store); + registrar.BindToTicketStore(store); + ArrayOf tickets = + [ + new ByteString(new byte[] { 0xAA }) + ]; - byte[] ticket = [0xAA]; - ByteString[] ticketsBs = [new ByteString(ticket)]; - ArrayOf registerInputs = new[] - { new Variant(ticketsBs.ToArrayOf()) }.ToArrayOf(); - List registerOutputs = []; - register.OnCallMethod2!( - new SystemContext(telemetry: null!), register, reg.NodeId, - registerInputs, registerOutputs); + _ = await CallAsync( + registrar.RegisterTickets!, + context, + registrar.NodeId, + Variant.From(tickets)) + .ConfigureAwait(false); + (_, _, List outputs) = await CallAsync( + registrar.UnregisterTickets!, + context, + registrar.NodeId, + Variant.From(tickets)) + .ConfigureAwait(false); + Assert.That(outputs, Has.Count.EqualTo(1)); + Assert.That( + outputs[0].TryGetValue(out ArrayOf statuses), + Is.True); + Assert.That( + statuses, + Is.EqualTo(new StatusCode[] { StatusCodes.Good }.ToArrayOf())); + Assert.That( + await store.ListAsync().CountAsync().ConfigureAwait(false), + Is.Zero); + } + + [Test] + public async Task RegisterTicketsReportsPerTicketStoreFailure() + { + (SystemContext context, DeviceRegistrarAdminState registrar) = CreateRegistrar(); + var store = new Mock(MockBehavior.Strict); + store + .Setup(s => s.AddAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Throws(new ServiceResultException(StatusCodes.BadEntryExists)); + registrar.BindToTicketStore(store.Object); + + (_, _, List outputs) = await CallAsync( + registrar.RegisterTickets!, + context, + registrar.NodeId, + Variant.From( + new ByteString[] + { + new(new byte[] { 1 }) + }.ToArrayOf())) + .ConfigureAwait(false); + + Assert.That( + outputs[0].TryGetValue(out ArrayOf statuses), + Is.True); + Assert.That( + statuses, + Is.EqualTo(new StatusCode[] { StatusCodes.BadEntryExists }.ToArrayOf())); + } + + [Test] + public async Task RegisterTicketsRejectsNonByteStringWireType() + { + (SystemContext context, DeviceRegistrarAdminState registrar) = CreateRegistrar(); + registrar.BindToTicketStore(new MemoryTicketStore()); + ArrayOf invalidTickets = ["not-a-ticket"]; + + (ServiceResult result, List argumentErrors, List outputs) = + await CallAsync( + registrar.RegisterTickets!, + context, + registrar.NodeId, + Variant.From(invalidTickets)) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(argumentErrors, Has.Count.EqualTo(1)); + Assert.That( + argumentErrors[0].StatusCode, + Is.EqualTo(StatusCodes.BadTypeMismatch)); + Assert.That(outputs, Is.Empty); + } + + [Test] + public void RegisterTicketsPropagatesCancellationToTicketStore() + { + (_, DeviceRegistrarAdminState registrar) = CreateRegistrar(); + var store = new Mock(MockBehavior.Strict); + store + .Setup(s => s.AddAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (_, _, _, ct) => + { + ct.ThrowIfCancellationRequested(); + return default; + }); + registrar.BindToTicketStore(store.Object); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + List outputs = [Variant.Null]; + + Assert.ThrowsAsync( + async () => await registrar.RegisterTickets!.OnCallMethod2Async!( + new SystemContext(NUnitTelemetryContext.Create()), + registrar.RegisterTickets, + registrar.NodeId, + [Variant.From( + new ByteString[] + { + new(new byte[] { 1 }) + }.ToArrayOf())], + outputs, + cts.Token).ConfigureAwait(false)); + } + + private static (SystemContext Context, DeviceRegistrarAdminState Registrar) + CreateRegistrar() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + ServiceMessageContext messageContext = ServiceMessageContext.Create(telemetry); + messageContext.NamespaceUris.GetIndexOrAppend( + Opc.Ua.Gds.Namespaces.OpcUaGds); + messageContext.NamespaceUris.GetIndexOrAppend( + Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding); + var typeTable = new TypeTable(messageContext.NamespaceUris); + typeTable.AddSubtype(Opc.Ua.DataTypeIds.ByteString, NodeId.Null); + typeTable.AddSubtype( + Opc.Ua.DataTypeIds.EncodedTicket, + Opc.Ua.DataTypeIds.ByteString); + var context = new SystemContext(telemetry) + { + NamespaceUris = messageContext.NamespaceUris, + ServerUris = messageContext.ServerUris, + EncodeableFactory = messageContext.Factory, + TypeTable = typeTable + }; + NodeStateCollection nodes = new NodeStateCollection().AddOpcUaOnboarding(context); + DeviceRegistrarState root = nodes.OfType().Single(); + DeviceRegistrarAdminState registrar = root.Administration ?? + throw new InvalidOperationException( + "The generated DeviceRegistrar has no Administration child."); + return (context, registrar); + } + + private static async ValueTask<( + ServiceResult Result, + List ArgumentErrors, + List Outputs)> CallAsync( + MethodState method, + ISystemContext context, + NodeId objectId, + Variant input) + { + var argumentErrors = new List(); + var outputs = new List(); + ServiceResult result = await method.CallAsync( + context, + objectId, + [input], + argumentErrors, + outputs) + .ConfigureAwait(false); + return (result, argumentErrors, outputs); + } + } + + internal static class AsyncEnumerableTestExtensions + { + public static async ValueTask CountAsync( + this IAsyncEnumerable values) + { int count = 0; - await foreach (TicketRecord _ in store.ListAsync().ConfigureAwait(false)) + await foreach (T _ in values.ConfigureAwait(false)) { count++; } - Assert.That(count, Is.EqualTo(1)); - - ArrayOf unregisterInputs = new[] - { new Variant(ticketsBs.ToArrayOf()) }.ToArrayOf(); - List unregisterOutputs = []; - unregister.OnCallMethod2!( - new SystemContext(telemetry: null!), unregister, reg.NodeId, - unregisterInputs, unregisterOutputs); - - int[] statuses = ((ArrayOf)unregisterOutputs[0].AsBoxedObject()!).ToArray()!; - Assert.That(statuses, Has.Length.EqualTo(1)); - Assert.That(statuses[0], Is.EqualTo((int)(uint)StatusCodes.Good)); - - count = 0; - await foreach (TicketRecord _ in store.ListAsync().ConfigureAwait(false)) - { - count++; - } - Assert.That(count, Is.Zero); + return count; } } } diff --git a/tests/Opc.Ua.Gds.Tests/Onboarding/OnboardingClientTests.cs b/tests/Opc.Ua.Gds.Tests/Onboarding/OnboardingClientTests.cs index f7d418d6b2..4cf4d3c138 100644 --- a/tests/Opc.Ua.Gds.Tests/Onboarding/OnboardingClientTests.cs +++ b/tests/Opc.Ua.Gds.Tests/Onboarding/OnboardingClientTests.cs @@ -1,5 +1,5 @@ /* ======================================================================== - * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * @@ -28,14 +28,17 @@ * ======================================================================*/ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Moq; using NUnit.Framework; using Opc.Ua.Client; using Opc.Ua.Gds.Client; - -#nullable enable +using Opc.Ua.Gds.Server.Onboarding; +using Opc.Ua.Onboarding; +using Opc.Ua.Tests; namespace Opc.Ua.Gds.Tests.Onboarding { @@ -49,182 +52,465 @@ namespace Opc.Ua.Gds.Tests.Onboarding { private static readonly NodeId kRegistrarId = new("Reg", 2); - private static Mock CreateSessionMock() - { - var mock = new Mock(); - var nsTable = new NamespaceTable(); - // Seed with the GDS namespace (which carries the Onboarding - // ObjectTypes after the model relocation). OnboardingClient - // resolves method NodeIds via browse-path translation, not - // by namespace-constant lookup, so this is only here to - // give the mocked session a non-empty namespace table. - nsTable.GetIndexOrAppend(Namespaces.OpcUaGds); - mock.SetupGet(s => s.NamespaceUris).Returns(nsTable); - return mock; - } - - private static ITelemetryContext NullTelemetry() - { - return new Mock().Object; - } - - private static void SetupTranslateToMethod( - Mock sessionMock, NodeId methodId) - { - sessionMock - .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .ReturnsAsync(new TranslateBrowsePathsToNodeIdsResponse - { - Results = new BrowsePathResult[] - { - new() { - StatusCode = StatusCodes.Good, - Targets = new BrowsePathTarget[] - { - new() { - TargetId = new ExpandedNodeId(methodId) - } - }.ToArrayOf() - } - }.ToArrayOf() - }); - } - - private static void SetupCallReturnsStatuses( - Mock sessionMock, int[] statuses) - { - sessionMock - .Setup(s => s.CallAsync( - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .ReturnsAsync(new CallResponse - { - Results = new CallMethodResult[] - { - new() { - StatusCode = StatusCodes.Good, - OutputArguments = new Variant[] - { - new(statuses.ToArrayOf()) - }.ToArrayOf() - } - }.ToArrayOf() - }); - } - [Test] public void ConstructorRejectsNullSession() { Assert.Throws( - () => new OnboardingClient(null!, kRegistrarId, NullTelemetry())); + () => new OnboardingClient(null!, kRegistrarId, CreateTelemetry())); } [Test] public void ConstructorRejectsNullRegistrarId() { - Mock s = CreateSessionMock(); + Mock session = CreateSessionMock(out _); Assert.Throws( - () => new OnboardingClient(s.Object, NodeId.Null, NullTelemetry())); + () => new OnboardingClient(session.Object, NodeId.Null, CreateTelemetry())); } [Test] public void ConstructorRejectsNullTelemetry() { - Mock s = CreateSessionMock(); + Mock session = CreateSessionMock(out _); Assert.Throws( - () => new OnboardingClient(s.Object, kRegistrarId, null!)); + () => new OnboardingClient(session.Object, kRegistrarId, null!)); } [Test] - public async Task RegisterTicketsReturnsStatusArray() + public async Task RegisterTicketsUsesGeneratedTypeMethodAndNativeTypes() { - Mock session = CreateSessionMock(); - SetupTranslateToMethod(session, new NodeId("Reg_Register", 2)); - SetupCallReturnsStatuses(session, + Mock session = CreateSessionMock(out IServiceMessageContext messageContext); + CallMethodRequest? observedRequest = null; + ArrayOf expected = [ - (int)(uint)StatusCodes.Good, - (int)(uint)StatusCodes.BadEntryExists - ]); - + StatusCodes.Good, + StatusCodes.BadEntryExists + ]; + SetupCall(session, request => + { + observedRequest = request; + return Good(Variant.From(expected)); + }); var client = new OnboardingClient( - session.Object, kRegistrarId, NullTelemetry()); - - int[] statuses = await client.RegisterTicketsAsync( + session.Object, + kRegistrarId, + CreateTelemetry()); + ArrayOf tickets = [ - [1, 2], - [3, 4] - ]).ConfigureAwait(false); + new ByteString(new byte[] { 1, 2 }), + new ByteString(new byte[] { 3, 4 }) + ]; - Assert.That(statuses, Has.Length.EqualTo(2)); - Assert.That(statuses[0], Is.EqualTo((int)(uint)StatusCodes.Good)); - Assert.That(statuses[1], Is.EqualTo((int)(uint)StatusCodes.BadEntryExists)); + ArrayOf statuses = await client + .RegisterTicketsAsync(tickets) + .ConfigureAwait(false); + + Assert.That(statuses, Is.EqualTo(expected)); + Assert.That(observedRequest, Is.Not.Null); + NodeId expectedMethodId = ExpandedNodeId.ToNodeId( + Opc.Ua.Onboarding.MethodIds.DeviceRegistrarAdminType_RegisterTickets, + messageContext.NamespaceUris); + Assert.That(observedRequest!.MethodId, Is.EqualTo(expectedMethodId)); + Assert.That(observedRequest.ObjectId, Is.EqualTo(kRegistrarId)); + Assert.That( + observedRequest.InputArguments[0].TryGetValue( + out ArrayOf observedTickets), + Is.True); + Assert.That(observedTickets, Is.EqualTo(tickets)); + session.Verify(s => s.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), Times.Never); } [Test] - public async Task UnregisterTicketsReturnsStatusArray() + public async Task RegisterTicketsFallbackUsesOnboardingBrowseNameNamespace() { - Mock session = CreateSessionMock(); - SetupTranslateToMethod(session, new NodeId("Reg_Unregister", 2)); - SetupCallReturnsStatuses(session, - [ - (int)(uint)StatusCodes.Good - ]); - + Mock session = CreateSessionMock(out IServiceMessageContext messageContext); var client = new OnboardingClient( - session.Object, kRegistrarId, NullTelemetry()); - - int[] statuses = await client.UnregisterTicketsAsync( - [ - "\t"u8.ToArray() - ]).ConfigureAwait(false); - - Assert.That(statuses, Has.Length.EqualTo(1)); - Assert.That(statuses[0], Is.EqualTo((int)(uint)StatusCodes.Good)); - } - - [Test] - public void RegisterTicketsThrowsOnUnresolvedMethod() - { - Mock session = CreateSessionMock(); + session.Object, + kRegistrarId, + CreateTelemetry()); + NodeId typeMethodId = ExpandedNodeId.ToNodeId( + Opc.Ua.Onboarding.MethodIds.DeviceRegistrarAdminType_RegisterTickets, + messageContext.NamespaceUris); + var instanceMethodId = new NodeId("Reg_Register", kRegistrarId.NamespaceIndex); + var calls = new List(); + BrowsePath? observedPath = null; + SetupCall(session, request => + { + calls.Add(request.MethodId); + return request.MethodId.Equals(typeMethodId) + ? BadMethodInvalid() + : Good(Variant.From(new StatusCode[] { StatusCodes.Good }.ToArrayOf())); + }); session .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( - It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) - .ReturnsAsync(new TranslateBrowsePathsToNodeIdsResponse - { - Results = new BrowsePathResult[] + .Returns, CancellationToken>( + (_, paths, _) => { - new() { - StatusCode = StatusCodes.BadNotFound, - Targets = [] - } - }.ToArrayOf() - }); + observedPath = paths[0]; + return new ValueTask( + new TranslateBrowsePathsToNodeIdsResponse + { + ResponseHeader = new ResponseHeader(), + Results = + [ + new BrowsePathResult + { + StatusCode = StatusCodes.Good, + Targets = + [ + new BrowsePathTarget + { + TargetId = new ExpandedNodeId(instanceMethodId), + RemainingPathIndex = uint.MaxValue + } + ] + } + ] + }); + }); + ArrayOf statuses = await client + .RegisterTicketsAsync([new ByteString(new byte[] { 1 })]) + .ConfigureAwait(false); - var client = new OnboardingClient( - session.Object, kRegistrarId, NullTelemetry()); - - Assert.ThrowsAsync( - async () => await client.RegisterTicketsAsync( - [ - [1] - ]).ConfigureAwait(false)); + Assert.That( + statuses, + Is.EqualTo(new StatusCode[] { StatusCodes.Good }.ToArrayOf())); + Assert.That(calls, Is.EqualTo(new[] { typeMethodId, instanceMethodId })); + Assert.That(observedPath, Is.Not.Null); + RelativePathElement element = observedPath!.RelativePath.Elements[0]; + Assert.That( + element.TargetName.Name, + Is.EqualTo(Opc.Ua.Onboarding.BrowseNames.RegisterTickets)); + Assert.That( + element.TargetName.NamespaceIndex, + Is.EqualTo((ushort)messageContext.NamespaceUris.GetIndex( + Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding))); } [Test] - public void RegisterTicketsRejectsNullArray() + public async Task UnregisterTicketsReturnsStatusCodes() { - Mock session = CreateSessionMock(); + Mock session = CreateSessionMock(out _); + SetupCall( + session, + _ => Good(Variant.From( + new StatusCode[] { StatusCodes.BadNotFound }.ToArrayOf()))); var client = new OnboardingClient( - session.Object, kRegistrarId, NullTelemetry()); + session.Object, + kRegistrarId, + CreateTelemetry()); - Assert.ThrowsAsync( - async () => await client.RegisterTicketsAsync(null!).ConfigureAwait(false)); + ArrayOf statuses = await client + .UnregisterTicketsAsync([new ByteString(new byte[] { 0xAA })]) + .ConfigureAwait(false); + + Assert.That( + statuses, + Is.EqualTo(new StatusCode[] { StatusCodes.BadNotFound }.ToArrayOf())); + } + + private static Mock CreateSessionMock( + out IServiceMessageContext messageContext) + { + ITelemetryContext telemetry = CreateTelemetry(); + ServiceMessageContext context = ServiceMessageContext.Create(telemetry); + var namespaceUris = new NamespaceTable(); + namespaceUris.GetIndexOrAppend( + Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding); + messageContext = context; + + var mock = new Mock(MockBehavior.Strict); + mock.SetupGet(s => s.MessageContext).Returns(context); + mock.SetupGet(s => s.NamespaceUris).Returns(namespaceUris); + return mock; + } + + private static ITelemetryContext CreateTelemetry() + { + return NUnitTelemetryContext.Create(); + } + + private static void SetupCall( + Mock session, + Func handler) + { + session + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, requests, _) => + new ValueTask(new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = [handler(requests[0])] + })); + } + + private static CallMethodResult Good(params Variant[] outputs) + { + return new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = outputs + }; + } + + private static CallMethodResult BadMethodInvalid() + { + return new CallMethodResult + { + StatusCode = StatusCodes.BadMethodInvalid + }; + } + } + + /// + /// Exercises the generated Part 21 model, client proxy fallback, method + /// validation and ticket-store bindings as one in-process call chain. + /// + [TestFixture] + [Category("Onboarding")] + public sealed class OnboardingEndToEndTests + { + [Test] + public async Task GeneratedRegistrarAndClientRoundTripTickets() + { + (SystemContext context, DeviceRegistrarAdminState registrar) = + CreateGeneratedRegistrar(); + var store = new MemoryTicketStore(); + registrar.BindToTicketStore(store); + Mock session = CreateSessionBridge(context, registrar); + var client = new OnboardingClient( + session.Object, + registrar.NodeId, + context.Telemetry); + ArrayOf tickets = + [ + new ByteString("ticket-one"u8.ToArray()), + new ByteString("ticket-two"u8.ToArray()) + ]; + + ArrayOf registered = await client + .RegisterTicketsAsync(tickets) + .ConfigureAwait(false); + ArrayOf removed = await client + .UnregisterTicketsAsync([tickets[0]]) + .ConfigureAwait(false); + ArrayOf removedAgain = await client + .UnregisterTicketsAsync([tickets[0]]) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That( + registered, + Is.EqualTo(new StatusCode[] + { + StatusCodes.Good, + StatusCodes.Good + }.ToArrayOf())); + Assert.That( + removed, + Is.EqualTo(new StatusCode[] { StatusCodes.Good }.ToArrayOf())); + Assert.That( + removedAgain, + Is.EqualTo(new StatusCode[] { StatusCodes.BadNotFound }.ToArrayOf())); + }); + + var remaining = new List(); + await foreach (TicketRecord record in store.ListAsync().ConfigureAwait(false)) + { + remaining.Add(record); + } + Assert.That(remaining, Has.Count.EqualTo(1)); + Assert.That( + remaining[0].EncodedTicket, + Is.EqualTo(tickets[1].ToArray())); + } + + private static (SystemContext Context, DeviceRegistrarAdminState Registrar) + CreateGeneratedRegistrar() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + ServiceMessageContext messageContext = ServiceMessageContext.Create(telemetry); + messageContext.NamespaceUris.GetIndexOrAppend( + Opc.Ua.Gds.Namespaces.OpcUaGds); + messageContext.NamespaceUris.GetIndexOrAppend( + Opc.Ua.Onboarding.Namespaces.OpcUaOnboarding); + var typeTable = new TypeTable(messageContext.NamespaceUris); + typeTable.AddSubtype(Opc.Ua.DataTypeIds.ByteString, NodeId.Null); + typeTable.AddSubtype( + Opc.Ua.DataTypeIds.EncodedTicket, + Opc.Ua.DataTypeIds.ByteString); + var context = new SystemContext(telemetry) + { + NamespaceUris = messageContext.NamespaceUris, + ServerUris = messageContext.ServerUris, + EncodeableFactory = messageContext.Factory, + TypeTable = typeTable + }; + NodeStateCollection nodes = new NodeStateCollection() + .AddOpcUaGds(context) + .AddOpcUaOnboarding(context); + Assert.That( + nodes.Any(node => node.NodeId.Equals(ExpandedNodeId.ToNodeId( + Opc.Ua.Gds.DataTypeIds.ApplicationRecordDataType, + context.NamespaceUris))), + Is.True, + "The Onboarding model's GDS datatype dependency must be loaded."); + DeviceRegistrarState root = nodes.OfType().Single(); + return (context, root.Administration ?? + throw new InvalidOperationException( + "The generated DeviceRegistrar has no Administration child.")); + } + + private static Mock CreateSessionBridge( + SystemContext context, + DeviceRegistrarAdminState registrar) + { + var session = new Mock(MockBehavior.Strict); + ServiceMessageContext messageContext = ServiceMessageContext.Create(context.Telemetry); + messageContext.NamespaceUris = context.NamespaceUris; + messageContext.ServerUris = context.ServerUris; + session.SetupGet(s => s.MessageContext).Returns(messageContext); + session.SetupGet(s => s.NamespaceUris).Returns(context.NamespaceUris); + session + .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, paths, _) => + new ValueTask( + ResolvePaths(context, registrar, paths))); + session + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, requests, ct) => InvokeMethodsAsync( + context, + registrar, + requests, + ct)); + return session; + } + + private static TranslateBrowsePathsToNodeIdsResponse ResolvePaths( + ISystemContext context, + DeviceRegistrarAdminState registrar, + ArrayOf paths) + { + var results = new BrowsePathResult[paths.Count]; + for (int i = 0; i < paths.Count; i++) + { + BrowsePath path = paths[i]; + NodeState? current = path.StartingNode.Equals(registrar.NodeId) + ? registrar + : null; + foreach (RelativePathElement element in path.RelativePath.Elements) + { + current = current?.FindChild(context, element.TargetName); + if (current == null) + { + break; + } + } + results[i] = current == null + ? new BrowsePathResult + { + StatusCode = StatusCodes.BadNoMatch, + Targets = [] + } + : new BrowsePathResult + { + StatusCode = StatusCodes.Good, + Targets = + [ + new BrowsePathTarget + { + TargetId = new ExpandedNodeId(current.NodeId), + RemainingPathIndex = uint.MaxValue + } + ] + }; + } + return new TranslateBrowsePathsToNodeIdsResponse + { + ResponseHeader = new ResponseHeader(), + Results = results + }; + } + + private static async ValueTask InvokeMethodsAsync( + ISystemContext context, + DeviceRegistrarAdminState registrar, + ArrayOf requests, + CancellationToken cancellationToken) + { + var results = new CallMethodResult[requests.Count]; + for (int i = 0; i < requests.Count; i++) + { + CallMethodRequest request = requests[i]; + MethodState? method = FindInstanceMethod(registrar, request.MethodId); + if (method == null) + { + results[i] = new CallMethodResult + { + StatusCode = StatusCodes.BadMethodInvalid, + InputArgumentResults = [], + OutputArguments = [] + }; + continue; + } + + var argumentErrors = new List(); + var outputs = new List(); + ServiceResult result = await method.CallAsync( + context, + request.ObjectId, + request.InputArguments, + argumentErrors, + outputs, + cancellationToken) + .ConfigureAwait(false); + results[i] = new CallMethodResult + { + StatusCode = result.StatusCode, + InputArgumentResults = argumentErrors + .Select(error => error.StatusCode) + .ToArrayOf(), + OutputArguments = outputs.ToArrayOf() + }; + } + return new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = results + }; + } + + private static MethodState? FindInstanceMethod( + DeviceRegistrarAdminState registrar, + NodeId methodId) + { + if (registrar.RegisterTickets?.NodeId.Equals(methodId) == true) + { + return registrar.RegisterTickets; + } + if (registrar.UnregisterTickets?.NodeId.Equals(methodId) == true) + { + return registrar.UnregisterTickets; + } + return null; } } } diff --git a/tests/Opc.Ua.History.Tests/AggregateBaseTests.cs b/tests/Opc.Ua.History.Tests/AggregateBaseTests.cs index 8112d254f6..c96a4c255a 100644 --- a/tests/Opc.Ua.History.Tests/AggregateBaseTests.cs +++ b/tests/Opc.Ua.History.Tests/AggregateBaseTests.cs @@ -440,13 +440,17 @@ namespace Opc.Ua.History.Tests DateTime endTime = DateTime.UtcNow; DateTime startTime = endTime.AddMinutes(-10); - await AssertProcessedReadFailsAsync( + HistoryReadResponse response = await ExecuteHistoryReadProcessedAsync( nodeId, startTime, endTime, aggregateId: ObjectIds.AggregateFunction_Average, - processingInterval: -1, - allowEmptyResults: false).ConfigureAwait(false); + processingInterval: -1).ConfigureAwait(false); + + Assert.That(response.Results.Count, Is.EqualTo(1)); + Assert.That( + response.Results[0].StatusCode, + Is.EqualTo(StatusCodes.BadInvalidArgument)); } [Description("AggregateType list is empty; expect a Bad operation status or service-level rejection.")] diff --git a/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs b/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs index 9cfaedc2ba..0e6abe3967 100644 --- a/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs +++ b/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs @@ -544,6 +544,67 @@ namespace Opc.Ua.Server.Tests.Historian Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); } + [TestCase(-1d)] + [TestCase(double.NaN)] + [TestCase(double.NegativeInfinity)] + [TestCase(double.PositiveInfinity)] + public async Task DispatchProcessedReadWithInvalidProcessingIntervalReturnsBadInvalidArgumentAsync( + double processingInterval) + { + HarnessFixture h = CreateHarness(); + var nodeId = new NodeId($"negative-interval-{Guid.NewGuid():N}", 1); + BaseDataVariableState node = CreateVariable(nodeId); + var provider = new Mock(); + Mock processedProvider = + provider.As(); + processedProvider + .Setup(p => p.ReadProcessedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask>(HistorianPage.Empty)); + + var details = new ReadProcessedDetails + { + StartTime = HarnessFixture.BaseTime, + EndTime = HarnessFixture.BaseTime.AddMinutes(1), + ProcessingInterval = processingInterval, + AggregateConfiguration = new AggregateConfiguration + { + PercentDataBad = 100, + PercentDataGood = 100 + } + }; + var nodeToRead = new HistoryReadValueId + { + NodeId = nodeId, + ContinuationPoint = ByteString.Empty + }; + + var result = new HistoryReadResult(); + ServiceResult error = await HistorianDispatcher.DispatchProcessedReadAsync( + h.SystemContext, + provider.Object, + node, + nodeToRead, + details, + ObjectIds.AggregateFunction_Average, + TimestampsToReturn.Source, + result, + CancellationToken.None).ConfigureAwait(false); + + Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + processedProvider.Verify( + p => p.ReadProcessedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + [Test] public async Task DispatchProcessedReadWithReverseTimeRangeIsNotRejectedAsInvalidArgumentAsync() { diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/DesignFileTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/DesignFileTests.cs index c90b6f4e16..54fdd0cd93 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/DesignFileTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/DesignFileTests.cs @@ -128,10 +128,11 @@ namespace Opc.Ua.SourceGeneration.Api.Tests } /// - /// Tests that Group returns single group when multiple design files are in the same directory. + /// Tests that Group returns one generation unit per design file when + /// multiple targets share a directory. /// [Test] - public void Group_MultipleFilesInSameDirectory_ReturnsSingleGroup() + public void GroupMultipleFilesInSameDirectoryReturnsOneGroupPerTarget() { // Arrange string dir = Path.Combine("C:", "TestDir"); @@ -155,12 +156,16 @@ namespace Opc.Ua.SourceGeneration.Api.Tests var result = collection.Group().ToList(); // Assert Assert.That(result, Is.Not.Null); - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].Targets, Is.Not.Null); - Assert.That(result[0].Targets, Has.Count.EqualTo(3)); - Assert.That(result[0].Targets, Is.EquivalentTo(designFiles)); - Assert.That(result[0].IdentifierFilePath, Is.EqualTo("fallback.csv")); - Assert.That(result[0].Options, Is.EqualTo(options)); + Assert.That(result, Has.Count.EqualTo(3)); + Assert.That(result.SelectMany(group => group.Targets), Is.EquivalentTo(designFiles)); + foreach (DesignFileCollection group in result) + { + Assert.That(group.Targets, Has.Count.EqualTo(1)); + Assert.That(group.Dependencies, Has.Count.EqualTo(2)); + Assert.That(group.Dependencies, Does.Not.Contain(group.Targets[0])); + Assert.That(group.IdentifierFilePath, Is.EqualTo("fallback.csv")); + Assert.That(group.Options, Is.EqualTo(options)); + } } /// @@ -256,10 +261,11 @@ namespace Opc.Ua.SourceGeneration.Api.Tests } /// - /// Tests that Group uses identifier file from matching directory when provided. + /// Tests that Group assigns an identifier file by matching the target + /// base name, even when several models share a directory. /// [Test] - public void Group_IdentifierFileInMatchingDirectory_UsesIdentifierFile() + public void GroupBasenameIdentifierFilesAssignsEachTarget() { // Arrange string dir = Path.Combine("C:", "TestDir"); @@ -270,7 +276,8 @@ namespace Opc.Ua.SourceGeneration.Api.Tests }; var identifierFiles = new List { - Path.Combine(dir, "identifiers.csv") + Path.Combine(dir, "Design1.csv"), + Path.Combine(dir, "Design2.CSV") }; var collection = new DesignFileCollection { @@ -282,26 +289,88 @@ namespace Opc.Ua.SourceGeneration.Api.Tests var result = collection.Group(identifierFiles).ToList(); // Assert Assert.That(result, Is.Not.Null); - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].IdentifierFilePath, Is.EqualTo(Path.Combine(dir, "identifiers.csv"))); + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That( + result.Single(group => group.Targets[0].EndsWith("Design1.xml", StringComparison.Ordinal)) + .IdentifierFilePath, + Is.EqualTo(Path.Combine(dir, "Design1.csv"))); + Assert.That( + result.Single(group => group.Targets[0].EndsWith("Design2.xml", StringComparison.Ordinal)) + .IdentifierFilePath, + Is.EqualTo(Path.Combine(dir, "Design2.CSV"))); } /// - /// Tests that Group uses first identifier file when multiple identifier files exist in same directory. + /// Tests that the legacy single-model case continues to use the only + /// adjacent identifier file even when its basename differs. /// [Test] - public void Group_MultipleIdentifierFilesInSameDirectory_UsesFirstIdentifierFile() + public void GroupSingleTargetWithNonBasenameIdentifierUsesAdjacentFile() + { + string dir = Path.Combine("C:", "TestDir"); + string target = Path.Combine(dir, "Design.xml"); + string identifierFile = Path.Combine(dir, "LegacyIdentifiers.csv"); + var collection = new DesignFileCollection + { + Targets = [target], + IdentifierFilePath = "fallback.csv" + }; + + DesignFileCollection result = collection + .Group([identifierFile]) + .Single(); + + Assert.That(result.IdentifierFilePath, Is.EqualTo(identifierFile)); + } + + /// + /// Tests that each single-model directory preserves its legacy + /// non-basename identifier file in a multi-directory collection. + /// + [Test] + public void GroupMultipleSingleTargetDirectoriesUseAdjacentLegacyFiles() + { + string dir1 = Path.Combine("C:", "Dir1"); + string dir2 = Path.Combine("C:", "Dir2"); + string target1 = Path.Combine(dir1, "Model1.xml"); + string target2 = Path.Combine(dir2, "Model2.xml"); + string identifier1 = Path.Combine(dir1, "Identifiers.csv"); + string identifier2 = Path.Combine(dir2, "Identifiers.csv"); + var collection = new DesignFileCollection + { + Targets = [target1, target2], + IdentifierFilePath = "fallback.csv" + }; + + List result = collection + .Group([identifier1, identifier2]) + .ToList(); + + Assert.That( + result.Single(group => group.Targets[0] == target1).IdentifierFilePath, + Is.EqualTo(identifier1)); + Assert.That( + result.Single(group => group.Targets[0] == target2).IdentifierFilePath, + Is.EqualTo(identifier2)); + } + + /// + /// Tests that unrelated identifier files in the same directory are + /// ignored in favour of the target's basename match. + /// + [Test] + public void GroupMultipleIdentifierFilesInSameDirectoryUsesBasenameMatch() { // Arrange string dir = Path.Combine("C:", "TestDir"); var designFiles = new List { - Path.Combine(dir, "Design1.xml") + Path.Combine(dir, "Design2.xml") }; var identifierFiles = new List { Path.Combine(dir, "identifiers1.csv"), - Path.Combine(dir, "identifiers2.csv"), + Path.Combine(dir, "Design2.csv"), Path.Combine(dir, "identifiers3.csv") }; var collection = new DesignFileCollection @@ -315,7 +384,7 @@ namespace Opc.Ua.SourceGeneration.Api.Tests // Assert Assert.That(result, Is.Not.Null); Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].IdentifierFilePath, Is.EqualTo(Path.Combine(dir, "identifiers1.csv"))); + Assert.That(result[0].IdentifierFilePath, Is.EqualTo(Path.Combine(dir, "Design2.csv"))); } /// @@ -367,8 +436,8 @@ namespace Opc.Ua.SourceGeneration.Api.Tests }; var identifierFiles = new List { - Path.Combine(dir1, "id1.csv"), - Path.Combine(dir3, "id3.csv") + Path.Combine(dir1, "Design1.csv"), + Path.Combine(dir3, "Design3.csv") }; var collection = new DesignFileCollection { @@ -383,13 +452,13 @@ namespace Opc.Ua.SourceGeneration.Api.Tests Assert.That(result, Has.Count.EqualTo(3)); DesignFileCollection group1 = result.FirstOrDefault(g => g.Targets.Contains(Path.Combine(dir1, "Design1.xml"))); Assert.That(group1, Is.Not.Null); - Assert.That(group1.IdentifierFilePath, Is.EqualTo(Path.Combine(dir1, "id1.csv"))); + Assert.That(group1.IdentifierFilePath, Is.EqualTo(Path.Combine(dir1, "Design1.csv"))); DesignFileCollection group2 = result.FirstOrDefault(g => g.Targets.Contains(Path.Combine(dir2, "Design2.xml"))); Assert.That(group2, Is.Not.Null); Assert.That(group2.IdentifierFilePath, Is.EqualTo("fallback.csv")); DesignFileCollection group3 = result.FirstOrDefault(g => g.Targets.Contains(Path.Combine(dir3, "Design3.xml"))); Assert.That(group3, Is.Not.Null); - Assert.That(group3.IdentifierFilePath, Is.EqualTo(Path.Combine(dir3, "id3.csv"))); + Assert.That(group3.IdentifierFilePath, Is.EqualTo(Path.Combine(dir3, "Design3.csv"))); } /// @@ -411,7 +480,7 @@ namespace Opc.Ua.SourceGeneration.Api.Tests }; var identifierFiles = new List { - Path.Combine(dir2, "identifiers_dir2.csv") + Path.Combine(dir2, "DesignC.csv") }; var options = new DesignFileOptions { @@ -429,16 +498,16 @@ namespace Opc.Ua.SourceGeneration.Api.Tests var result = collection.Group(identifierFiles).ToList(); // Assert Assert.That(result, Is.Not.Null); - Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result, Has.Count.EqualTo(5)); DesignFileCollection group1 = result.FirstOrDefault(g => g.Targets.Contains(Path.Combine(dir1, "DesignA.xml"))); Assert.That(group1, Is.Not.Null); - Assert.That(group1.Targets, Has.Count.EqualTo(2)); + Assert.That(group1.Targets, Has.Count.EqualTo(1)); Assert.That(group1.IdentifierFilePath, Is.EqualTo("global_fallback.csv")); Assert.That(group1.Options, Is.EqualTo(options)); DesignFileCollection group2 = result.FirstOrDefault(g => g.Targets.Contains(Path.Combine(dir2, "DesignC.xml"))); Assert.That(group2, Is.Not.Null); - Assert.That(group2.Targets, Has.Count.EqualTo(3)); - Assert.That(group2.IdentifierFilePath, Is.EqualTo(Path.Combine(dir2, "identifiers_dir2.csv"))); + Assert.That(group2.Targets, Has.Count.EqualTo(1)); + Assert.That(group2.IdentifierFilePath, Is.EqualTo(Path.Combine(dir2, "DesignC.csv"))); Assert.That(group2.Options, Is.EqualTo(options)); } @@ -498,7 +567,7 @@ namespace Opc.Ua.SourceGeneration.Api.Tests /// Tests that Group handles design files with relative paths correctly. /// [Test] - public void Group_DesignFilesWithRelativePaths_GroupsByDirectory() + public void GroupDesignFilesWithRelativePathsEmitsEachTarget() { // Arrange var designFiles = new List @@ -517,7 +586,7 @@ namespace Opc.Ua.SourceGeneration.Api.Tests var result = collection.Group().ToList(); // Assert Assert.That(result, Is.Not.Null); - Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result, Has.Count.EqualTo(3)); } /// diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/GenerateStackTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/GenerateStackTests.cs index 85c7d5fa2c..e133f873d2 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/GenerateStackTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/GenerateStackTests.cs @@ -75,6 +75,35 @@ namespace Opc.Ua.SourceGeneration.Api.Tests GenerateStack(generationType, telemetry, out _); } + [Test] + public void EncodedTicketUsesByteStringSupertype() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error); + Dictionary generatedText = GenerateStack( + StackGenerationType.Models, + telemetry, + out _); + string nodeStates = generatedText.Single(pair => + pair.Key.EndsWith( + "Opc.Ua.NodeStates.ex.g.cs", + System.StringComparison.Ordinal)).Value; + const string declaration = + "internal static global::Opc.Ua.DataTypeState CreateEncodedTicket("; + int declarationIndex = nodeStates.IndexOf( + declaration, + System.StringComparison.Ordinal); + + Assert.That(declarationIndex, Is.GreaterThanOrEqualTo(0)); + int initializerEnd = System.Math.Min( + declarationIndex + 1500, + nodeStates.Length); + string encodedTicketInitializer = + nodeStates[declarationIndex..initializerEnd]; + Assert.That( + encodedTicketInitializer, + Does.Contain("state.SuperTypeId = global::Opc.Ua.NodeId.Create(15u,")); + } + [Theory] public async Task GenerateAndCompileStackTestAsync( OptimizationLevel optimizationLevel, diff --git a/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs index 3473def2d3..41cbcbe75f 100644 --- a/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs @@ -141,6 +141,62 @@ namespace Opc.Ua.SourceGeneration Assert.That(generatorResult.GeneratedSources, Has.Length.EqualTo(19)); } + [Test] + public void SameDirectoryModelDesignFilesGenerateEveryModelTest() + { + var options = new AnalyzerOptionsProvider( + new Dictionary + { + ["build_property.ModelSourceGeneratorVersion"] = "v105", + ["build_property.ModelSourceGeneratorExclude"] = "Draft", + ["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true", + ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true", + ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true" + }); + + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunGeneratorLeniently( + options, + [ + EmbeddedText.From("OpcUaGdsModel.xml"), + EmbeddedText.From("OpcUaGdsModel.csv"), + EmbeddedText.From("OpcUaOnboardingModel.xml"), + EmbeddedText.From("OpcUaOnboardingModel.csv") + ]); + + Assert.That( + diagnostics, + Is.Empty, + string.Join("\n", diagnostics.Select(diagnostic => diagnostic.ToString()))); + GeneratorRunResult result = runResult.Results[0]; + Assert.That(result.Exception, Is.Null); + string generated = string.Join( + "\n", + result.GeneratedSources.Select(source => source.SourceText.ToString())); + + Assert.Multiple(() => + { + Assert.That( + result.GeneratedSources.Any(source => + source.HintName == "Opc.Ua.Gds.Identifiers.g.cs"), + Is.True); + Assert.That( + result.GeneratedSources.Any(source => + source.HintName == "Opc.Ua.Onboarding.Identifiers.g.cs"), + Is.True); + Assert.That( + generated, + Does.Contain("public const uint ApplicationRecordDataType = 1;")); + Assert.That( + generated, + Does.Contain("public const uint DeviceRegistrar = 1344;")); + Assert.That( + generated, + Does.Contain( + "global::Opc.Ua.ArrayOf tickets")); + }); + } + [Test] public void GenerateAndCompileDemoModelWotNativeProjectionTest() { diff --git a/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj b/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj index a6a684c90f..757dd7a9d8 100644 --- a/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj +++ b/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj @@ -40,6 +40,9 @@ PreserveNewest Resources\%(Filename)%(Extension) + + Resources\%(Filename)%(Extension) + diff --git a/tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml b/tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml index 8332aaed11..8e49c93e53 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml +++ b/tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml @@ -8665,7 +8665,7 @@ - + diff --git a/tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs b/tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs index 11fe02a46d..156e6cba0b 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs @@ -151,10 +151,10 @@ namespace Opc.Ua.SourceGeneration internal static class DesignFileExtensions { /// - /// Get design file groups for processing. A group is a set of design files - /// in the same common folder with an optional csv file included. Each group - /// carries the same so that - /// cross-model references resolve for every target. + /// Gets design file groups for processing. Each target is emitted as an + /// independent model and is paired with an adjacent identifier file that + /// has the same base name. Every other target remains available as a + /// dependency so cross-model references resolve in either direction. /// /// /// is null. @@ -166,33 +166,90 @@ namespace Opc.Ua.SourceGeneration { throw new ArgumentNullException(nameof(collection)); } - var idFiles = new Dictionary>(); - if (identifierFiles != null) - { - foreach (string idFile in identifierFiles) - { - string dir = Path.GetDirectoryName(idFile); - if (!idFiles.TryGetValue(dir, out List value)) - { - value = []; - idFiles[dir] = value; - } - value.Add(idFile); - } - } + IReadOnlyList idFiles = identifierFiles ?? []; IReadOnlyList dependencies = collection.Dependencies ?? []; + IReadOnlyList targets = collection.Targets; return collection.Targets - .GroupBy(Path.GetDirectoryName) - .Select(g => new DesignFileCollection + .Select(target => new DesignFileCollection { - Targets = [.. g], - Dependencies = dependencies, - IdentifierFilePath = idFiles.TryGetValue(g.Key, out List files) ? - files.FirstOrDefault() : collection.IdentifierFilePath, + Targets = [target], + Dependencies = [.. dependencies + .Concat(targets) + .Where(path => !string.Equals(path, target, StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal)], + IdentifierFilePath = ResolveIdentifierFile( + target, + idFiles, + collection.IdentifierFilePath, + IsSoleTargetInDirectory(target, targets)), Options = collection.Options }); } + private static string ResolveIdentifierFile( + string target, + IReadOnlyList identifierFiles, + string fallback, + bool useSoleAdjacentFallback) + { + string targetDirectory = Path.GetDirectoryName(target) ?? string.Empty; + string targetName = Path.GetFileNameWithoutExtension(target); + string basenameMatch = identifierFiles.FirstOrDefault(path => + string.Equals( + Path.GetDirectoryName(path) ?? string.Empty, + targetDirectory, + StringComparison.Ordinal) && + string.Equals( + Path.GetFileNameWithoutExtension(path), + targetName, + StringComparison.Ordinal) && + string.Equals( + Path.GetExtension(path), + ".csv", + StringComparison.OrdinalIgnoreCase)); + if (basenameMatch != null) + { + return basenameMatch; + } + if (useSoleAdjacentFallback) + { + string[] adjacentFiles = [.. identifierFiles.Where(path => + string.Equals( + Path.GetDirectoryName(path) ?? string.Empty, + targetDirectory, + StringComparison.Ordinal))]; + if (adjacentFiles.Length == 1) + { + return adjacentFiles[0]; + } + } + return fallback; + } + + private static bool IsSoleTargetInDirectory( + string target, + IReadOnlyList targets) + { + string targetDirectory = Path.GetDirectoryName(target) ?? string.Empty; + bool foundTarget = false; + foreach (string path in targets) + { + if (!string.Equals( + Path.GetDirectoryName(path) ?? string.Empty, + targetDirectory, + StringComparison.Ordinal)) + { + continue; + } + if (foundTarget) + { + return false; + } + foundTarget = true; + } + return foundTarget; + } + /// /// Validates the model design files ///