From 5d400a55e65bd22932cb5314302e54ca0454f7ac Mon Sep 17 00:00:00 2001 From: kzangeli Date: Fri, 9 Oct 2026 20:59:26 +0200 Subject: [PATCH 1/2] Findings of the first bridge built from the template (corCkanBridge): make test no longer reaps every coraine on the host, a copy no longer pushes to the template, the TODO grep finds the README and the CI, and a refused Channel says why in the plugin's words - make test: COR_TEST_NO_REAP=on - corTest's start-up reaping (killall -9 coraine) is for coraine's own suite; the tests stop their broker by pid - starting a bridge: an export (git archive) and git init, not cp -r - which carried the template's .git, its history and an origin pushing to corBridgeTemplate; rename.sh warns when origin is the template - grep -rn --exclude-dir=.git TODO . - the README (marked: its intro, "Where each piece goes" and "Configuration" describe the example transport) and ci.yml carry TODOs too - channelStatusIn (bridge ABI 12, under #if: builds against older headers): the transport's own reason on a refused Channel - bridge_tmpl's '../escape' shows it; the README says how and when - README: DataChannels (a transport whose messages are rows; coraine COR_FEATURE_DATA_CHANNELS), publish NULL for a bridge that only reads, the catch-all entity on unless "defaultEntity": false Co-Authored-By: Claude Opus 5.5 --- README.md | 29 ++++++++++++++++++--- makefile | 6 ++++- rename.sh | 9 +++++++ src/tmplRegister.c | 37 +++++++++++++++++++++++++-- src/tmplTransport.c | 11 +++++--- src/tmplTransport.h | 3 ++- test/funcTests/cases/bridge_tmpl.test | 4 +-- 7 files changed, 85 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4bd5b4a..f7d3725 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # corBridgeTemplate + A **bridge plugin** for the coraine NGSI-LD broker: `tmpl.so`, a working bridge with a tiny example transport, to be turned into a real one - OPC UA, CKAN, Philips Hue, anything the broker does not speak. It builds, loads into a broker, and carries a value each way before a line of its own is @@ -8,12 +10,15 @@ written. ## Starting a bridge from this template - cp -r corBridgeTemplate corOpcuaBridge && cd corOpcuaBridge # or a fresh clone / export + mkdir corOpcuaBridge && git -C corBridgeTemplate archive HEAD | tar -x -C corOpcuaBridge + cd corOpcuaBridge && git init -b main ./rename.sh opcua # tmpl -> opcua everywhere: opcua.so, src/opcuaRegister.c, ... make && make test # builds, and a broker moves a value both ways through it - grep -rn TODO makefile src test # where the protocol goes + grep -rn --exclude-dir=.git TODO . # where the protocol goes - the README and the CI too `rename.sh` takes the bridge's alias: lowercase letters and digits. It removes this section and itself. +An export, not a copy of the checkout: `cp -r` takes the template's `.git` along - its history, and an +`origin` that pushes to corBridgeTemplate (`rename.sh` warns about that). ## What a bridge is @@ -30,6 +35,12 @@ A bridge connects the broker to a foreign transport. It **knows nothing about en - **Channel** - *this endpoint <-> this entity attribute, this direction*. Channels live in the broker, which configures them from the `--bridgeConfig` file and shows them on `GET /ngsi-ld/v1/channels`. A Channel the bridge refuses stays, `dormant`, with the reason. +- **DataChannel** - for a transport whose messages are **records** (a CKAN row, a CSV line: one + JSON object, one member per column) naming their entity in a column: *this endpoint -> the entities + each record makes*, inbound only - the `dataChannels` section of the file, `GET + /ngsi-ld/v1/dataChannels` (coraine `COR_FEATURE_DATA_CHANNELS`). Not a Channel; the plugin's side is + the same `sampleIn`, one record a call, its time 0 when the record has none (no `observedAt`); + corCkanBridge is the example. - **Bridge** - one transport instance (one connection, one server), `GET /ngsi-ld/v1/bridges`. `--bridges tmpl` loads the plugin; it is never loaded at runtime or inferred from configuration. - **In** - a value arrives on an endpoint; the plugin calls `brokerP->sampleIn(alias, endpoint, json, @@ -50,6 +61,7 @@ offers). In the order the broker uses them: | `init(configFile, brokerP)` | broker, late in startup | keeps `brokerP`; reads its member of the `--bridgeConfig` file; brings the transport up | | `channelAddInfo(endpoint, kind, direction, info)` | broker | per Channel, after `init`: checks the endpoint and `channelInfo`, subscribes | | `brokerP->sampleIn(...)` | the plugin's own | a value arrived | +| `brokerP->channelStatusIn(...)` | the plugin's own, or inside `channelAddInfo` | ABI 12: a Channel carries, or not and why - shown as its `status` and `statusReason` | | `publish(endpoint, json)` | broker, inside the request | hands the value to the transport and returns - must not block | | `channelDel(endpoint)` | broker | stops carrying an endpoint (not called yet: Channels come from the file only) | | `close()` | broker, at shutdown | returns only when no plugin thread can call `sampleIn` any more | @@ -58,7 +70,11 @@ offers). In the order the broker uses them: - **NULL means "not supported".** A slot the plugin leaves NULL is answered `BRIDGE_UNSUPPORTED` by the broker. Optional slots: `args` (command-line options of the plugin's own), services and actions (ABI 2-4), `notifySchemes` + `notify` (notifications to a URI scheme - corMqttBridge), Service - Execution (ABI 11). + Execution (ABI 11) - and `publish`, for a bridge that only reads (its Channels inbound). +- **Why a Channel is dormant** is the plugin's to say: through `channelStatusIn` (ABI 12) before + `channelAddInfo` refuses it, and at any time after - a peer that does not have the endpoint, a peer + that cannot be reached. Without it, `GET /ngsi-ld/v1/channels` shows only the broker's "its address + or channelInfo does not fit the transport", and anything learned later shows nowhere but the log. - **The ABI handshake.** The broker zeroes the driver, writes *its* `BRIDGE_ABI_VERSION` into `abiVersion` and calls `bridgeRegister`. The struct is the broker's, at the size of its header: the plugin fills in only the slots that version has (`channelAddInfo` from ABI 9), then writes its own @@ -100,6 +116,9 @@ broker's - it makes the Channels; everything else in the member is the plugin's. - A Channel's direction is `both` and every configured attribute exists from startup, with the value `"uninitialized"` until the first sample. +- The **catch-all entity** is on unless the section says `"defaultEntity": false`: a sample on an + endpoint no Channel claims is stored on `urn:ngsi-ld:tmpl:default`. A transport that hands over only + the endpoints it was given never reaches it; one that hands over everything it hears does. - `channelInfo` - how to read or carry one endpoint - is checked by the broker for its shape (an array of `{"key", "value"}` strings) and never for its keys: an unknown key is the plugin's to refuse (`BRIDGE_BAD_INPUT`). @@ -126,7 +145,9 @@ a broker built with `coraine-build `: `make COR_LIBS=`, and Starts `coraine` (from `PATH`: the coraine package, or `. /install/env` after `coraine-build`) on port 19026 with ramDB - no database server - loads `./tmpl.so` by path, and runs -`test/funcTests/cases/*.test` with corTest (`PATH`, or `CORTEST=`). `TMPL_BROKER`, `TMPL_PORT` and +`test/funcTests/cases/*.test` with corTest (`PATH`, or `CORTEST=`), with `COR_TEST_NO_REAP=on`: +corTest's start-up reaping is for coraine's own suite - it kills every `coraine` process on the host - +and these tests stop their broker by its pid. `TMPL_BROKER`, `TMPL_PORT` and `TMPL_TEST_DIR` override; the broker's log is `/tmp/tmpl-bridge-test/broker.log`. ## Running diff --git a/makefile b/makefile index 2b3177f..3735d9a 100644 --- a/makefile +++ b/makefile @@ -114,8 +114,12 @@ contract: | $(COR_LIBS)/corBridge/BridgeDriver.h # CORTEST ?= $(shell command -v corTest || echo $(COR_LIBS)/corTest/corTest) +# COR_TEST_NO_REAP=on: corTest's start-up reaping (killall -9 coraine, and coraine's mock clients) is +# for coraine's own suite - here it would take down every broker on the host. These tests stop their +# own broker by pid. +# test: all - $(CORTEST) --noColor + COR_TEST_NO_REAP=on $(CORTEST) --noColor clean: rm -f src/*.o src/*.d *.so *~ .flags diff --git a/rename.sh b/rename.sh index 7c92530..1510a36 100755 --- a/rename.sh +++ b/rename.sh @@ -32,6 +32,15 @@ self=$(basename "$0") [ -f src/tmplRegister.c ] || { echo "$0: no src/tmplRegister.c - renamed already?" >&2; exit 1; } +# +# A copy of the template's checkout carries its .git: the template's history, and an origin that pushes +# to corBridgeTemplate +# +if origin=$(git config --get remote.origin.url 2>/dev/null) && [[ "$origin" == *corBridgeTemplate* ]]; then + echo "$0: warning: this checkout's origin is the template ($origin) - a push goes there." >&2 + echo " start from an export instead: git -C corBridgeTemplate archive HEAD | tar -x -C ; git init" >&2 +fi + Name="$(tr '[:lower:]' '[:upper:]' <<< "${name:0:1}")${name:1}" NAME=$(tr '[:lower:]' '[:upper:]' <<< "$name") repo="cor${Name}Bridge" diff --git a/src/tmplRegister.c b/src/tmplRegister.c index ad40fac..dc389a7 100644 --- a/src/tmplRegister.c +++ b/src/tmplRegister.c @@ -241,6 +241,29 @@ static bool channelInfoText(const char* info, bool* textP, const char** whyP) +// ----------------------------------------------------------------------------- +// +// channelRefused - say on the Channel why it is not carried: GET /ngsi-ld/v1/channels shows it +// +// channelStatusIn (ABI 12) - from inside channelAddInfo, before it refuses, or at any time later from a +// thread of the plugin's own: a peer that turns out not to have the endpoint, one that cannot be +// reached (BridgeChannelDormant), one that is back (BridgeChannelAvailable). Built against corBridge +// headers from before ABI 12, or loaded by a broker without it, the reason is in the log only and the +// Channel shows the broker's own words. +// +static void channelRefused(const char* endpoint, const char* why) +{ +#if BRIDGE_ABI_VERSION >= 12 + if ((brokerP != NULL) && (brokerP->abiVersion >= 12) && (brokerP->channelStatusIn != NULL)) + brokerP->channelStatusIn("tmpl", endpoint, BridgeChannelDormant, why); +#else + (void) endpoint; + (void) why; +#endif +} + + + // ----------------------------------------------------------------------------- // // tmplChannelAddInfo - start carrying an endpoint, ABI 9 @@ -258,16 +281,26 @@ static int tmplChannelAddInfo(const char* endpoint, BridgeChannelKind kind, Brid if (kind != BridgeChannelTopic) { COR_E("tmpl: '%s': this bridge carries topics only", endpoint); + channelRefused(endpoint, "this bridge carries topics only"); return BRIDGE_UNSUPPORTED; } if (channelInfoText(info, &text, &why) == false) { - COR_E("tmpl: '%s': %s", endpoint, (why != NULL) ? why : "channelInfo is not an array of {key, value}"); + if (why == NULL) + why = "channelInfo is not an array of {key, value}"; + + COR_E("tmpl: '%s': %s", endpoint, why); + channelRefused(endpoint, why); return BRIDGE_BAD_INPUT; } - return tmplTransportEndpointAdd(endpoint, direction, text); + int r = tmplTransportEndpointAdd(endpoint, direction, text, &why); + + if (r != BRIDGE_OK) + channelRefused(endpoint, (why != NULL) ? why : "the transport would not carry it"); + + return r; } diff --git a/src/tmplTransport.c b/src/tmplTransport.c index 73a4a63..9bd95c3 100644 --- a/src/tmplTransport.c +++ b/src/tmplTransport.c @@ -314,20 +314,22 @@ void tmplTransportClose(void) // An endpoint is a relative path of letters, digits, '_', '-', '.' and '/', with no '..' - a Channel // cannot reach out of the directory. // -int tmplTransportEndpointAdd(const char* endpoint, BridgeDirection direction, bool text) +int tmplTransportEndpointAdd(const char* endpoint, BridgeDirection direction, bool text, const char** whyP) { const char* allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-./"; if ((endpoint[0] == 0) || (endpoint[0] == '/') || (endpoint[strspn(endpoint, allowed)] != 0) || (strstr(endpoint, "..") != NULL)) { - COR_E("tmpl: '%s' is not an endpoint - a relative file path, no '..'", endpoint); + *whyP = "not an endpoint of this transport - a relative file path, no '..'"; + COR_E("tmpl: '%s': %s", endpoint, *whyP); return BRIDGE_BAD_INPUT; } char path[PATH_MAX]; if (snprintf(path, sizeof(path), "%s/%s", dir, endpoint) >= (int) sizeof(path)) { - COR_E("tmpl: '%s': the path is too long", endpoint); + *whyP = "the path is too long"; + COR_E("tmpl: '%s': %s", endpoint, *whyP); return BRIDGE_BAD_INPUT; } @@ -351,7 +353,8 @@ int tmplTransportEndpointAdd(const char* endpoint, BridgeDirection direction, bo if (freeP == NULL) { pthread_mutex_unlock(&mtx); - COR_E("tmpl: '%s': no room for another endpoint", endpoint); + *whyP = "no room for another endpoint"; + COR_E("tmpl: '%s': %s", endpoint, *whyP); return BRIDGE_ERR; } diff --git a/src/tmplTransport.h b/src/tmplTransport.h index 403f36d..1fd4698 100644 --- a/src/tmplTransport.h +++ b/src/tmplTransport.h @@ -78,10 +78,11 @@ extern void tmplTransportClose(void); // tmplTransportEndpointAdd - start carrying an endpoint // // @param text the endpoint carries plain text, not JSON: an arriving value becomes a JSON string +// @param whyP on a refusal, why - one line for a person, static text // // @return BRIDGE_OK, BRIDGE_BAD_INPUT (not an endpoint of this transport) or BRIDGE_ERR // -extern int tmplTransportEndpointAdd(const char* endpoint, BridgeDirection direction, bool text); +extern int tmplTransportEndpointAdd(const char* endpoint, BridgeDirection direction, bool text, const char** whyP); diff --git a/test/funcTests/cases/bridge_tmpl.test b/test/funcTests/cases/bridge_tmpl.test index da3e623..3fdc310 100644 --- a/test/funcTests/cases/bridge_tmpl.test +++ b/test/funcTests/cases/bridge_tmpl.test @@ -12,7 +12,7 @@ # echo the bridge's own write does not come back as a sample - samplesIn stays 0 # # An endpoint the transport cannot carry ('../escape') is refused: the Channel stays, dormant, with -# the reason. The device is a directory of files (see src/tmplTransport.c). +# the transport's reason (channelStatusIn, bridge ABI 12). The device is a directory of files (see src/tmplTransport.c). # # TODO: in a real bridge the device is the protocol's peer - a test server the INIT section starts # (corMqttBridge's tests start mosquitto) - and deviceWrite / deviceAwait talk to it. @@ -107,7 +107,7 @@ rm -rf "${TMPL_TEST_DIR:?}/device" "$TMPL_TEST_DIR/bridges.json" # broker. 04. GET /channels - '../escape' dormant and why; setpoint sent once and not taken back in ========================================================================================== -../escape dormant samplesIn=0 samplesOut=0 - the bridge would not carry it: its address or channelInfo does not fit the transport +../escape dormant samplesIn=0 samplesOut=0 - not an endpoint of this transport - a relative file path, no '..' tank1/level available samplesIn=1 samplesOut=0 tank1/setpoint available samplesIn=0 samplesOut=1 tank1/state available samplesIn=1 samplesOut=0 From bbfd6a92c7c93c8b3b31daca22d90058b30c004a Mon Sep 17 00:00:00 2001 From: kzangeli Date: Wed, 14 Oct 2026 15:00:16 +0200 Subject: [PATCH 2/2] ci.yml: CORJSON for the functional tests - corTest refuses to run without the corJson tool, built but not on PATH here (as coraine's and corCkanBridge's ci.yml set it) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00b5566..49bdbcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,12 @@ jobs: - name: Functional tests working-directory: stack/bridge + # + # corTest sorts JSON bodies with the corJson tool and refuses to run without it: built with corJson, + # not on PATH here (as in coraine's ci.yml) + # + env: + CORJSON: ${{ github.workspace }}/stack/corJson/bin/corJson run: make test CORTEST=../corLibs/bin/corTest - name: Keep what a failing test left