diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d3cdd4397..fa66fc7f0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -237,6 +237,7 @@ jobs: - historian-sparkplug - historian-uns - uns-ingester-sparkplug + - acs-opcua-server-edge permissions: contents: read packages: write diff --git a/.gitignore b/.gitignore index 0685e703b..47965beb2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ config.mk .vscode .DS_Store /edge-ads/node_modules -spirit-schemas/ \ No newline at end of file +spirit-schemas/ +/acs-opcua-server-edge/test/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..8d6a43708 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +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. + +## Repository Structure + +- `lib/` - Shared libraries (must build first) + - `js-service-client` - Main client library for Factory+ services + - `js-service-api` - Base classes for building service APIs + - `js-edge-driver` - Base driver class for edge translators + - `js-sparkplug-app` - Sparkplug B protocol utilities + - `js-pg-client`, `js-rx-client`, `js-rx-util` - Database and reactive utilities + - `py-edge-driver` - Python edge driver base + - `java-service-client` - Java client library + +- `acs-*` - Central cluster services (Auth, ConfigDB, Directory, etc.) +- `edge-*` - Edge protocol translators (Modbus, BACnet, ADS, etc.) +- `historian-*`, `uns-ingester-*` - Data ingestion services +- `deploy/` - Helm chart for Kubernetes deployment +- `mk/` - Makefile fragments + +## JavaScript Services + +Most services are ES modules using `@amrc-factoryplus/service-client` for Factory+ integration. Services reference local libraries via `file:../lib/js-*` in package.json. + +TypeScript services (like `acs-edge`) use: +```bash +npm run dev # Development with ts-node-dev +npm run build # Compile TypeScript +npm run test # Run Jest tests +``` + +## Key Patterns + +- Services authenticate via Kerberos to the MQTT broker +- Configuration is stored in ConfigDB and accessed via the service client +- Edge agents publish Sparkplug B messages to the MQTT broker +- The `@amrc-factoryplus/service-client` library provides `ServiceClient` class for accessing Factory+ services + +## Contributing + +- Branch naming: `initials/branch-description` or `feature/xxx` for long-running branches +- Commit messages: imperative mood, explain the "why", reference issues +- Keep PRs focused on a single issue/feature +- Rebase onto `main` rather than merging diff --git a/Makefile b/Makefile index 6d4a21642..96f8780d7 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,7 @@ subdirs+= acs-krb-keys-operator subdirs+= acs-krb-utils subdirs+= acs-monitor subdirs+= acs-mqtt +subdirs+= acs-opcua-server-edge subdirs+= acs-service-setup subdirs+= acs-visualiser subdirs+= deploy diff --git a/acs-opcua-server-edge/Dockerfile b/acs-opcua-server-edge/Dockerfile new file mode 100644 index 000000000..016a00375 --- /dev/null +++ b/acs-opcua-server-edge/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +ARG base_version +ARG base_prefix=ghcr.io/amrc-factoryplus/acs-base + +FROM ${base_prefix}-js-build:${base_version} AS build +ARG acs_npm=NO +ARG TARGETOS +ARG TARGETARCH + +USER root +RUN <<'SHELL' + install -d -o node -g node /home/node/app + mkdir /home/node/lib +SHELL +COPY --from=lib . /home/node/lib/ + +WORKDIR /home/node/app +USER node +COPY package*.json ./ +RUN <<'SHELL' + touch /home/node/.npmrc + npm install --save=false --omit=dev --install-links +SHELL +COPY --chown=node . . + +FROM ${base_prefix}-js-run:${base_version} AS run +ARG revision=unknown +# Copy across from the build container. +WORKDIR /home/node/app +COPY --from=build --chown=root:root /home/node/app ./ +# Do this last to not smash the build cache +RUN <<'SHELL' + echo "export const GIT_VERSION=\"$revision\";" > ./lib/git-version.js +SHELL +USER node +CMD node bin/opcua-server.js diff --git a/acs-opcua-server-edge/Makefile b/acs-opcua-server-edge/Makefile new file mode 100644 index 000000000..cc0ebf724 --- /dev/null +++ b/acs-opcua-server-edge/Makefile @@ -0,0 +1,6 @@ +top=.. +include ${top}/mk/acs.init.mk + +repo?=acs-opcua-server-edge + +include ${mk}/acs.js.mk diff --git a/acs-opcua-server-edge/bin/opcua-server.js b/acs-opcua-server-edge/bin/opcua-server.js new file mode 100644 index 000000000..d66b18f68 --- /dev/null +++ b/acs-opcua-server-edge/bin/opcua-server.js @@ -0,0 +1,74 @@ +/* + * Copyright (c) University of Sheffield AMRC 2026. + */ + +/* + * ACS Edge OPC UA Server - Entry Point + * + * Reads configuration from a mounted ConfigMap file and credentials + * from environment variables, initialises the data store, MQTT client + * (via ServiceClient), and OPC UA server. + */ + +import fs from "node:fs"; + +import { ServiceClient } from "@amrc-factoryplus/service-client"; + +import { DataStore } from "../lib/data-store.js"; +import { MqttClient } from "../lib/mqtt-client.js"; +import { Server } from "../lib/server.js"; + +/* Read configuration from files. */ +const configFile = process.env.CONFIG_FILE ?? "/config/config.json"; +const dataDir = process.env.DATA_DIR ?? "/data"; + +const config = JSON.parse(fs.readFileSync(configFile, "utf-8")); + +/* Read OPC UA credentials from environment variables. */ +const opcuaUsername = process.env.OPCUA_USERNAME; +const opcuaPassword = process.env.OPCUA_PASSWORD; + +if (!opcuaUsername || !opcuaPassword) { + console.error("OPCUA_USERNAME and OPCUA_PASSWORD must be set"); + process.exit(1); +} + +/* Build ServiceClient - reads SERVICE_USERNAME, SERVICE_PASSWORD, + * DIRECTORY_URL, VERBOSE from process.env. */ +const fplus = await new ServiceClient({ env: process.env }).init(); + +/* Initialise components. */ +const dataStore = new DataStore({ dataDir }); +dataStore.start(); + +const mqttClient = new MqttClient({ + fplus, + topics: config.topics, + dataStore, +}); + +const server = new Server({ + port: config.opcua.port, + dataStore, + username: opcuaUsername, + password: opcuaPassword, + allowAnonymous: config.opcua.allowAnonymous ?? false, +}); + +/* Start everything. */ +await mqttClient.start(); +await server.start(); + +console.log(`OPC UA server ready. Subscribed to ${config.topics.length} MQTT topic pattern(s).`); + +/* Graceful shutdown. */ +const shutdown = async (signal) => { + console.log(`Received ${signal}, shutting down...`); + await server.stop(); + await mqttClient.stop(); + dataStore.stop(); + process.exit(0); +}; + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); diff --git a/acs-opcua-server-edge/docker-compose.yml b/acs-opcua-server-edge/docker-compose.yml new file mode 100644 index 000000000..d9f3ec7f6 --- /dev/null +++ b/acs-opcua-server-edge/docker-compose.yml @@ -0,0 +1,26 @@ +services: + opcua-server: + image: acs-opcua-server-edge:local + platform: linux/amd64 + user: root + ports: + - "4840:4840" + environment: + CONFIG_FILE: /config/config.json + DATA_DIR: /data + SERVICE_USERNAME: username + SERVICE_PASSWORD: password + DIRECTORY_URL: https://directory.baseUrl + OPCUA_USERNAME: opcua + OPCUA_PASSWORD: test123 + volumes: + - ./test/config.json:/config/config.json:ro + - opcua-data:/data + networks: + - opcua-test + +networks: + opcua-test: + +volumes: + opcua-data: diff --git a/acs-opcua-server-edge/lib/data-store.js b/acs-opcua-server-edge/lib/data-store.js new file mode 100644 index 000000000..4fd9e8a82 --- /dev/null +++ b/acs-opcua-server-edge/lib/data-store.js @@ -0,0 +1,89 @@ +/* + * Copyright (c) University of Sheffield AMRC 2026. + */ + +/* + * ACS Edge OPC UA Server - Data Store + * + * In-memory store for tag values with periodic flush to a persistent + * JSON file so values survive pod restarts. + */ + +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import path from "node:path"; + +export class DataStore extends EventEmitter { + constructor(opts) { + super(); + this.dataDir = opts.dataDir; + this.cacheFile = path.join(this.dataDir, "last-values.json"); + this.flushInterval = opts.flushInterval ?? 5000; + + this.values = new Map(); + this.dirty = false; + this.timer = null; + } + + load() { + try { + if (fs.existsSync(this.cacheFile)) { + const data = JSON.parse(fs.readFileSync(this.cacheFile, "utf-8")); + for (const [topic, entry] of Object.entries(data)) { + this.values.set(topic, entry); + } + console.log(`Loaded ${this.values.size} cached values from ${this.cacheFile}`); + } + } + catch (err) { + console.error(`Error loading cache file: ${err.message}`); + } + } + + start() { + this.load(); + this.timer = setInterval(() => this.flush(), this.flushInterval); + } + + stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.flush(); + } + + set(topic, value, timestamp) { + const entry = { + value, + timestamp: timestamp ?? new Date().toISOString(), + }; + this.values.set(topic, entry); + this.dirty = true; + this.emit("change", topic, entry); + } + + get(topic) { + const entry = this.values.get(topic); + return entry ?? null; + } + + topics() { + return this.values.keys(); + } + + flush() { + if (!this.dirty) return; + + try { + const data = Object.fromEntries(this.values); + const tmp = this.cacheFile + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2)); + fs.renameSync(tmp, this.cacheFile); + this.dirty = false; + } + catch (err) { + console.error(`Error flushing cache: ${err.message}`); + } + } +} diff --git a/acs-opcua-server-edge/lib/mqtt-client.js b/acs-opcua-server-edge/lib/mqtt-client.js new file mode 100644 index 000000000..9da2533c9 --- /dev/null +++ b/acs-opcua-server-edge/lib/mqtt-client.js @@ -0,0 +1,98 @@ +/* + * Copyright (c) University of Sheffield AMRC 2026. + */ + +/* + * ACS Edge OPC UA Server - MQTT Client + * + * Subscribes to configured UNS topics on the ACS MQTT broker and + * updates the data store with incoming values. The MQTT connection + * is obtained via ServiceClient which handles broker discovery and + * authentication (GSSAPI or basic). + */ + +export class MqttClient { + constructor(opts) { + this.fplus = opts.fplus; + this.topics = opts.topics; + this.dataStore = opts.dataStore; + + this.mqtt = null; + } + + async start() { + const mqtt = await this.fplus.mqtt_client({}); + if (!mqtt) { + throw new Error("Failed to obtain MQTT client from ServiceClient"); + } + this.mqtt = mqtt; + + mqtt.on("authenticated", () => { + console.log("MQTT authenticated, subscribing to topics"); + this.subscribe(); + }); + + mqtt.on("error", (err) => { + console.error(`MQTT error: ${err.message}`); + }); + + mqtt.on("message", (topic, payload) => { + this.handleMessage(topic, payload); + }); + } + + subscribe() { + for (const topic of this.topics) { + console.log(`Subscribing to ${topic}`); + this.mqtt.subscribe(topic, { qos: 1 }, (err) => { + if (err) { + console.error(`Failed to subscribe to ${topic}: ${err.message}`); + } + }); + } + } + + handleMessage(topic, payload) { + try { + let value; + let timestamp; + const text = payload.toString(); + + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + if ("value" in parsed) { + value = parsed.value; + } + else { + value = parsed; + } + + if ("timestamp" in parsed) { + timestamp = parsed.timestamp; + } + } + else { + value = parsed; + } + } + catch { + value = text; + } + + this.dataStore.set(topic, value, timestamp); + } + catch (err) { + console.error(`Error handling message on ${topic}: ${err.message}`); + } + } + + async stop() { + if (this.mqtt) { + await new Promise((resolve) => { + this.mqtt.end(false, {}, resolve); + }); + console.log("Disconnected from MQTT broker"); + } + } +} diff --git a/acs-opcua-server-edge/lib/server.js b/acs-opcua-server-edge/lib/server.js new file mode 100644 index 000000000..169af231a --- /dev/null +++ b/acs-opcua-server-edge/lib/server.js @@ -0,0 +1,206 @@ +/* + * Copyright (c) University of Sheffield AMRC 2026. + */ + +/* + * ACS Edge OPC UA Server - OPC UA Server + * + * Creates an OPC UA server with a dynamic address space. Topic paths + * are mapped to an OPC UA folder hierarchy with Variable nodes at the + * leaves. Nodes are created on the fly as new topics arrive from the + * data store, supporting MQTT wildcard subscriptions. + */ + +import { + OPCUAServer, + Variant, + DataType, + StatusCodes, + DataValue, + makeAccessLevelFlag, + SecurityPolicy, + MessageSecurityMode, + OPCUACertificateManager, +} from "node-opcua"; + +export class Server { + constructor(opts) { + this.dataStore = opts.dataStore; + this.port = opts.port; + this.username = opts.username; + this.password = opts.password; + this.allowAnonymous = opts.allowAnonymous ?? false; + + this.server = null; + /* Track which topics already have OPC UA variable nodes. */ + this.variables = new Map(); + } + + async start() { + /* Both certificate managers must use the writable /data volume. + * Without an explicit userCertificateManager node-opcua will + * try to create one under ~/.config which is read-only in the + * container image. */ + const serverCertificateManager = new OPCUACertificateManager({ + automaticallyAcceptUnknownCertificate: true, + rootFolder: "/data/pki/server", + }); + const userCertificateManager = new OPCUACertificateManager({ + automaticallyAcceptUnknownCertificate: true, + rootFolder: "/data/pki/user", + }); + + this.server = new OPCUAServer({ + port: this.port, + resourcePath: "/UA/ACSEdge", + + buildInfo: { + productName: "ACS Edge OPC UA Server", + buildNumber: "0.0.1", + buildDate: new Date(), + }, + + serverCertificateManager, + userCertificateManager, + + /* For an edge deployment, certificate-based security is + * not practical. We still need SignAndEncrypt with a real + * security policy so that username/password tokens can be + * transmitted securely. SecurityPolicy.None is kept so + * that anonymous browsing without encryption is possible + * when allowAnonymous is true. */ + securityPolicies: [ + SecurityPolicy.None, + SecurityPolicy.Basic256Sha256, + ], + securityModes: [ + MessageSecurityMode.None, + MessageSecurityMode.SignAndEncrypt, + ], + allowAnonymous: this.allowAnonymous, + + userManager: { + isValidUser: (user, pass) => { + return user === this.username + && pass === this.password; + }, + }, + }); + + await this.server.initialize(); + + this.addressSpace = this.server.engine.addressSpace; + this.ns = this.addressSpace.getOwnNamespace(); + + /* Create nodes for any values already in the data store + * (e.g. restored from the persistent cache). */ + for (const topic of this.dataStore.topics()) { + this.ensureVariable(topic); + } + + /* Dynamically create nodes as new topics arrive. */ + this.dataStore.on("change", (topic, entry) => { + const variable = this.ensureVariable(topic); + variable.setValueFromSource( + this.toVariant(entry.value), + StatusCodes.Good, + new Date(entry.timestamp), + ); + }); + + await this.server.start(); + + const endpoint = this.server.getEndpointUrl(); + console.log(`OPC UA server listening at ${endpoint}`); + } + + /* Ensure a topic has a corresponding OPC UA variable node, + * creating the folder hierarchy and variable if needed. */ + ensureVariable(topic) { + if (this.variables.has(topic)) { + return this.variables.get(topic); + } + + const parts = topic.split("/"); + let parent = this.addressSpace.rootFolder.objects; + + /* Walk/create folders for all parts except the last. */ + for (let i = 0; i < parts.length - 1; i++) { + const name = parts[i]; + const existing = parent.getFolderElements?.()?.find( + n => n.browseName.name === name); + + if (existing) { + parent = existing; + } + else { + parent = this.ns.addFolder(parent, { + browseName: name, + displayName: name, + }); + } + } + + const leafName = parts[parts.length - 1]; + + const entry = this.dataStore.get(topic); + const initValue = entry + ? new DataValue({ + value: this.toVariant(entry.value), + statusCode: StatusCodes.Good, + sourceTimestamp: new Date(entry.timestamp), + serverTimestamp: new Date(), + }) + : new DataValue({ + value: new Variant({ dataType: DataType.Null }), + statusCode: StatusCodes.UncertainInitialValue, + sourceTimestamp: new Date(), + serverTimestamp: new Date(), + }); + + const variable = this.ns.addVariable({ + componentOf: parent, + browseName: leafName, + displayName: leafName, + description: `UNS topic: ${topic}`, + dataType: DataType.Variant, + accessLevel: makeAccessLevelFlag("CurrentRead"), + value: initValue, + minimumSamplingInterval: 0, + }); + + this.variables.set(topic, variable); + console.log(`New OPC UA variable: ${topic}`); + return variable; + } + + toVariant(value) { + if (value === null || value === undefined) { + return new Variant({ dataType: DataType.Null }); + } + if (typeof value === "boolean") { + return new Variant({ dataType: DataType.Boolean, value }); + } + if (typeof value === "number") { + if (Number.isInteger(value)) { + return new Variant({ dataType: DataType.Int64, value }); + } + return new Variant({ dataType: DataType.Double, value }); + } + if (typeof value === "string") { + return new Variant({ dataType: DataType.String, value }); + } + /* For objects/arrays, serialise to JSON string. */ + return new Variant({ + dataType: DataType.String, + value: JSON.stringify(value), + }); + } + + async stop() { + if (this.server) { + await this.server.shutdown(); + console.log("OPC UA server stopped"); + } + } +} diff --git a/acs-opcua-server-edge/package.json b/acs-opcua-server-edge/package.json new file mode 100644 index 000000000..09f14bdef --- /dev/null +++ b/acs-opcua-server-edge/package.json @@ -0,0 +1,17 @@ +{ + "name": "acs-opcua-server-edge", + "version": "0.0.1", + "description": "ACS Edge OPC UA Server - exposes UNS topics as OPC UA tags", + "main": "bin/opcua-server.js", + "type": "module", + "scripts": { + "start": "node bin/opcua-server.js" + }, + "keywords": [], + "author": "AMRC", + "license": "MIT", + "dependencies": { + "@amrc-factoryplus/service-client": "file:../lib/js-service-client", + "node-opcua": "^2.145.0" + } +} \ No newline at end of file diff --git a/acs-service-setup/dumps/helm.yaml b/acs-service-setup/dumps/helm.yaml index 179776e5e..f433fb76f 100644 --- a/acs-service-setup/dumps/helm.yaml +++ b/acs-service-setup/dumps/helm.yaml @@ -64,6 +64,14 @@ objects: subclassOf: - !u Auth.Class.EdgeService - !u UNS.Group.Reader + !u Local.Role.OPCUAServer: + name: "OPC UA Server" + memberOf: + - !u Auth.Class.EdgeRole + - !u Edge.Group.EdgeGroup + subclassOf: + - !u Auth.Class.EdgeService + - !u UNS.Group.Reader !u Clusters.Class.SystemHelmChart: !u Local.Chart.EdgeAgent: @@ -78,6 +86,8 @@ objects: name: "Modbus-REST adapter" !u Local.Chart.MQTT: name: "Edge MQTT broker" + !u Local.Chart.OPCUAServer: + name: "OPC UA Server" !u Clusters.Class.K8sResource: !u Clusters.Resource.GitRepo: @@ -218,6 +228,15 @@ configs: authGroup: edgeService: !u Auth.Class.EdgeService unsReader: !u Local.Role.UNSBridge + !u Local.Chart.OPCUAServer: + chart: "opcua-server" + values: + name: "{{name}}" + uuid: "{{uuid}}" + hostname: "{{hostname}}" + authGroup: + edgeService: !u Auth.Class.EdgeService + unsReader: !u Local.Role.OPCUAServer --- service: !u UUIDs.Service.Authentication version: 2 diff --git a/acs-service-setup/lib/local-uuids.js b/acs-service-setup/lib/local-uuids.js index e61f87647..15767677e 100644 --- a/acs-service-setup/lib/local-uuids.js +++ b/acs-service-setup/lib/local-uuids.js @@ -121,12 +121,14 @@ class LocalUUIDs { await this.create_objects( ["Chart", Clusters.Class.HelmChart, - "EdgeAgent", "Cluster", "ModbusRest", "MQTT", "UNSBridge"], + "EdgeAgent", "Cluster", "ModbusRest", "MQTT", "UNSBridge", + "OPCUAServer"], ["Repo", Git.Class.Repo, "HelmCharts"], ["RepoGroup", Git.Class.Group, "Cluster", "Shared"], ["Role", Auth.Class.EdgeRole, - "EdgeAgent", "EdgeFlux", "EdgeKrbkeys", "EdgeMonitor", "EdgeSync", "UNSBridge"], + "EdgeAgent", "EdgeFlux", "EdgeKrbkeys", "EdgeMonitor", "EdgeSync", + "UNSBridge", "OPCUAServer"], ); await this.put_conf(ServiceConfig, this.local); diff --git a/edge-helm-charts/Dockerfile b/edge-helm-charts/Dockerfile index 70ce4ef8a..ddf83f2d7 100644 --- a/edge-helm-charts/Dockerfile +++ b/edge-helm-charts/Dockerfile @@ -33,6 +33,7 @@ RUN sh -x <<'SHELL' -e"s!%%PULLPOLICY%%!${pullpolicy}!g" \ charts/edge-agent/values.yaml \ charts/edge-cluster/values.yaml \ + charts/opcua-server/values.yaml \ SHELL FROM ${base_prefix}-js-run:${base_version} AS run diff --git a/edge-helm-charts/charts/opcua-server/Chart.yaml b/edge-helm-charts/charts/opcua-server/Chart.yaml new file mode 100644 index 000000000..ddb9b43d3 --- /dev/null +++ b/edge-helm-charts/charts/opcua-server/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: opcua-server +version: "0.0.1" +description: "ACS OPC UA Server - exposes select UNS topics as OPC UA tags" diff --git a/edge-helm-charts/charts/opcua-server/README.md b/edge-helm-charts/charts/opcua-server/README.md new file mode 100644 index 000000000..1b5a38f33 --- /dev/null +++ b/edge-helm-charts/charts/opcua-server/README.md @@ -0,0 +1,97 @@ +# OPC UA Server Helm Chart + +## Overview + +The OPC UA Server chart deploys a lightweight OPC UA server that exposes +select UNS (Unified Namespace) topics as OPC UA tags. This enables +integration with OPC UA clients such as SCADA systems, historians, and +other industrial software. + +## Architecture + +The server runs a Node.js application that: +- Connects to the local ACS MQTT broker and subscribes to configured + UNS topics +- Maintains the latest value for each topic in memory, with a + persistent cache on a PVC so values survive pod restarts +- Serves values via an OPC UA server on port 4840 (configurable) +- Uses the UNS topic path directly as the OPC UA node hierarchy + +## Deployment + +### Via ACS Admin UI + +1. In `acs-admin`, navigate to an edge cluster +2. Click "New Edge Deployment" +3. Select "OPC UA Server" from the Chart dropdown +4. Configure values (see below) +5. Click "Create" + +### Required Values + +Provide these in the deployment dialog: + +```yaml +topics: + "UNS/v1/AMRC/#": {} # Topic patterns to subscribe to +``` + +### Optional Values + +Override any of the defaults from `values.yaml`: + +```yaml +local: + host: "mqtt.namespace.svc.cluster.local" + port: 1883 # Default: 1883 +topics: + "UNS/v1/AMRC/#": {} + "UNS/v1/Utilities/#": {} # Multiple topic patterns supported +opcua: + port: 4840 # Default: 4840 + username: "customuser" # Default: "opcua" +nodePort: 30484 # Optional: fixed NodePort (else random) +limits: + cpu: "150m" # Default: "100m" + memory: "256Mi" # Default: "128Mi" +tolerations: + specific: [] # Host-specific tolerations + floating: [] # Floating pod tolerations +``` + +### Automatic Values + +These are populated automatically by the ACS deployment system: + +- `name` — deployment name (from dialog) +- `uuid` — deployment UUID (generated by ConfigDB) +- `hostname` — target Kubernetes node (from dialog, optional) +- `realm` — Kerberos realm (from cluster config) +- `cluster` — cluster name (from edge-sync environment) +- `authGroup.edgeService` — edge service account class +- `authGroup.unsReader` — OPC UA Server edge role (grants UNS read permissions) + +### Value Merge Behavior + +Values are merged in this order (last wins): +1. Cluster base values (realm, cluster, directory_url) +2. HelmTemplate defaults (name, uuid, hostname, authGroup) +3. Your provided values (local, topics, opcua, etc.) + +## Current Status + +✅ **Working Features:** +- KerberosKey creation with proper account setup +- Authentication to local ACS MQTT broker with optional TLS +- Subscription to configured UNS topics (wildcards supported) +- Dynamic OPC UA node creation as new topics arrive +- Sub-millisecond value update propagation (event-driven, not polling) +- OPC UA server with username/password authentication +- Auto-generated OPC UA client password stored in a Secret +- Persistent last-value cache across pod restarts +- NodePort Service for external OPC UA client access +- Configurable topic filters (MQTT wildcard patterns) + +⚠️ **Known Limitations:** +- Tags report `null` until a UNS message is received for that topic +- OPC UA client authentication is username/password only (no certificates) diff --git a/edge-helm-charts/charts/opcua-server/templates/_helpers.tpl b/edge-helm-charts/charts/opcua-server/templates/_helpers.tpl new file mode 100644 index 000000000..8f2fff9ae --- /dev/null +++ b/edge-helm-charts/charts/opcua-server/templates/_helpers.tpl @@ -0,0 +1,14 @@ +{{- define "opcua-server.image" -}} +{{- $root := index . 0 -}} +{{- $key := index . 1 -}} +{{- $image := $root.Values.image -}} +{{- $spec := merge (get $image $key) $image.default -}} +image: "{{ $spec.registry }}/{{ $spec.repository }}:{{ $spec.tag }}" +imagePullPolicy: {{ $spec.pullPolicy }} +{{- end }} + +{{- define "opcua-server.k8sname" }} + {{- $lc := . | lower }} + {{- $rp := regexReplaceAllLiteral "[^a-z0-9-]+" $lc "-" }} + {{- $rp | trimAll "-" }} +{{- end }} diff --git a/edge-helm-charts/charts/opcua-server/templates/configmap.yaml b/edge-helm-charts/charts/opcua-server/templates/configmap.yaml new file mode 100644 index 000000000..838601997 --- /dev/null +++ b/edge-helm-charts/charts/opcua-server/templates/configmap.yaml @@ -0,0 +1,17 @@ +{{- $k8sname := include "opcua-server.k8sname" .Values.name }} +apiVersion: v1 +kind: ConfigMap +metadata: + namespace: {{ .Release.Namespace }} + name: opcua-server-{{ $k8sname }} + labels: + factory-plus.app: opcua-server + factory-plus.uuid: {{ .Values.uuid }} +data: + config.json: | + { + "opcua": { + "port": {{ .Values.opcua.port }} + }, + "topics": {{ keys .Values.topics | toJson }} + } diff --git a/edge-helm-charts/charts/opcua-server/templates/deployment.yaml b/edge-helm-charts/charts/opcua-server/templates/deployment.yaml new file mode 100644 index 000000000..0259e6d68 --- /dev/null +++ b/edge-helm-charts/charts/opcua-server/templates/deployment.yaml @@ -0,0 +1,98 @@ +{{- $k8sname := include "opcua-server.k8sname" .Values.name }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: opcua-server-{{ $k8sname }} + namespace: {{ .Release.Namespace }} + annotations: + factory-plus.app/disable-backoff: "true" + labels: + factory-plus.app: opcua-server + factory-plus.uuid: {{ .Values.uuid }} + factory-plus.name: {{ .Values.name }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + factory-plus.app: opcua-server + factory-plus.uuid: {{ .Values.uuid }} + template: + metadata: + labels: + factory-plus.app: opcua-server + factory-plus.uuid: {{ .Values.uuid }} + factory-plus.name: {{ .Values.name }} + annotations: + cluster-autoscaler.kubernetes.io/safe-to-evict: "true" + spec: + restartPolicy: Always +{{- if .Values.hostname }} + nodeSelector: + kubernetes.io/hostname: {{ .Values.hostname | quote }} + tolerations: {{ .Values.tolerations.specific | toYaml | nindent 8 }} +{{- else }} + tolerations: {{ .Values.tolerations.floating | toYaml | nindent 8 }} +{{- end }} + volumes: + - name: config + configMap: + name: opcua-server-{{ $k8sname }} + - name: data + persistentVolumeClaim: + claimName: opcua-server-data-{{ $k8sname }} + containers: + - name: opcua-server +{{ list . "opcua" | include "opcua-server.image" | indent 10 }} + ports: + - name: opcua + containerPort: {{ .Values.opcua.port }} + protocol: TCP + env: + - name: SERVICE_USERNAME + value: "opcua1/{{ .Values.cluster }}/{{ .Values.name }}" + - name: SERVICE_PASSWORD + valueFrom: + secretKeyRef: + name: opcua-server-mqtt-secrets.{{ $k8sname }} + key: keytab + - name: DIRECTORY_URL + value: "{{ .Values.directory_url }}" + - name: OPCUA_USERNAME + valueFrom: + secretKeyRef: + name: opcua-server-opcua-username.{{ $k8sname }} + key: username + - name: OPCUA_PASSWORD + valueFrom: + secretKeyRef: + name: opcua-server-opcua-password.{{ $k8sname }} + key: password + - name: CONFIG_FILE + value: "/config/config.json" + - name: DATA_DIR + value: "/data" + startupProbe: + tcpSocket: + port: opcua + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 60 + livenessProbe: + tcpSocket: + port: opcua + periodSeconds: 30 + failureThreshold: 3 + resources: + limits: + memory: {{ .Values.limits.memory | quote }} + requests: + cpu: {{ .Values.limits.cpu | quote }} + memory: {{ .Values.limits.memory | quote }} + volumeMounts: + - mountPath: /config + name: config + readOnly: true + - mountPath: /data + name: data diff --git a/edge-helm-charts/charts/opcua-server/templates/kerberos-key.yaml b/edge-helm-charts/charts/opcua-server/templates/kerberos-key.yaml new file mode 100644 index 000000000..93292a9ae --- /dev/null +++ b/edge-helm-charts/charts/opcua-server/templates/kerberos-key.yaml @@ -0,0 +1,15 @@ +{{- $k8sname := include "opcua-server.k8sname" .Values.name }} +apiVersion: factoryplus.app.amrc.co.uk/v1 +kind: KerberosKey +metadata: + namespace: {{ .Release.Namespace }} + name: opcua-server.{{ $k8sname }} +spec: + type: Password + principal: opcua1/{{ .Values.cluster }}/{{ .Values.name }}@{{ .Values.realm }} + secret: opcua-server-mqtt-secrets.{{ $k8sname }}/keytab + account: + class: {{ .Values.authGroup.edgeService | quote }} + name: "OPC UA Server: {{ .Values.name }}" + groups: + - {{ .Values.authGroup.unsReader | quote }} diff --git a/edge-helm-charts/charts/opcua-server/templates/pvc.yaml b/edge-helm-charts/charts/opcua-server/templates/pvc.yaml new file mode 100644 index 000000000..d65757975 --- /dev/null +++ b/edge-helm-charts/charts/opcua-server/templates/pvc.yaml @@ -0,0 +1,18 @@ +{{- $k8sname := include "opcua-server.k8sname" .Values.name }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + namespace: {{ .Release.Namespace }} + name: opcua-server-data-{{ $k8sname }} + labels: + factory-plus.app: opcua-server + factory-plus.uuid: {{ .Values.uuid }} +spec: + accessModes: + - ReadWriteOnce +{{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} +{{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} diff --git a/edge-helm-charts/charts/opcua-server/templates/secret.yaml b/edge-helm-charts/charts/opcua-server/templates/secret.yaml new file mode 100644 index 000000000..a049288e2 --- /dev/null +++ b/edge-helm-charts/charts/opcua-server/templates/secret.yaml @@ -0,0 +1,27 @@ +{{- $k8sname := include "opcua-server.k8sname" .Values.name }} +{{- $userSecret := printf "opcua-server-opcua-username.%s" $k8sname }} +{{- $passSecret := printf "opcua-server-opcua-password.%s" $k8sname }} +apiVersion: v1 +kind: Secret +metadata: + namespace: {{ .Release.Namespace }} + name: {{ $userSecret }} + labels: + factory-plus.app: opcua-server + factory-plus.uuid: {{ .Values.uuid }} +type: Opaque +stringData: + username: {{ .Values.opcua.username | quote }} +--- +apiVersion: factoryplus.app.amrc.co.uk/v1 +kind: LocalSecret +metadata: + namespace: {{ .Release.Namespace }} + name: {{ $passSecret }}.password + labels: + factory-plus.app: opcua-server + factory-plus.uuid: {{ .Values.uuid }} +spec: + format: Password + secret: {{ $passSecret }} + key: password diff --git a/edge-helm-charts/charts/opcua-server/templates/service.yaml b/edge-helm-charts/charts/opcua-server/templates/service.yaml new file mode 100644 index 000000000..99e1d10e0 --- /dev/null +++ b/edge-helm-charts/charts/opcua-server/templates/service.yaml @@ -0,0 +1,22 @@ +{{- $k8sname := include "opcua-server.k8sname" .Values.name }} +apiVersion: v1 +kind: Service +metadata: + namespace: {{ .Release.Namespace }} + name: opcua-server-{{ $k8sname }} + labels: + factory-plus.app: opcua-server + factory-plus.uuid: {{ .Values.uuid }} +spec: + type: NodePort + selector: + factory-plus.app: opcua-server + factory-plus.uuid: {{ .Values.uuid }} + ports: + - name: opcua + protocol: TCP + port: {{ .Values.opcua.port }} + targetPort: opcua +{{- if .Values.nodePort }} + nodePort: {{ .Values.nodePort }} +{{- end }} diff --git a/edge-helm-charts/charts/opcua-server/values.yaml b/edge-helm-charts/charts/opcua-server/values.yaml new file mode 100644 index 000000000..ffffb6ea1 --- /dev/null +++ b/edge-helm-charts/charts/opcua-server/values.yaml @@ -0,0 +1,67 @@ +# OPC UA Server configuration +name: "opcua-server" # Server name (required, override this) +uuid: "00000000-0000-0000-0000-000000000000" # Server UUID (required, override this) + +# Kerberos realm for local authentication +realm: "REALM.EXAMPLE.COM" # Override this + +# Cluster name for principal construction (e.g., opcua1//) +cluster: "cluster-name" # Override this + +# Auth group to add the server account to +authGroup: + edgeService: "" # Auth.Class.EdgeService - set by deployment + unsReader: "d6a4d87c-cd02-11ef-9a87-2f86ebe5ee08" # UNS.Group.Reader - grants UNS read permissions + +# Directory URL for ServiceClient service discovery +directory_url: "http://directory.namespace.svc.cluster.local" + +# UNS topics to expose via OPC UA (object keyed by topic filter) +# The topic path is used directly as the OPC UA node hierarchy +# Values are currently ignored but reserved for future settings +topics: {} +# "UNS/v1/site/area/workcentre/Axes/1/Position/Actual": {} +# "UNS/v1/site/area/workcentre/Temperature/Current": {} + +# OPC UA server configuration +opcua: + port: 4840 + # Username for OPC UA client authentication. + # A random password is generated by the LocalSecret operator and + # stored alongside the username in the opcua-creds Secret. + username: "opcua" + +# Image configuration +image: + default: + registry: %%REGISTRY%% + tag: %%TAG%% + pullPolicy: %%PULLPOLICY%% + opcua: + repository: acs-opcua-server-edge + +# Persistent storage for last-value cache +persistence: + size: "64Mi" + storageClass: "" + +# Tolerations for pod scheduling +tolerations: + # Tolerations to apply to pods deployed to a specific host + specific: + - key: factoryplus.app.amrc.co.uk/specialised + operator: Exists + # Tolerations to apply to floating pods + floating: [] + +# This deploys to a specific host +# hostname: foo + +# NodePort for external OPC UA access +# If not set, a random NodePort will be assigned +# nodePort: 30484 + +# Resource limits +limits: + cpu: "100m" + memory: "128Mi"