3 Commits

Author SHA1 Message Date
romanett 2871355d18 GDS: implement the AliasName Server facet (OPC 10000-17 Annex C) (#4668)
## Summary

Implements the **GDS AliasName Server facet** (OPC 10000-17 Annex C) in
`Opc.Ua.Gds.Server`, so that the CTT conformance unit *GDS AliasName
Discovery* passes apart from two CTT script defects (documented, Mantis
drafts in `plans/ctt-issues.md`).

Spec requirements:
- **C.1:** aggregate the AliasNames of registered Servers that expose
the `ALIAS` capability (OPC 10000-12 Annex D). Pull all
AliasNameCategory instances and merge those whose BrowseNames have the
same Name. Provide the ExpandedNodeId of every referenced node and the
ServerUri of the Server that contains it.
- **C.2:** on registration, merge the Server's AliasNames into a single
tree for TagVariables/Topics, combine aliases that share a name, and add
the ServerUri to `ServerArray`.
- **C.3:** on unregistration, remove that Server's aliases and
categories (but keep those other Servers still provide), remove its
AliasFor references, and remove its ServerUri from `ServerArray`.

### Design
- **`AliasNameAggregator`** (`Opc.Ua.Gds.Server.AliasNames`) holds one
snapshot per registered application and builds the merged view:
- Categories are keyed by their BrowseName path below `Aliases`. Aliases
are merged by name within a category; their targets are the union over
all sources.
- Targets are stored as namespace-URI ExpandedNodeIds. The ServerIndex
is resolved against the server's `ServerArray` each time the view is
built, because removing a ServerUri renumbers the entries after it.
  - Only ServerUris the aggregator added itself are removed again.
- Also implements `FindAlias`/`FindAliasVerbose`, recursive over
sub-categories.
- **`SessionAliasNameSourceReader`** reads a source over a client
Session by browsing the `Aliases` (i=23470) hierarchy and the AliasFor
references.
- It browses instead of calling `FindAlias` because the category
structure has to be aggregated too, and a source whose aliases come from
a NodeSet (like the CTT's embedded server) does not implement
`FindAlias`.
- It connects anonymously, to the most secure endpoint first, falling
back to SecurityMode None. It runs GetEndpoints once per DiscoveryUrl.
- **`ApplicationsNodeManager`** (new partial
`ApplicationsNodeManager.AliasNames.cs`):
- Hooks RegisterApplication (now `OnCallAsync`), UpdateApplication
(background re-read) and UnregisterApplication.
- Materializes the merged view as `AliasNameType` and
`AliasNameCategoryType` nodes in the GDS application-record namespace,
organized by the well-known TagVariables/Topics/Aliases objects. Each
category gets a `FindAlias` answered from the merged list.
- Changes and node updates are serialized, so a read that finishes after
a concurrent unregister cannot re-add the source.
- RegisterApplication waits at most `AliasNameRegistrationWait` (5 s)
for the read; a slower or unreachable source is finished in the
background and never blocks clients past their call timeout.
- Optional periodic re-read via `AliasNameRefreshInterval`; it also
retries sources that could not be read.
-
**`IAliasNameStoreRegistry.RegisterContributor`/`UnregisterContributor`**
(Opc.Ua.Server): a read-only contributor's aliases are added to the
`FindAlias`/`FindAliasVerbose` results of the categories it reports,
next to the owning store's. This lets the aggregated aliases share the
well-known categories with the server's own aliases, which matters for
the reference server, an AliasName Server and a GDS at once.
Contributors never receive Add/Delete calls.
- **Opt-in:**
`GlobalDiscoveryServerConfiguration.EnableAliasNameAggregation` is off
by default, because the GDS then opens client connections to the
DiscoveryUrls of registered applications.
`Ctt.ReferenceServer.Config.xml` enables it.
- `Opc.Ua.Gds.Server` now references `Opc.Ua.Client`; the `Opc.Ua`
meta-package already depends on both.

### CTT (1.05.513 with the patched scripts), p14-gds, reference server
`--ctt`, embedded CTT source + 2 extra AliasName sources

| GDS AliasName Discovery | before | after | after, with the C54/C55
script fixes |
| --- | --- | --- | --- |
| initialize | Skipped (no sources) | Pass | Pass |
| 001 | Error | Error (C55) | Pass |
| 002 | Error | Error (C54) | Warning (RegisterApplication > 100 ms) |
| 003 | Pass | Pass | Pass |
| 004 | Error | Error (C54) | Pass |
| 005 | Skipped | Error (C55) | Pass |
| 006, 007, 009, 011, 012, 014, 015 | Skipped | Error (C54) | Pass |
| 008 | Skipped | Pass | Pass |
| 010, 013 | Skipped | Error (C55) | Pass |

- **C54:** the script compares the GDS's AliasFor target (ServerIndex >
0, namespace URI, as Annex C.1 and Part 4 §7.16 require) with the
source's local NodeId using `equals()`, which can never match.
- **C55:** "no remote aliases" is checked as "no aliases", so it fails
on the reference server's own aliases.

Both are documented with draft Mantis texts (not filed). The remaining
script checks pass in 15/15 test cases.

No regressions:
- Application Directory and Query Applications are unchanged against the
baseline. The only difference is one ActivateSession timing warning in
one run.
- p09 (AliasName CUs) passes 32/32, identical to the baseline.

## Tests
- `AliasNameAggregatorTests` (unit) covers:
  - merging across Servers;
- categories kept while still referenced and removed after the last
source;
  - ServerArray add, remove and renumbering;
  - pre-existing ServerUris kept;
  - local targets;
  - FindAlias/FindAliasVerbose scope, pattern and reference-type filter.
- `GdsAliasNameAggregationTests` (end-to-end) registers a second
in-process reference server with `ALIAS` and reads it with the real
session reader. It checks:
  - aggregated nodes and remote AliasFor targets;
  - the ServerArray entry;
  - FindAlias on TagVariables and on the aggregated category;
  - that unregistering removes everything;
  - that an unreachable source still registers;
  - that a Server without `ALIAS` is ignored.
- Registry contributor tests in `AliasNameStoreRegistryTests`.
- Opc.Ua.Gds.Tests: 1246 passed, 0 failed, 56 skipped.
Opc.Ua.Server.Tests AliasNames: 198/198. InformationModel and Features
alias tests pass. net48 compiles.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:10:02 +00:00
romanett ececea15c8 Reimplement the GDS ApplicationsNodeManager as a generated fluent node manager (#4434)
# Description

Builds on #4428, which is now merged; this branch is rebased onto
master.

The GDS `ApplicationsNodeManager` becomes a source-generated
`[NodeManager]`: the companion model, its loader and the fluent plumbing
are emitted, and what stays hand-written is the behaviour. Getting there
needed three things from the shared surface, which are the first two
commits.

## Commits

**1. Add the fluent hooks a hand-written manager needs at startup**

- `OnReadRolePermissions` / `OnReadUserRolePermissions` on the node
builder. Every other `NodeState` `On*` hook was already fluent; these
were not, and a server that grants access on something the static model
cannot express has to compute the permission set per request.
- `Node<TState>(TState)` returns a builder over a node the caller
already holds, for nodes materialised after `Configure` resolved its
graph.
- `OnAddressSpaceReadyAsync` on `FluentNodeManagerBase` — the one seam
between the address space existing and the wiring being applied, for
managers whose wiring depends on asynchronous setup. `Configure` is a
`partial void` and cannot await. `DiNodeManager` had already invented
the hook privately, so its declaration becomes an `override`.

**2. Let a `[NodeManager]` bind to a model a referenced assembly
supplies**

A model belongs to the assembly that emits its types, which leaves a
node manager for it homeless whenever that assembly cannot reference
`Opc.Ua.Server` — a model-only package shared with clients, for
instance. Binding was rejected twice over: the design was skipped before
the binding was matched (`MODELGEN010`), and fluent-accessors-only mode
refused to run alongside manager generation (`MODELGEN014`). The binding
is now matched before the skip decision, and a matched binding puts that
design into accessors-only mode. A design nothing binds to is still
skipped, so this cannot start duplicating types by accident.

The emitted manager also gains what a real manager needs:
`DefaultNamespaceUris()` plus a protected constructor taking a
replacement set; `GenerateDefaultConstructor=false` to suppress the
two-argument form; its own logger category; and the call to
`OnAddressSpaceReadyAsync`.

Generated fluent accessors also stop re-enabling nullable warnings — a
bare `#nullable enable` undid the shared header's `disable warnings`, so
any model with a structure-typed method argument reported CS8600 on
every generated `out T` in a project that builds warnings as errors.

**3. Reimplement the GDS `ApplicationsNodeManager` as a generated node
manager**

The design stays owned by `Opc.Ua.Gds.Common`; the server assembly only
binds a manager to it.

**NodeIds do not move.** `NamespaceIndexes[0]` must stay the application
record namespace — it is the one the application database, the
certificate request store and the base allocator mint ids in — so the
constructor passes the namespace order explicitly rather than adopting
the generated model-first default. A test pins both ends against a live
server.

Four overrides go, leaving `OnAddressSpaceReadyAsync` and
`Dispose(bool)` — no address-space overrides at all:

| Removed | Why it was unnecessary |
| --- | --- |
| `New` | The base allocator already mints into `NamespaceIndexes[0]`,
with a thread-safe counter. The override existed only because
`Create(assignNodeIds: true)` asks the factory with the declaration id
still set; `AssignInstanceNodeId` nulls it and re-asks, documented as
being there for exactly this allocator. |
| `AddBehaviourToPredefinedNodeAsync` | Its `AuthorizationServiceType`
branch re-wired a node that was already wired, and its
`KeyCredentialServiceType` branch had nothing to wire — the model ships
that folder empty. |
| `GetManagerHandleAsync`, `ValidateNodeAsync` | Verbatim
reimplementations of the base that also blocked the fluent base's
virtual-node support. |
| `DeleteAddressSpaceAsync` | A `// TBD` passthrough. |

Everything else — all sixteen directory methods with their
self-administration permissions, the certificate groups' concrete types
and trust-list flags, and the authorization service (created through
this PR's `Add` surface and wired in the same place) — is one
`Configure` pass.

**4. Bind the certificate groups from the `Configure` pass**

Acquiring a certificate group and binding it to its nodes are different
kinds of work: the first is I/O (`InitAsync` opens certificate stores
and creates CA certificates), the second only touches the address space.
Each now runs in the phase it belongs to, which puts the whole of the
wiring in one place.

Ownership no longer rides on the binding. A group joins
`m_ownedCertificateGroups` the moment it is constructed, before
`InitAsync` can throw, so a group that fails half way through is still
disposed — which the previous code could not manage, keying ownership
off a dictionary it filled only after initialization succeeded.

> Commit 3's message says this move is impossible and blames `PushTest`.
**That was a wrong diagnosis, and this commit supersedes it.** Two real
defects were hiding behind those failures:

- **`Create(..., NodeId.Null, ..., assignNodeIds: false)` does not null
the NodeId.** `NodeState.Create` overrides it only when handed a
non-null one, so a custom group node kept the namespace-0 id of its type
declaration. `AssignInstanceNodeId` hid this by re-asking the factory
with the id nulled; the builder validates instead, and rejected it. The
node is now created with an id in the manager's own namespace, as the
`Default` authorization service already was.
- **The custom certificate group node was not browseable.** It answered
`GetCertificateGroups` and `ReadNode`, but a type-filtered recursive
browse from the Objects folder never returned it — which OPC 10000-12
§7.8.2 requires of a certificate group. Staging the node through the
builder gives it the reference it was missing.

### Two `PushTest` methods change — please review these closely

The second defect had been masking what those tests actually did.
Selecting the group positionally, `groups[3]` resolved to the server's
**own `ServerConfiguration` group** while the custom one stayed
invisible, so `UpdateTrustListOfCustomGroupAsync` and
`AddRemoveCertOfCustomGroupAsync` were exercising a *transactional*
trust list under a custom-group name.

Browse results, before and after:

| # | Before (baseline) | After |
| --- | --- | --- |
| 0-2 | GDS Directory groups (`ns=3`) | GDS Directory groups (`ns=3`) |
| 3 | **`DefaultApplicationGroup` `i=14156`** — the server's own |
**`MyCustomGroup` `ns=2`** |
| 4 | — | `DefaultApplicationGroup` `i=14156` |

They now resolve the group by browse name — which asserts it is
browseable at all — and expect the immediate-apply semantics the GDS
actually gives the groups of its own `Directory`: `CloseAndUpdate`
writes the stores at once and reports `applyChangesRequired == false`,
so the `ApplyChanges` calls go away (they would return
`Bad_NothingToDo`). The read-back assertions are what prove the writes
landed, and they are unchanged.

This is a genuine loss of the *transactional* trust-list coverage those
two methods were accidentally providing — the server's own group is
still covered by the other `PushTest` methods, which target it
deliberately.

**5. Select `GetCertificates` results by type instead of by position**

`GetCertificatesAsync` checked entry zero of a parallel-array result.
Which entry that is depends on the order the server reports its
certificate types in, an empty result throws `IndexOutOfRange` rather
than failing an assertion, and a second type going missing or
unparseable would not be noticed. Every returned pair is now checked,
and a failure names the certificate type.

**6. Drop the `.Common` suffix from the three GDS projects**

Every other companion specification ships as `<Family>` /
`<Family>.Client` / `<Family>.Server` — Di, ISA95, Positioning,
Robotics, Vision, WotCon, XRegistry. GDS was the exception, with a
`.Common` suffix that named nothing.

| Before | After |
| --- | --- |
| `src/Opc.Ua.Gds.Common` | `src/Opc.Ua.Gds` |
| `src/Opc.Ua.Gds.Client.Common` | `src/Opc.Ua.Gds.Client` |
| `src/Opc.Ua.Gds.Server.Common` | `src/Opc.Ua.Gds.Server` |

**No source changes were needed.** `RootNamespace` was already the final
name in all three projects — the namespaces have always been
`Opc.Ua.Gds`, `Opc.Ua.Gds.Client` and `Opc.Ua.Gds.Server`, and only the
assembly and package ids disagreed. Nothing compiling against these
assemblies changes a `using`.

The package ids do change, which is consumer-visible, so
`docs/migrate/2.0.x/packages.md` gains a renamed-packages section — it
is the document that undertakes to cover NuGet renames — and the
migration skill's package table gains the two rows a 1.5.378 consumer
needs.

Build infrastructure that names the projects or their output moves with
them: both solution files, `expected-packages.txt` (re-sorted — it is
maintained alphabetically), both signing manifests, the reference-server
Dockerfile, the metapackage nuspecs, and the analyzer's package
dependencies. `preview-pack.slnx` is derived from `UA.slnx` and needs no
edit. The migration analyzer's shim tree mirrors the source layout by
its own documented convention, so `Gds.Client.Common/` becomes
`Gds.Client/`.

Prose naming past work is left alone — plan 23 still records that its
deferral happened during the "GDS Client.Common modernization", which is
the name that effort had.

## Validation

`net10.0`, on master + these four commits, from a clean GDS PKI:

- `UA.slnx`: 0 errors, 0 warnings in changed files (the solution's 30
pre-existing warning lines are CA1861/CA1850/CA1307 in `XRegistry`,
`Types.Tests` and `WotCon.Tests` — same rules and projects as the base).
- `Opc.Ua.Gds.Tests`: 1,117 passed, 0 failed, 54 skipped — identical to
the base branch.
- `Opc.Ua.Server.Tests`: 4,961 passed, 0 failed, 5 skipped (measured
before commit 4, which touches only the GDS assembly and its tests).
- `Opc.Ua.SourceGeneration.Tests`: 169 passed.
- `Opc.Ua.SourceGeneration.Core.Tests`, NodeManager/Generators/Fluent:
84 passed; `GenerateCode`: 21 passed, 4 skipped.
- `Opc.Ua.MigrationAnalyzer.Core.Tests`: 10 passed, 3 skipped.
- After the rename: assemblies build as `Opc.Ua.Gds.dll` / `.Client.dll`
/ `.Server.dll`, with no `*.Common.dll` produced anywhere.
- Commits 1-3 build standalone.
- `Opc.Ua.Gds.Server.Common` also builds for `net48` and
`netstandard2.1` (checked before the rebase onto #4428).

Not yet run: the full `Opc.Ua.SourceGeneration.Core.Tests` suite
(~3,800), and the older target frameworks since the rebase.

## Checklist

- [x] Existing tests pass
- [x] New tests cover the new surface (role permissions, resolved-node
builder, referenced-model binding, constructor/namespace control, GDS
namespace invariant, custom-group node namespace)
- [ ] Full Core.Tests sweep and net48/netstandard2.1 re-run after the
rebase

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-09 10:41:06 +02:00
Copilot d525bc3f28 Bound reconnect cleanup and organize samples by domain (#4276)
# Description

This PR fixes bounded ManagedSession reconnect cleanup and reorganizes
the sample applications into domain-focused folders.

## Reconnect cleanup

- Bounds reconnect integration cleanup with independent 30-second close
and dispose limits.
- Sends `CloseSession` through the awaited transport request path during
`Session.DisposeAsync`.
- Preserves explicit `Session.CloseAsync` behavior that waits for or
cancels outstanding publish requests.
- Covers the disposal request path with a regression test.

## Sample organization

- `samples/OpenUsd`: `GeneratorServer`, `SiteCompositionServer`
- `samples/Redundancy`: `RedundantClient`, `RedundantServer`,
`RedundantPubSub`
- `samples/Isa95`: `MinimalIsa95Server`
- `samples/MinimalApi`: `MinimalBoilerServer`, `MinimalCalcServer`,
`MinimalClient`
- `samples/DI`: `PumpDeviceIntegrationServer`
- `samples/Lds`: `ConsoleLdsServer`
- `samples/Reference`: `ConsoleReferenceClient`,
`ConsoleReferenceServer`
- `samples/PubSub`: `ConsoleReferencePubSubClient`

`UA.slnx` now exposes the same requested domain groups. Project
references, tests, runtime sample probes, CI workflows, Dockerfiles,
Compose files, helper scripts, and documentation were updated to the new
paths. `samples/Robotics/MinimalRobotServer` remains under `Robotics`.

## Validation

- `dotnet build UA.slnx`
- 3 Redundancy short-haul sample tests
- 70 DI sample tests
- 914 OpenUSD tests
- 1 WotCon model-materialization test
- 137 AOT tests
- `docker compose config` for all 6 moved Redundancy Compose files

## Related Issues

- Fixes #4273

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce new build or
analyzer warnings.
- [x] I built the complete `UA.slnx` solution locally.
- [ ] I ran all tests locally against at least .NET Framework and .NET
10.
- [ ] I fixed all failing and flaky tests in the CI pipelines and all
CodeQL warnings.
- [x] I have addressed all PR feedback received.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: Marc Schier <mschier@microsoft.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Copilot-Session: a2c2838f-74ae-4997-a1d4-50574d9e0636
2026-08-21 04:35:47 +00:00