Skip to content

Commit ed09b87

Browse files
Add ACS OPC UA Edge Server integration (#596)
This PR introduces `acs-opcua-server-edge`, a new edge helm chart that exposes UNS topics as OPC UA tags for consumption by OPC UA clients (e.g. SCADA, HMI systems) at the edge. ### What's included **New `acs-opcua-server-edge` edge helm chart** - Node.js OPC UA server built on `node-opcua` that subscribes to MQTT topics and surfaces them as OPC UA variable nodes - Address space is built **dynamically**: nodes are created on the fly as new topics arrive from the MQTT broker, supporting wildcard subscriptions rather than requiring a static topic list at startup - Last-known values are persisted to disk so the OPC UA server can serve cached values immediately on restart - OPC UA security: supports `Basic256Sha256` + `SignAndEncrypt` for secure username/password authentication, with `SecurityPolicy.None` kept available for anonymous browsing; anonymous access is configurable - MQTT: supports optional TLS (`mqtts://`) with a configurable CA certificate, auto-detected from port 8883 **Helm chart (`edge-helm-charts/charts/opcua-server`)** - Full Kubernetes deployment with configmap-driven configuration, a PVC for the value cache and PKI data, a NodePort service for external OPC UA access, and a Kerberos keytab secret - Certificate managers use a writable `/data` volume (fixes node-opcua defaulting to `~/.config` which is read-only in the container) - Dynamic image tag/registry injection via `%%REGISTRY%%` / `%%TAG%%` / `%%PULLPOLICY%%` placeholders - Configurable resource limits, tolerations, and host pinning **ACS service-setup integration** - Registers `Local.Role.OPCUAServer` (with `Auth.Class.EdgeService` + `UNS.Group.Reader` membership) and `Local.Chart.OPCUAServer` in the helm dump - Adds UUIDs for the new role and chart to `local-uuids.js` **CI** - `acs-opcua-server-edge` added to the publish workflow for Docker image builds ### Configuration highlights ```yaml topics: "UNS/v1/site/area/cell/+/Temperature": {} # wildcard subscriptions supported opcua: port: 4840 username: "opcua" # password auto-generated into a Secret ```
2 parents 2c7f2f6 + d83d2ee commit ed09b87

File tree

25 files changed

+1009
-3
lines changed

25 files changed

+1009
-3
lines changed

.github/workflows/publish.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ jobs:
237237
- historian-sparkplug
238238
- historian-uns
239239
- uns-ingester-sparkplug
240+
- acs-opcua-server-edge
240241
permissions:
241242
contents: read
242243
packages: write

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ config.mk
77
.vscode
88
.DS_Store
99
/edge-ads/node_modules
10-
spirit-schemas/
10+
spirit-schemas/
11+
/acs-opcua-server-edge/test/

CLAUDE.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
The AMRC Connectivity Stack (ACS) is a Kubernetes-deployed implementation of the [Factory+](https://factoryplus.app.amrc.co.uk) framework for industrial connectivity and data management. It consists of central cluster services (MQTT broker, Directory, Auth, ConfigDB, Historians, Manager UI) and edge agents that collect data from industrial devices.
8+
9+
## Repository Structure
10+
11+
- `lib/` - Shared libraries (must build first)
12+
- `js-service-client` - Main client library for Factory+ services
13+
- `js-service-api` - Base classes for building service APIs
14+
- `js-edge-driver` - Base driver class for edge translators
15+
- `js-sparkplug-app` - Sparkplug B protocol utilities
16+
- `js-pg-client`, `js-rx-client`, `js-rx-util` - Database and reactive utilities
17+
- `py-edge-driver` - Python edge driver base
18+
- `java-service-client` - Java client library
19+
20+
- `acs-*` - Central cluster services (Auth, ConfigDB, Directory, etc.)
21+
- `edge-*` - Edge protocol translators (Modbus, BACnet, ADS, etc.)
22+
- `historian-*`, `uns-ingester-*` - Data ingestion services
23+
- `deploy/` - Helm chart for Kubernetes deployment
24+
- `mk/` - Makefile fragments
25+
26+
## JavaScript Services
27+
28+
Most services are ES modules using `@amrc-factoryplus/service-client` for Factory+ integration. Services reference local libraries via `file:../lib/js-*` in package.json.
29+
30+
TypeScript services (like `acs-edge`) use:
31+
```bash
32+
npm run dev # Development with ts-node-dev
33+
npm run build # Compile TypeScript
34+
npm run test # Run Jest tests
35+
```
36+
37+
## Key Patterns
38+
39+
- Services authenticate via Kerberos to the MQTT broker
40+
- Configuration is stored in ConfigDB and accessed via the service client
41+
- Edge agents publish Sparkplug B messages to the MQTT broker
42+
- The `@amrc-factoryplus/service-client` library provides `ServiceClient` class for accessing Factory+ services
43+
44+
## Contributing
45+
46+
- Branch naming: `initials/branch-description` or `feature/xxx` for long-running branches
47+
- Commit messages: imperative mood, explain the "why", reference issues
48+
- Keep PRs focused on a single issue/feature
49+
- Rebase onto `main` rather than merging

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ subdirs+= acs-krb-keys-operator
5252
subdirs+= acs-krb-utils
5353
subdirs+= acs-monitor
5454
subdirs+= acs-mqtt
55+
subdirs+= acs-opcua-server-edge
5556
subdirs+= acs-service-setup
5657
subdirs+= acs-visualiser
5758
subdirs+= deploy

acs-opcua-server-edge/Dockerfile

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# syntax=docker/dockerfile:1
2+
3+
ARG base_version
4+
ARG base_prefix=ghcr.io/amrc-factoryplus/acs-base
5+
6+
FROM ${base_prefix}-js-build:${base_version} AS build
7+
ARG acs_npm=NO
8+
ARG TARGETOS
9+
ARG TARGETARCH
10+
11+
USER root
12+
RUN <<'SHELL'
13+
install -d -o node -g node /home/node/app
14+
mkdir /home/node/lib
15+
SHELL
16+
COPY --from=lib . /home/node/lib/
17+
18+
WORKDIR /home/node/app
19+
USER node
20+
COPY package*.json ./
21+
RUN <<'SHELL'
22+
touch /home/node/.npmrc
23+
npm install --save=false --omit=dev --install-links
24+
SHELL
25+
COPY --chown=node . .
26+
27+
FROM ${base_prefix}-js-run:${base_version} AS run
28+
ARG revision=unknown
29+
# Copy across from the build container.
30+
WORKDIR /home/node/app
31+
COPY --from=build --chown=root:root /home/node/app ./
32+
# Do this last to not smash the build cache
33+
RUN <<'SHELL'
34+
echo "export const GIT_VERSION=\"$revision\";" > ./lib/git-version.js
35+
SHELL
36+
USER node
37+
CMD node bin/opcua-server.js

acs-opcua-server-edge/Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
top=..
2+
include ${top}/mk/acs.init.mk
3+
4+
repo?=acs-opcua-server-edge
5+
6+
include ${mk}/acs.js.mk
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright (c) University of Sheffield AMRC 2026.
3+
*/
4+
5+
/*
6+
* ACS Edge OPC UA Server - Entry Point
7+
*
8+
* Reads configuration from a mounted ConfigMap file and credentials
9+
* from environment variables, initialises the data store, MQTT client
10+
* (via ServiceClient), and OPC UA server.
11+
*/
12+
13+
import fs from "node:fs";
14+
15+
import { ServiceClient } from "@amrc-factoryplus/service-client";
16+
17+
import { DataStore } from "../lib/data-store.js";
18+
import { MqttClient } from "../lib/mqtt-client.js";
19+
import { Server } from "../lib/server.js";
20+
21+
/* Read configuration from files. */
22+
const configFile = process.env.CONFIG_FILE ?? "/config/config.json";
23+
const dataDir = process.env.DATA_DIR ?? "/data";
24+
25+
const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
26+
27+
/* Read OPC UA credentials from environment variables. */
28+
const opcuaUsername = process.env.OPCUA_USERNAME;
29+
const opcuaPassword = process.env.OPCUA_PASSWORD;
30+
31+
if (!opcuaUsername || !opcuaPassword) {
32+
console.error("OPCUA_USERNAME and OPCUA_PASSWORD must be set");
33+
process.exit(1);
34+
}
35+
36+
/* Build ServiceClient - reads SERVICE_USERNAME, SERVICE_PASSWORD,
37+
* DIRECTORY_URL, VERBOSE from process.env. */
38+
const fplus = await new ServiceClient({ env: process.env }).init();
39+
40+
/* Initialise components. */
41+
const dataStore = new DataStore({ dataDir });
42+
dataStore.start();
43+
44+
const mqttClient = new MqttClient({
45+
fplus,
46+
topics: config.topics,
47+
dataStore,
48+
});
49+
50+
const server = new Server({
51+
port: config.opcua.port,
52+
dataStore,
53+
username: opcuaUsername,
54+
password: opcuaPassword,
55+
allowAnonymous: config.opcua.allowAnonymous ?? false,
56+
});
57+
58+
/* Start everything. */
59+
await mqttClient.start();
60+
await server.start();
61+
62+
console.log(`OPC UA server ready. Subscribed to ${config.topics.length} MQTT topic pattern(s).`);
63+
64+
/* Graceful shutdown. */
65+
const shutdown = async (signal) => {
66+
console.log(`Received ${signal}, shutting down...`);
67+
await server.stop();
68+
await mqttClient.stop();
69+
dataStore.stop();
70+
process.exit(0);
71+
};
72+
73+
process.on("SIGINT", () => shutdown("SIGINT"));
74+
process.on("SIGTERM", () => shutdown("SIGTERM"));
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
services:
2+
opcua-server:
3+
image: acs-opcua-server-edge:local
4+
platform: linux/amd64
5+
user: root
6+
ports:
7+
- "4840:4840"
8+
environment:
9+
CONFIG_FILE: /config/config.json
10+
DATA_DIR: /data
11+
SERVICE_USERNAME: username
12+
SERVICE_PASSWORD: password
13+
DIRECTORY_URL: https://directory.baseUrl
14+
OPCUA_USERNAME: opcua
15+
OPCUA_PASSWORD: test123
16+
volumes:
17+
- ./test/config.json:/config/config.json:ro
18+
- opcua-data:/data
19+
networks:
20+
- opcua-test
21+
22+
networks:
23+
opcua-test:
24+
25+
volumes:
26+
opcua-data:
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright (c) University of Sheffield AMRC 2026.
3+
*/
4+
5+
/*
6+
* ACS Edge OPC UA Server - Data Store
7+
*
8+
* In-memory store for tag values with periodic flush to a persistent
9+
* JSON file so values survive pod restarts.
10+
*/
11+
12+
import { EventEmitter } from "node:events";
13+
import fs from "node:fs";
14+
import path from "node:path";
15+
16+
export class DataStore extends EventEmitter {
17+
constructor(opts) {
18+
super();
19+
this.dataDir = opts.dataDir;
20+
this.cacheFile = path.join(this.dataDir, "last-values.json");
21+
this.flushInterval = opts.flushInterval ?? 5000;
22+
23+
this.values = new Map();
24+
this.dirty = false;
25+
this.timer = null;
26+
}
27+
28+
load() {
29+
try {
30+
if (fs.existsSync(this.cacheFile)) {
31+
const data = JSON.parse(fs.readFileSync(this.cacheFile, "utf-8"));
32+
for (const [topic, entry] of Object.entries(data)) {
33+
this.values.set(topic, entry);
34+
}
35+
console.log(`Loaded ${this.values.size} cached values from ${this.cacheFile}`);
36+
}
37+
}
38+
catch (err) {
39+
console.error(`Error loading cache file: ${err.message}`);
40+
}
41+
}
42+
43+
start() {
44+
this.load();
45+
this.timer = setInterval(() => this.flush(), this.flushInterval);
46+
}
47+
48+
stop() {
49+
if (this.timer) {
50+
clearInterval(this.timer);
51+
this.timer = null;
52+
}
53+
this.flush();
54+
}
55+
56+
set(topic, value, timestamp) {
57+
const entry = {
58+
value,
59+
timestamp: timestamp ?? new Date().toISOString(),
60+
};
61+
this.values.set(topic, entry);
62+
this.dirty = true;
63+
this.emit("change", topic, entry);
64+
}
65+
66+
get(topic) {
67+
const entry = this.values.get(topic);
68+
return entry ?? null;
69+
}
70+
71+
topics() {
72+
return this.values.keys();
73+
}
74+
75+
flush() {
76+
if (!this.dirty) return;
77+
78+
try {
79+
const data = Object.fromEntries(this.values);
80+
const tmp = this.cacheFile + ".tmp";
81+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
82+
fs.renameSync(tmp, this.cacheFile);
83+
this.dirty = false;
84+
}
85+
catch (err) {
86+
console.error(`Error flushing cache: ${err.message}`);
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)