From 00f8237db7feb9d79f03aac502b2e26bd391ec64 Mon Sep 17 00:00:00 2001 From: bartvanbos Date: Fri, 6 Nov 2026 01:26:04 +0100 Subject: [PATCH 1/2] feat: own-stack image of go-sunspec, layout of the other harnesses (v0.2.0) - own/go-sunspec/ and the manual "Own stack (go-sunspec)" workflow build ghcr.io/otfabric/sunspec-interop-otfabric from the latest go-sunspec release (ADR-0012): not a reference, not in a release or manifest.json. - scripts/lib.sh (image_of honours ADAPTER_IMAGE_), smoke.sh and interop.sh ; every client against every server on tests/cases.tsv; an undeclared client operation is unsupported; results.json takes independent from the image. - Contract 1.1: upstream.independentEngine; pysunspec2 declares true. - R1 grep also names go-modbus and modbusctl; REQUIREMENTS.md, compose.yaml, versions.yaml own_implementations, docs, v0.2.0 notes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 +- .github/workflows/own-go-sunspec.yml | 194 +++++++++++ AGENTS.md | 8 +- CONTRIBUTING.md | 4 +- Makefile | 14 +- README.md | 11 +- RELEASE.md | 23 ++ REQUIREMENTS.md | 102 ++++++ TESTING.md | 5 +- THIRD_PARTY_NOTICES.md | 15 +- adapters/pysunspec2/sunspec_interop/main.py | 3 +- compose.yaml | 26 ++ docs/CAPABILITIES.md | 23 +- docs/COMPATIBILITY.md | 6 + docs/CONTAINER_CONTRACT.md | 10 +- own/go-sunspec/Dockerfile | 94 ++++++ own/go-sunspec/Makefile | 29 ++ own/go-sunspec/README.md | 102 ++++++ own/go-sunspec/client.go | 186 +++++++++++ own/go-sunspec/fixture.go | 258 +++++++++++++++ own/go-sunspec/go.mod | 10 + own/go-sunspec/go.sum | 6 + own/go-sunspec/main.go | 222 +++++++++++++ own/go-sunspec/main_test.go | 127 +++++++ own/go-sunspec/server.go | 92 ++++++ schemas/capabilities.schema.json | 4 + scripts/interop.sh | 31 ++ scripts/lib.sh | 26 ++ scripts/selftest.py | 348 +++++++++++++------- scripts/smoke.sh | 23 ++ tests/cases.tsv | 16 + tests/known-bugs.tsv | 2 +- versions.yaml | 32 +- 33 files changed, 1906 insertions(+), 153 deletions(-) create mode 100644 .github/workflows/own-go-sunspec.yml create mode 100644 REQUIREMENTS.md create mode 100644 compose.yaml create mode 100644 own/go-sunspec/Dockerfile create mode 100644 own/go-sunspec/Makefile create mode 100644 own/go-sunspec/README.md create mode 100644 own/go-sunspec/client.go create mode 100644 own/go-sunspec/fixture.go create mode 100644 own/go-sunspec/go.mod create mode 100644 own/go-sunspec/go.sum create mode 100644 own/go-sunspec/main.go create mode 100644 own/go-sunspec/main_test.go create mode 100644 own/go-sunspec/server.go create mode 100755 scripts/interop.sh create mode 100644 scripts/lib.sh create mode 100755 scripts/smoke.sh create mode 100644 tests/cases.tsv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f348abe..9ad2441 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,9 +46,12 @@ jobs: run: make lint - name: No reference to a library under test # REQUIREMENTS.md R1: adapters, fixtures, scripts and schemas must not - # know any consumer. Documentation may name consumers as examples. + # know any consumer. Documentation may name consumers as examples, and + # own/ with its workflow is the one exception (own/go-sunspec/README.md). + # go-sunspec runs on go-modbus, so neither may appear; "modbus" alone + # is the transport and stays allowed. run: | - if grep -rInE 'otfabric/go-|go-sunspec|sunspecctl' adapters fixtures schemas scripts tests Makefile compose.yaml; then + if grep -rInE 'otfabric/go-|go-sunspec|go-modbus|sunspecctl|modbusctl' adapters fixtures schemas scripts tests Makefile compose.yaml; then echo "::error::consumer-specific reference found"; exit 1 fi diff --git a/.github/workflows/own-go-sunspec.yml b/.github/workflows/own-go-sunspec.yml new file mode 100644 index 0000000..aac1c66 --- /dev/null +++ b/.github/workflows/own-go-sunspec.yml @@ -0,0 +1,194 @@ +name: Own stack (go-sunspec) +run-name: "Build sunspec-interop-otfabric from the latest go-sunspec, by @${{ github.actor }} on ${{ github.ref_name }}" + +# Manual only. Builds ghcr.io/otfabric/sunspec-interop-otfabric from the latest +# go-sunspec release, tests it with the self-tests and references of the latest +# harness release and, when run from main, publishes it as : +# and :latest. +# +# Nothing is committed, and nothing else waits for this: no pin to bump, no +# release, no schedule. So no release loop can form (own/go-sunspec/README.md). +# The harness Release workflow and manifest.json do not contain this image. +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: own-go-sunspec + cancel-in-progress: false + +env: + IMAGE: ghcr.io/otfabric/sunspec-interop-otfabric + BASE_REGISTRY: mirror.gcr.io/library + +jobs: + version: + name: Resolve versions + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + upstream: ${{ steps.version.outputs.upstream }} + revision: ${{ steps.version.outputs.revision }} + harness: ${{ steps.version.outputs.harness }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: version + name: The latest go-sunspec, and the harness release to test against + env: + SHA: ${{ github.sha }} + REF: ${{ github.ref_name }} + run: | + upstream=$(git ls-remote --tags --refs https://github.com/otfabric/go-sunspec.git 'v[0-9]*' \ + | sed 's,.*refs/tags/,,' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1) + [ -n "${upstream}" ] || { echo "::error::no go-sunspec release found"; exit 1; } + # An annotated tag has a peeled line, with the commit. + revision=$(git ls-remote --tags https://github.com/otfabric/go-sunspec.git "refs/tags/${upstream}^{}" | cut -f1) + [ -n "${revision}" ] || revision=$(git ls-remote --tags https://github.com/otfabric/go-sunspec.git "refs/tags/${upstream}" | cut -f1) + harness=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1) + [ -n "${harness}" ] || { echo "::error::no harness release found"; exit 1; } + if [ "${REF}" = "main" ]; then publish="yes: ${IMAGE}:${upstream} and ${IMAGE}:latest"; else publish="no: not run from main"; fi + { + echo "upstream=${upstream}" + echo "revision=${revision}" + echo "harness=${harness}" + } >> "$GITHUB_OUTPUT" + { + echo "## Versions" + echo + echo "| | |" + echo "|---|---|" + echo "| go-sunspec (latest release) | \`${upstream}\` |" + echo "| go-sunspec commit | \`${revision}\` |" + echo "| Image tag | \`${upstream}\` |" + echo "| Reference images (latest harness release) | \`${harness}\` |" + echo "| This repository | \`${REF}\` at \`${SHA}\` |" + echo "| Publish | ${publish} |" + } | tee -a "$GITHUB_STEP_SUMMARY" + + build: + name: Build, test (${{ matrix.arch }}) + needs: version + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + permissions: + contents: read + packages: write + strategy: + fail-fast: true + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + env: + VERSION: ${{ needs.version.outputs.harness }} + UPSTREAM: ${{ needs.version.outputs.upstream }} + REVISION: ${{ needs.version.outputs.revision }} + ADAPTER_IMAGE_otfabric: ghcr.io/otfabric/sunspec-interop-otfabric:ci + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - run: pip install jsonschema + + - name: Build from go-sunspec ${{ needs.version.outputs.upstream }} + run: | + scripts/retry.sh docker build --progress=plain --build-arg BASE_REGISTRY="${BASE_REGISTRY}" \ + --build-arg UPSTREAM_VERSION="${UPSTREAM}" --build-arg UPSTREAM_SHA="${REVISION}" \ + --build-arg ADAPTER_VERSION="${UPSTREAM}" \ + --tag "${ADAPTER_IMAGE_otfabric}" --file own/go-sunspec/Dockerfile . + + - name: What was built + run: | + reported=$(docker run --rm "${ADAPTER_IMAGE_otfabric}" print-capabilities | jq -r '.upstream.version') + { + echo "## ${{ matrix.arch }}" + echo + echo "- \`$(docker run --rm "${ADAPTER_IMAGE_otfabric}" version)\`" + docker run --rm "${ADAPTER_IMAGE_otfabric}" print-capabilities | jq -r '"- upstream \(.upstream.name) \(.upstream.version), commit `\(.upstream.revision)`, independent engine: \(.upstream.independentEngine)"' + } | tee -a "$GITHUB_STEP_SUMMARY" + [ "${reported}" = "${UPSTREAM}" ] || { echo "::error::the image reports go-sunspec ${reported}, want ${UPSTREAM}"; exit 1; } + + # The self-tests are those of the harness release whose reference images + # they run against: main's scripts may already check what only the next + # release's references do. + - name: Check out the self-tests of ${{ needs.version.outputs.harness }} + uses: actions/checkout@v7 + with: + ref: ${{ needs.version.outputs.harness }} + path: harness + + - name: Pull the reference images of ${{ needs.version.outputs.harness }} + run: | + for a in pysunspec2; do + scripts/retry.sh docker pull --quiet "ghcr.io/otfabric/sunspec-interop-${a}:${VERSION}" + docker run --rm "ghcr.io/otfabric/sunspec-interop-${a}:${VERSION}" print-capabilities \ + | jq -r --arg image "${a}:${VERSION}" '"- \($image): \(.upstream.name) \(.upstream.version)"' | tee -a "$GITHUB_STEP_SUMMARY" + done + + - name: Smoke, cross-stack self-test against the references, document schemas + working-directory: harness + run: | + set -o pipefail + mkdir -p reports + REPORT_DIR=reports scripts/smoke.sh otfabric + REPORT_DIR=reports scripts/interop.sh otfabric pysunspec2 2>&1 | tee reports/interop.log + python3 scripts/validate-documents.py reports + grep -E '^\[(smoke|interop)\]' reports/interop.log | tee -a "$GITHUB_STEP_SUMMARY" || true + + - name: Upload documents + if: always() + uses: actions/upload-artifact@v7 + with: + name: own-go-sunspec-documents-${{ matrix.arch }} + path: | + harness/reports/ + reports/ + if-no-files-found: ignore + + - name: Push the tested image as - + if: github.ref == 'refs/heads/main' + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARCH: ${{ matrix.arch }} + ACTOR: ${{ github.actor }} + run: | + printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io --username "${ACTOR}" --password-stdin + docker tag "${ADAPTER_IMAGE_otfabric}" "${IMAGE}:${UPSTREAM}-${ARCH}" + scripts/retry.sh docker push --quiet "${IMAGE}:${UPSTREAM}-${ARCH}" + + publish: + name: Publish ${{ needs.version.outputs.upstream }} and latest + needs: [version, build] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + env: + UPSTREAM: ${{ needs.version.outputs.upstream }} + steps: + - uses: actions/checkout@v7 + - name: Join the architectures + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTOR: ${{ github.actor }} + run: | + printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io --username "${ACTOR}" --password-stdin + scripts/retry.sh docker buildx imagetools create --tag "${IMAGE}:${UPSTREAM}" --tag "${IMAGE}:latest" \ + "${IMAGE}:${UPSTREAM}-amd64" "${IMAGE}:${UPSTREAM}-arm64" + digest=$(scripts/retry.sh docker buildx imagetools inspect "${IMAGE}:${UPSTREAM}" --format '{{.Manifest.Digest}}') + { + echo "## Published" + echo + echo "- \`${IMAGE}:${UPSTREAM}\`" + echo "- \`${IMAGE}:latest\`" + echo "- \`${IMAGE}@${digest}\`" + } | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index d86ec85..9e7b579 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## What this is -The SunSpec Alliance's pysunspec2 in a container behind one command-line contract, with deterministic fixtures, used as an oracle by SunSpec libraries. Nothing here depends on a library under test. Profile: **interoperability**. +The SunSpec Alliance's pysunspec2 in a container behind one command-line contract, with deterministic fixtures, used as an oracle by SunSpec libraries. Nothing here depends on a library under test, except `own/`, the one image of OT Fabric's own stack (`own/go-sunspec/README.md`). Profile: **interoperability**. ## Organisation standards @@ -25,16 +25,18 @@ This repository follows **otfabric/engineering policy 1.2**, profile **interoper 2. Skips and known differences are never counted as passes. 3. An adapter refuses an unsupported operation with a usage error, never with a fake success. 4. Contract changes bump `contract_version` and reach docs/CONTAINER_CONTRACT.md in the same change. -5. Register encoding and decoding stay pysunspec2's. The adapter's code carries Modbus frames and nothing about SunSpec layout. +5. Register encoding and decoding stay pysunspec2's. The adapter's code carries Modbus frames and nothing about SunSpec layout. In `own/go-sunspec/` they are go-sunspec's, in the same way. 6. Licences: every redistributed component in THIRD_PARTY_NOTICES.md. 7. The release tag equals `interop_version`; bump versions.yaml before tagging. +8. `own/` is the one place that builds a library under test (`own/go-sunspec/README.md`). Its image is not a reference and is not in a release. It declares a subset of the contract, so a contract change need not extend it. ## Commands | When you change… | Run | |---|---| | anything | `make validate lint` | -| the adapter or fixtures | `make image-pysunspec2 interop` | +| the adapter or fixtures | `make image-pysunspec2 smoke interop` | +| `own/go-sunspec/` | `make -f own/go-sunspec/Makefile image smoke interop VERSION=dev` | | documents or the contract | `make documents` | ## Git and releases diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 524f955..9d7cd40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Ground rules -- **Nothing here may know about a library under test.** No imports, no build steps, no expectations and no names from any consumer. +- **Nothing here may know about a library under test.** No imports, no build steps, no expectations and no names from `go-sunspec` or any other consumer. See [REQUIREMENTS.md](REQUIREMENTS.md) R1, and `own/` for the one exception. - **The contract is the product.** A change to [docs/CONTAINER_CONTRACT.md](docs/CONTAINER_CONTRACT.md) or [docs/FIXTURES.md](docs/FIXTURES.md) lands with the adapter change. - **Declare only what is tested.** A capability is listed when the self-test exercises it. - **Pin everything.** Upstream by exact version and wheel hash, base images by digest, all mirrored in [versions.yaml](versions.yaml). @@ -15,6 +15,8 @@ Docker (with buildx), GNU make and Python 3. make ci # validate, lint, build, self-test, document schemas ``` +The own-stack image is built apart: `make -f own/go-sunspec/Makefile image smoke interop VERSION=dev` tests it against the locally built references (`VERSION` defaults to the latest harness release). + ## Moving the upstream pin 1. Change `ARG UPSTREAM_VERSION` in the Dockerfile, `adapters/pysunspec2/requirements.txt` and `versions.yaml`; `scripts/check-versions.sh` must pass. diff --git a/Makefile b/Makefile index 91e9cf6..dd9a34f 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ image = $(REGISTRY)/sunspec-interop-$(1) export REGISTRY VERSION -# image-% and smoke-% are pattern rules, which make ignores for .PHONY +# image-%, smoke-% and candidate-% are pattern rules, which make ignores for .PHONY # targets: they depend on FORCE instead. .PHONY: help all ci validate unittests documents lint images smoke interop buildx publish \ buildx-setup publish-arch publish-manifest digests capabilities clean FORCE @@ -102,10 +102,13 @@ image-%: FORCE ## Build one adapter image, e.g. image-pysunspec2 # ── Self-tests (images must be built) ───────────────────────────────────────── -smoke: interop ## Contract and cross-role self-test +smoke: $(addprefix smoke-,$(ADAPTERS)) ## Container-contract smoke test of every adapter -interop: ## Self-test: every client case against every fixture, contract, exit codes - @for a in $(ADAPTERS); do python3 scripts/selftest.py $$a || exit 1; done +smoke-%: FORCE ## Smoke test one adapter, e.g. smoke-pysunspec2 + @scripts/smoke.sh $* + +interop: ## Cross-stack self-test: every client against every server on every case of tests/cases.tsv + @scripts/interop.sh $(ADAPTERS) # ── Upstream candidates ─────────────────────────────────────────────────────── # Builds one adapter on another upstream version than the release pin and runs @@ -127,7 +130,8 @@ candidate-%: FORCE ## Build one adapter on CANDIDATE_VERSION (and CANDIDATE_SHA) --tag $(call image,$*):candidate \ --file adapters/$*/Dockerfile . @for a in $(filter-out $*,$(ADAPTERS)); do docker tag $(call image,$$a):$(VERSION) $(call image,$$a):candidate || exit 1; done - @VERSION=candidate python3 scripts/selftest.py $* + @VERSION=candidate scripts/smoke.sh $* + @VERSION=candidate scripts/interop.sh $(ADAPTERS) capabilities: ## Print the capability document of every adapter @for a in $(ADAPTERS); do docker run --rm $(call image,$$a):$(VERSION) print-capabilities; done diff --git a/README.md b/README.md index bec4cfc..161e5a2 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,18 @@ This is test equipment. It is not a SunSpec implementation of its own, and a pas pysunspec2 has no Modbus server. In the device role pysunspec2 encodes the register map from a fixture, and a small Modbus TCP layer of the adapter serves those registers. The SunSpec encoding is pysunspec2's; the transport is not, and is not what is under test ([docs/CAPABILITIES.md](docs/CAPABILITIES.md)). +## OT Fabric's own stack + +`ghcr.io/otfabric/sunspec-interop-otfabric` is go-sunspec (on go-modbus) behind the same contract, so +you can test your stack against ours the way we test ours against yours. Despite +the name it shares with the reference image, it is not an independent +implementation and not part of a release of this repository, so its tags are +go-sunspec versions and `manifest.json` does not list it: see [own/go-sunspec/README.md](own/go-sunspec/README.md). + ## Quick start ```sh -make images interop # build, then self-test +make images smoke interop # build, then self-test docker run --rm ghcr.io/otfabric/sunspec-interop-pysunspec2:dev print-capabilities make run-pysunspec2 # a device on 127.0.0.1:1502 ``` @@ -28,6 +36,7 @@ make run-pysunspec2 # a device on 127.0.0.1:1502 | [docs/FIXTURES.md](docs/FIXTURES.md) | What a device serves | | [docs/CAPABILITIES.md](docs/CAPABILITIES.md) | What the stack does and does not do | | [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) | Results and known differences | +| [REQUIREMENTS.md](REQUIREMENTS.md) | What the repository must and must not do | | [TESTING.md](TESTING.md) | The self-test and how to reproduce it | | [versions.yaml](versions.yaml) | Pins | | [RELEASE.md](RELEASE.md) | Releases | diff --git a/RELEASE.md b/RELEASE.md index 5a0473b..3a4ac78 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,28 @@ # sunspec-interop Releases +The `sunspec-interop-otfabric` image is not part of these releases. It has its own +manual workflow and no releases (`own/go-sunspec/README.md`). + +## v0.2.0 + +**Date:** 2026-11-06 +**Previous release:** v0.1.0 + +### Added + +- Container contract 1.1: `upstream.independentEngine` in the capability document (`true` for pysunspec2; absent in a 1.0 image means `true`), and a client operation an image does not list is a usage error. +- `own/go-sunspec/` and the manual **Own stack (go-sunspec)** workflow: OT Fabric's go-sunspec behind the contract as `ghcr.io/otfabric/sunspec-interop-otfabric`, built from the latest go-sunspec release, for others to test against. It is not a reference and not in this release or `manifest.json` (otfabric/engineering ADR-0012). +- `scripts/smoke.sh ` and `scripts/interop.sh `, with `scripts/lib.sh`: an image comes from `ADAPTER_IMAGE_` when it is set. The cross-stack test runs every client against every server on the cases of `tests/cases.tsv`; a client operation an image does not declare is `unsupported`, and `results.json` takes `independent` from the image. +- `REQUIREMENTS.md` and `compose.yaml`, as in the other harnesses. + +### Changed + +- The smoke test also checks the baked fixtures, the user, the health check and an undeclared operation. `make smoke` and `make interop` are separate targets; the pass floor of a run is 6. +- CI's R1 check also refuses go-modbus and modbusctl outside `own/`. + +The image `sunspec-interop-pysunspec2` is still pysunspec2 1.3.7. + +--- ## v0.1.0 **Date:** 2026-10-31 diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 0000000..8e762d3 --- /dev/null +++ b/REQUIREMENTS.md @@ -0,0 +1,102 @@ +# Requirements + +What this repository must do and must not do. +[docs/CONTAINER_CONTRACT.md](docs/CONTAINER_CONTRACT.md) and +[docs/FIXTURES.md](docs/FIXTURES.md) are the specifications that follow from +it. + +## Purpose + +Independent, reproducible interoperability validation for SunSpec libraries: +established third-party implementations, packaged as containers that behave +the same way, so that a library can be tested against an implementation it +does not own. + +It is not another SunSpec implementation and not a general protocol-testing +framework. + +## R1 - Repository isolation + +- R1.1 This repository does not import, build, link against or depend on any + library under test, and contains no test logic or expectation specific to + one. The one exception is `own/` with its workflow: images of OT Fabric's own + stacks, published for others to test against (`own/go-sunspec/README.md`). The + rest of the repository does not know them, and they are not part of a release. + CI fails when `adapters/`, `fixtures/`, `schemas/`, `scripts/`, `tests/`, the + `Makefile` or `compose.yaml` name go-sunspec, go-modbus or their tools. +- R1.2 A consumer depends on published images and the documented contract + only. It does not need a checkout of this repository to run its tests. +- R1.3 No third-party code from here is copied into a consumer, and the + contract does not shape a consumer's public API. + +| Responsibility | Here | Consumer | +|----------------|:----:|:--------:| +| Third-party implementations, adapters, Dockerfiles | yes | no | +| Fixtures, fixture schema and validation | yes | no | +| Container contract, capability declarations | yes | no | +| Image builds and publishing, licence compliance | yes | no | +| Self-tests of the reference stacks | yes | no | +| Scenarios and assertions about a library | no | yes | +| Compatibility claims and release qualification of a library | no | yes | + +## R2 - Reference implementations + +- R2.1 In scope: the SunSpec Alliance's pysunspec2 (Python, independent + engine). +- R2.2 Each implementation is its own image. No image contains two. +- R2.3 Each image provides both roles where the implementation has them: a + device (serving a fixture) and a client (bounded operations). +- R2.4 An adapter uses the implementation's public API as an application + would. It does not patch the implementation and does not re-implement + protocol behaviour the implementation lacks: what a stack cannot do is + declared in its capabilities. + +## R3 - One contract + +- R3.1 Every image has the same commands, flags, documents and exit codes + ([docs/CONTAINER_CONTRACT.md](docs/CONTAINER_CONTRACT.md)). +- R3.2 Every device serves the same fixtures the same way + ([docs/FIXTURES.md](docs/FIXTURES.md)). The fixtures are baked into the image + and can be printed from it. +- R3.3 Readiness is a file, never a delay. Every wait is bounded. +- R3.4 stdout carries JSON documents only; diagnostics go to stderr. + +## R4 - Honest capabilities + +- R4.1 `print-capabilities` lists an operation only when the self-tests + exercise it for that adapter. +- R4.2 A consumer can skip, not fail, what a stack does not declare. + +## R5 - Self-tests + +- R5.1 A smoke test checks the contract of each image on its own + (`scripts/smoke.sh`). +- R5.2 A cross-stack test runs every client against every server on every + case of `tests/cases.tsv` (`scripts/interop.sh`) and compares what was read + with the fixture. +- R5.3 Disagreements between stacks are resolved against the SunSpec + specification, not by majority, and recorded in + [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md). + +## R6 - Reproducibility + +- R6.1 Upstreams are pinned by exact version and wheel hash, base images by + digest; [versions.yaml](versions.yaml) mirrors every pin and a check fails + when it disagrees with the build files. +- R6.2 Images are built natively for linux/amd64 and linux/arm64 and + self-tested on both before they are published. +- R6.3 The latest upstream release is the pin unless it fails the + self-tests; then it is built as a candidate with its bug listed in + [tests/known-bugs.tsv](tests/known-bugs.tsv). + +## R7 - Safety + +- R7.1 The images are test fixtures: unauthenticated, loopback or private + network only ([SECURITY.md](SECURITY.md)). +- R7.2 Processes run as a non-root user. + +## R8 - Licensing + +- R8.1 The repository's own code is MIT-licensed. +- R8.2 Every image carries the licence texts of what it contains in + `/licenses`, and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) lists them. diff --git a/TESTING.md b/TESTING.md index 88a615d..3711f9b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4,10 +4,11 @@ |---------|--------------|-------| | `make validate` | versions.yaml against the Dockerfile, RELEASE.md, fixtures against the schema, unit tests of the result scripts | Python 3 | | `make lint` | Shellcheck | shellcheck or Docker | -| `make interop` | `scripts/selftest.py`: the contract, then the client against the device on every fixture | Docker, a built image | +| `make smoke` | `scripts/smoke.sh `: the contract of each image on its own | Docker, built images | +| `make interop` | `scripts/interop.sh `: every case of `tests/cases.tsv` for every client against every server | Docker, built images | | `make documents` | The same, keeping every document in `reports/`, validated against `schemas/` | Docker | | `make ci` | All of the above | Docker | -The self-test writes `results.json` in the interop-result-v1 format. A run fails on any `fail` or `infra-error`, and with fewer than 8 passes. +Both scripts take an image from `ADAPTER_IMAGE_` when it is set (`scripts/lib.sh`), and run `scripts/selftest.py`. `interop.sh` writes `results.json` in the interop-result-v1 format, with `independent` taken from the image's `upstream.independentEngine`. A run fails on any `fail` or `infra-error`, and with fewer than 6 passes. A client that does not declare an operation gets `unsupported` for its cases, never a pass. This repository tests itself with one stack on both sides, so it shows that the adapter and the contract work, not that pysunspec2 is right. Evidence about another implementation comes from that implementation's own suite against these images. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9c0912c..605720e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,13 +8,26 @@ Every image carries the licence texts of what it contains under `/licenses`. | Part | Licence | |------|---------| | Repository tooling, fixtures, schemas, scripts, documentation | MIT ([LICENSE](LICENSE)) | -| Adapter programs under `adapters/` | MIT | +| Adapter programs under `adapters/` and `own/` | MIT | | The published images | the adapter (MIT) together with the components listed below, each under its own licence | Nothing in this repository is linked into a consumer: a library that tests against the images talks to them over TCP and reads their output, and is not affected by their licences. +## Image `sunspec-interop-otfabric` + +Built by hand from `own/go-sunspec/` (not part of a release); every module's licence is in the image under `/licenses`. + +| Component | Version | Licence | Source | +|-----------|---------|---------|--------| +| go-sunspec | latest release when built | MIT | https://github.com/otfabric/go-sunspec | +| go-modbus | as go-sunspec requires | MIT | https://github.com/otfabric/go-modbus | +| go-serial | as go-modbus requires | MIT | https://github.com/otfabric/go-serial | +| Go standard library | 1.27.2 | BSD-3-Clause | https://go.dev | +| Adapter | this repository, `own/go-sunspec/` | MIT | https://github.com/otfabric/sunspec-interop | +| Base system | Debian trixie-slim | various; see `/usr/share/doc/*/copyright` in the image | https://www.debian.org | + ## Image `sunspec-interop-pysunspec2` | Component | Version | Licence | Source | diff --git a/adapters/pysunspec2/sunspec_interop/main.py b/adapters/pysunspec2/sunspec_interop/main.py index f0e249e..0f4563a 100644 --- a/adapters/pysunspec2/sunspec_interop/main.py +++ b/adapters/pysunspec2/sunspec_interop/main.py @@ -12,7 +12,7 @@ from . import client, fixture, server ADAPTER = "pysunspec2" -CONTRACT = "1.0" +CONTRACT = "1.1" SCHEMA = "1.0" EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_CONNECT, EXIT_FIXTURE = 0, 1, 2, 3, 4 BUILD_INFO = "/opt/sunspec-interop/build-info.json" @@ -88,6 +88,7 @@ def capabilities(): "version": info["upstreamVersion"], "revision": info["upstreamRevision"], "artifact": info["upstreamArtifact"], + "independentEngine": True, }, "roles": ["server", "client"], "clientOperations": sorted(client.OPERATIONS), diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..bdcf4f8 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,26 @@ +# Reference devices for local work. Ports are published on loopback only: +# these are unauthenticated SunSpec Modbus TCP devices that accept writes from +# anyone who can reach them, and must not be reachable from a plant network. +# +# docker compose up -d --wait # every device, waits for readiness +# docker compose down +# +# SUNSPEC_INTEROP_VERSION selects the image tag (default: dev, the local build), +# SUNSPEC_INTEROP_FIXTURE the fixture (default: baseline). + +x-server: &server + command: ["server", "--fixture", "/fixtures/${SUNSPEC_INTEROP_FIXTURE:-baseline}/fixture.json"] + healthcheck: + test: ["CMD", "test", "-f", "/run/sunspec-interop/ready"] + interval: 2s + timeout: 2s + retries: 15 + start_period: 10s + stop_grace_period: 10s + +services: + pysunspec2: + <<: *server + image: ghcr.io/otfabric/sunspec-interop-pysunspec2:${SUNSPEC_INTEROP_VERSION:-dev} + ports: + - "127.0.0.1:1502:1502" diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index e6cc27b..2517d13 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -2,16 +2,27 @@ `print-capabilities` is the authority for an image. This page explains what it means. -| | pysunspec2 1.3.7 | +| Field | Meaning | |---|---| -| Register encoding from values | pysunspec2 (`Device.get_mb`) | -| Device transport | The adapter's Modbus TCP layer: functions 3, 6, 16 | -| Client | pysunspec2 (`SunSpecModbusClientDeviceTCP`): scan, point read and write | -| Base addresses scanned | 40000, 0, 50000 | -| Models | Those of the model definitions in the pysunspec2 wheel | +| `upstream` | The implementation inside: name, exact version, revision, artifact | +| `upstream.independentEngine` | `false` for OT Fabric's own stack (`own/`): results against it do not count as an independent implementation. Absent (contract 1.0 images) means `true` | +| `roles` | Which of `server` and `client` the image provides | +| `clientOperations` | Operations of `client`; an operation that is not listed is a usage error (exit 2) | +| `serverFunctions` | Function codes the device answers with something other than exception 1 | +| `baseAddresses` | What `scan` tries, in order | + +| | pysunspec2 1.3.7 | otfabric (`own/go-sunspec`, not a reference) | +|---|---|---| +| Independent engine | yes | no | +| Register encoding from values | pysunspec2 (`Device.get_mb`) | go-sunspec (`NewServer`) | +| Device transport | The adapter's Modbus TCP layer: functions 3, 6, 16 | go-modbus: functions 3, 6, 16 | +| Client | pysunspec2 (`SunSpecModbusClientDeviceTCP`): scan, point read and write | go-sunspec (`Discover`, `ReadModels`): scan only | +| Base addresses scanned | 40000, 0, 50000 | 40000, 0, 50000 | +| Models | Those of the model definitions in the pysunspec2 wheel | Those of go-sunspec's registry | Not covered: - Models without a definition. pysunspec2 skips them in its model list, so `scan` does not report them. - Modbus RTU, TLS and input registers. - pysunspec2 as a Modbus server: it has none, so the transport of the device role is not evidence about pysunspec2. +- `write` in the client of `otfabric`: go-sunspec's client does not write (its LIMITATIONS.md), and the adapter does not write registers on its own, so `clientOperations` is `["scan"]` and the write cases are `unsupported` for it as a client. Its device accepts writes, so a pysunspec2 client writes it. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 4c8e369..647313f 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -2,6 +2,12 @@ No known differences. The self-test of every release passes all cases against pysunspec2 1.3.7 on amd64 and arm64; `results.json` of a run is its evidence. Only a consumer's own suite shows anything about that consumer. +## OT Fabric's own stack + +`otfabric` (go-sunspec, `own/go-sunspec/`) is run by hand against the references of the latest release, and is not evidence about go-sunspec ([own/go-sunspec/README.md](../own/go-sunspec/README.md)). Where its device differs from pysunspec2's without a case failing: + +- A `pad` point: go-sunspec encodes 0x8000, SunSpec's not-implemented value for `pad`; pysunspec2 encodes 0. `print-registers` differs in those registers only (the last register of model 1, and that of model 120 in `baseline`). + ## Upstream observations - pysunspec2 1.3.7 has no Modbus server, and the wheel of its dependency pyserial 3.5 carries no licence file. diff --git a/docs/CONTAINER_CONTRACT.md b/docs/CONTAINER_CONTRACT.md index 9b1382e..bcf7e92 100644 --- a/docs/CONTAINER_CONTRACT.md +++ b/docs/CONTAINER_CONTRACT.md @@ -1,6 +1,6 @@ # Container contract -Version: **1.0** (`contract_version` in [versions.yaml](../versions.yaml)) +Version: **1.1** (`contract_version` in [versions.yaml](../versions.yaml)) Consumers depend on this document, not on the adapter's source. A change that breaks it bumps the major version; an addition bumps the minor one. Documents carry `schemaVersion` `"1.0"`, which changes only with the major version. @@ -8,7 +8,7 @@ Consumers depend on this document, not on the adapter's source. A change that br | Item | Value | |------|-------| -| Image | `ghcr.io/otfabric/sunspec-interop-` | +| Image | `ghcr.io/otfabric/sunspec-interop-`; OT Fabric's own stack is the adapter `otfabric` | | Entrypoint | `sunspec-interop` | | Default command | `server` | | Protocol | SunSpec over Modbus TCP | @@ -50,17 +50,17 @@ Prints one document on standard output ([schema](../schemas/client-result.schema | `result` | Present when `ok` is true | | `error` | `kind` (`connect`, `failed`) and `message`, present when `ok` is false | -`scan` finds the base address among 40000, 0 and 50000, in that order, and reads every model. `result` holds `baseAddress` and `models`, one entry per model: `id`, `name`, `address`, `length`, `values` (raw) and `scaled`. Both are the point names of the model, with repeating groups as lists of objects. A not-implemented point is `null`. `error` appears in a model pysunspec2 flagged. +`scan` finds the base address among 40000, 0 and 50000, in that order, and reads every model. `result` holds `baseAddress` and `models`, one entry per model: `id`, `name`, `address`, `length`, `values` (raw) and `scaled`. Both are the point names of the model, with repeating groups as lists of objects. A not-implemented point is `null`. `error` appears in a model the client's stack flagged. `write` scans, writes one point in raw form and reads it back. `result` holds `model`, `point` and the `value` read back. ## `print-capabilities` -The capability document ([schema](../schemas/capabilities.schema.json)). +The capability document ([schema](../schemas/capabilities.schema.json)). Since 1.1, `upstream.independentEngine` says whether the stack is an independent implementation; it is `false` for OT Fabric's own stack. A client operation the document does not list is a usage error. ## `print-fixture`, `print-registers` -The fixture as baked in, and the whole register map pysunspec2 encodes from it, from the base address through the end model: `fixture`, `unitId`, `baseAddress`, `registers` (numbers). +The fixture as baked in, and the whole register map the adapter's stack encodes from it, from the base address through the end model: `fixture`, `unitId`, `baseAddress`, `registers` (numbers). ## Exit codes diff --git a/own/go-sunspec/Dockerfile b/own/go-sunspec/Dockerfile new file mode 100644 index 0000000..4fb774f --- /dev/null +++ b/own/go-sunspec/Dockerfile @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: MIT +# +# sunspec-interop-otfabric: OT Fabric's own SunSpec stack behind the container contract. +# Not an independent implementation: see README.md. +# +# Build from the repository root: +# make -f own/go-sunspec/Makefile image +# +# The build takes the go-sunspec release as UPSTREAM_VERSION (the released git tag, +# which is the Go module version) and its commit as UPSTREAM_SHA. There is no pin +# in this repository: the workflow passes the latest release. A build never uses +# main. go.mod and go.sum here are only a starting point for local work. + +ARG BASE_REGISTRY=docker.io/library +# golang:1.27.2-trixie and debian:trixie-slim, multi-arch indexes +# (linux/amd64 and linux/arm64). +ARG GOLANG_DIGEST=sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d +ARG DEBIAN_DIGEST=sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 + +FROM ${BASE_REGISTRY}/golang:1.27.2-trixie@${GOLANG_DIGEST} AS build + +ARG UPSTREAM_VERSION +ARG UPSTREAM_SHA +ARG ADAPTER_VERSION=dev +ARG GOPROXY=https://proxy.golang.org + +ENV CGO_ENABLED=0 GOPROXY=${GOPROXY} GOSUMDB=sum.golang.org GOTOOLCHAIN=local +WORKDIR /src +COPY own/go-sunspec/go.mod own/go-sunspec/go.sum own/go-sunspec/*.go ./ + +RUN set -eu; \ + test -n "${UPSTREAM_VERSION}" || { echo "UPSTREAM_VERSION is required" >&2; exit 1; }; \ + echo "${UPSTREAM_SHA}" | grep -Eq '^[0-9a-f]{40}$'; \ + go get "github.com/otfabric/go-sunspec@${UPSTREAM_VERSION}"; \ + go mod tidy; \ + built="$(go list -m -f '{{.Version}}' github.com/otfabric/go-sunspec)"; \ + test "${built}" = "${UPSTREAM_VERSION}" || { echo "go.mod has go-sunspec ${built}, want ${UPSTREAM_VERSION}" >&2; exit 1; }; \ + go mod download && go mod verify; \ + origin="$(go mod download -json "github.com/otfabric/go-sunspec@${UPSTREAM_VERSION}" \ + | sed -nE 's/^[[:space:]]*"Hash": "([0-9a-f]{40})".*/\1/p')"; \ + if [ -n "${origin}" ] && [ "${origin}" != "${UPSTREAM_SHA}" ]; then \ + echo "go-sunspec ${UPSTREAM_VERSION} is commit ${origin}, not ${UPSTREAM_SHA}" >&2; exit 1; \ + fi + +RUN set -eu; \ + test -z "$(gofmt -l .)"; \ + go vet ./...; \ + go test ./...; \ + go build -trimpath -ldflags "-s -w \ + -X main.adapterVersion=${ADAPTER_VERSION} \ + -X main.upstreamVersion=${UPSTREAM_VERSION} \ + -X main.upstreamRevision=${UPSTREAM_SHA}" \ + -o /out/sunspec-interop .; \ + mkdir -p /out/licenses/go; \ + cp "$(go env GOROOT)/LICENSE" /out/licenses/go/LICENSE; \ + go version -m /out/sunspec-interop | awk '$1 == "dep" { print $2, $3 }' > /out/licenses/BUNDLED-MODULES.txt; \ + test -s /out/licenses/BUNDLED-MODULES.txt; \ + while read -r module modversion; do \ + dir="$(go list -m -f '{{.Dir}}' "${module}")"; \ + mkdir -p "/out/licenses/${module}"; \ + for file in "${dir}"/LICENSE* "${dir}"/LICENCE* "${dir}"/COPYING* "${dir}"/NOTICE*; do \ + if [ -f "${file}" ]; then cp "${file}" "/out/licenses/${module}/"; fi; \ + done; \ + test -n "$(ls "/out/licenses/${module}")" || { echo "no licence file in ${module} ${modversion}" >&2; exit 1; }; \ + done < /out/licenses/BUNDLED-MODULES.txt + +FROM ${BASE_REGISTRY}/debian:trixie-slim@${DEBIAN_DIGEST} AS runtime + +ARG ADAPTER_VERSION=dev +ARG UPSTREAM_VERSION + +RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin interop \ + && mkdir -p /run/sunspec-interop \ + && chown interop /run/sunspec-interop + +COPY --from=build /out/sunspec-interop /usr/local/bin/sunspec-interop +COPY --from=build /out/licenses /licenses +COPY LICENSE /licenses/sunspec-interop/LICENSE +COPY fixtures /fixtures + +LABEL org.opencontainers.image.title="sunspec-interop-otfabric" \ + org.opencontainers.image.description="OT Fabric go-sunspec behind the sunspec-interop container contract, for testing. Not an independent implementation." \ + org.opencontainers.image.source="https://github.com/otfabric/sunspec-interop" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.version="${UPSTREAM_VERSION}" + +USER interop +EXPOSE 1502 + +HEALTHCHECK --interval=2s --timeout=2s --start-period=10s --retries=15 \ + CMD test -f /run/sunspec-interop/ready + +ENTRYPOINT ["/usr/local/bin/sunspec-interop"] +CMD ["server"] diff --git a/own/go-sunspec/Makefile b/own/go-sunspec/Makefile new file mode 100644 index 0000000..2c667b1 --- /dev/null +++ b/own/go-sunspec/Makefile @@ -0,0 +1,29 @@ +# The sunspec-interop-otfabric image, from the repository root: +# make -f own/go-sunspec/Makefile image smoke interop +# +# Apart from the top-level Makefile on purpose: this image is not in ADAPTERS +# and not in a release of the harness. + +IMAGE ?= ghcr.io/otfabric/sunspec-interop-otfabric:dev +# The harness release of the reference images to test against. +VERSION ?= $(shell git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1) +REFERENCES ?= pysunspec2 +BASE_REGISTRY ?= docker.io/library + +# The latest go-sunspec release, as the workflow builds it. +UPSTREAM_VERSION ?= $(shell git ls-remote --tags --refs https://github.com/otfabric/go-sunspec.git 'v[0-9]*' | sed 's,.*refs/tags/,,' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | sort -V | tail -1) +UPSTREAM_SHA ?= $(shell git ls-remote --tags https://github.com/otfabric/go-sunspec.git 'refs/tags/$(UPSTREAM_VERSION)^{}' | cut -f1) + +export VERSION +export ADAPTER_IMAGE_otfabric = $(IMAGE) + +.PHONY: image smoke interop + +image: + docker build --build-arg BASE_REGISTRY=$(BASE_REGISTRY) --build-arg UPSTREAM_VERSION=$(UPSTREAM_VERSION) --build-arg UPSTREAM_SHA=$(UPSTREAM_SHA) --build-arg ADAPTER_VERSION=$(UPSTREAM_VERSION) --tag $(IMAGE) --file own/go-sunspec/Dockerfile . + +smoke: + scripts/smoke.sh otfabric + +interop: + scripts/interop.sh otfabric $(REFERENCES) diff --git a/own/go-sunspec/README.md b/own/go-sunspec/README.md new file mode 100644 index 0000000..57780a1 --- /dev/null +++ b/own/go-sunspec/README.md @@ -0,0 +1,102 @@ +# sunspec-interop-otfabric + +[go-sunspec](https://github.com/otfabric/go-sunspec), OT Fabric's own SunSpec +stack (on [go-modbus](https://github.com/otfabric/go-modbus)), behind the +[container contract](../../docs/CONTAINER_CONTRACT.md), so that other stacks can +test against it. Anything that uses a `sunspec-interop-` image can use +this one in its place, within what it declares. Unlike those, it is not a +reference: its tags are go-sunspec versions, and no harness release or +`manifest.json` contains it. + +**For testing only.** It runs an unauthenticated Modbus TCP device that accepts +writes. Do not expose it to a network you do not control. Old images stay +available, so check +[go-sunspec advisories](https://github.com/otfabric/go-sunspec/security/advisories) +for the version you pull. + +**Not an independent implementation.** The image declares +`upstream.independentEngine: false`, and `results.json` records +`independent: false`. A result against it says nothing independent about +go-sunspec when OT Fabric produces it. A result you publish, naming the +implementation and version, is independent evidence, and we would like to hear +about it. + +## Use + +```sh +docker run -d --name go-sunspec -p 127.0.0.1:1502:1502 ghcr.io/otfabric/sunspec-interop-otfabric: server +until docker exec go-sunspec test -f /run/sunspec-interop/ready; do sleep 0.1; done +``` + +`` is a go-sunspec version such as `v0.9.0`, or `latest`, the newest one +published. A rebuild of a version replaces its tag, so pin the digest if you need +an exact build. The digest is in the summary of the +[workflow run](https://github.com/otfabric/sunspec-interop/actions/workflows/own-go-sunspec.yml), +and `docker run --rm version` prints the versions. + +| | | +|---|---| +| Upstream | go-sunspec, the latest release when the image was built | +| Licence | MIT; every module's licence is in the image under `/licenses` | +| Adapter name | `otfabric` (the name in documents, as the schema allows) | +| Language | Go | +| Protocol engine | OT Fabric's own: go-sunspec encodes and decodes, go-modbus carries the frames | + +## Why it is apart from the other adapters + +`REQUIREMENTS.md` R1: nothing under `adapters/`, `fixtures/`, `schemas/`, +`scripts/`, `tests/`, the top-level `Makefile` or `compose.yaml` knows a library +under test, and CI enforces that. This directory and +`.github/workflows/own-go-sunspec.yml` are the one exception. The scripts take +the image through `ADAPTER_IMAGE_otfabric`. + +No release loop can form (`otfabric/engineering` ADR-0012). This image is built +by hand, from the latest go-sunspec release, and nothing is committed: this +repository has no pin of go-sunspec to bump. No release or required check of +go-sunspec needs the image, and go-sunspec commits no pin of it. It is not in a +harness release or `manifest.json`. + +## Publishing + +Run the **Own stack (go-sunspec)** workflow by hand, from `main`. It finds the +latest go-sunspec release and the latest harness release, and prints them. It +builds the image, runs the smoke test and the cross-stack self-test on both +architectures, then publishes `:` and `:latest`. Run from a +branch, it stops before publishing. Nothing runs it automatically, so after a +go-sunspec release the image stays on the old version until someone does. + +```sh +make -f own/go-sunspec/Makefile image smoke interop # from the repository root, against the latest release +make -f own/go-sunspec/Makefile image smoke interop VERSION=dev # against references built locally (make images) +``` + +## How it maps the contract + +- **Device.** The fixture's models become a go-sunspec `Server` (`NewServer`, + served by go-modbus). The fixture holds raw values and go-sunspec takes + engineering values for a scaled point, so the adapter hands it + raw × 10^sf, written as an exact decimal, with the scale factor go-sunspec's + encoder would resolve; go-sunspec divides it out again. Everything about the + register layout, the marker, the end model, writes and exceptions is + go-sunspec's, with one change: function 4 is answered with exception 1, as + the contract says, where go-sunspec answers exception 2 (input registers are + "the other register type" to it). +- **Client.** `scan` is go-sunspec's `Discover` with the contract's base + addresses (40000, 0, 50000) and `ReadModels`; `values` are the decoded raw + values, `scaled` the scaled ones, a point not implemented is `null`. +- `print-registers` reads the served map back through the `Server`'s own + request handler. + +## What it cannot do + +Not declared, because the public API of go-sunspec does not offer it and the +adapter does not encode protocol itself: + +- `write` (the only client operation besides `scan`): go-sunspec's client + reads only (its LIMITATIONS.md). `client write` is a usage error, and the + write cases of `tests/cases.tsv` are `unsupported` with this image as the + client. As a device it accepts writes of read-write points and refuses the + rest, which the cases check with pysunspec2 as the client. + +Where the device differs from pysunspec2's without a case failing (the `pad` +value) is in [docs/COMPATIBILITY.md](../../docs/COMPATIBILITY.md). diff --git a/own/go-sunspec/client.go b/own/go-sunspec/client.go new file mode 100644 index 0000000..e89a642 --- /dev/null +++ b/own/go-sunspec/client.go @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "errors" + "fmt" + "math" + "net" + "os" + "strconv" + "time" + + "github.com/otfabric/go-modbus" + sunspec "github.com/otfabric/go-sunspec" +) + +type clientError struct { + Kind string `json:"kind"` + Message string `json:"message"` +} + +type clientDoc struct { + SchemaVersion string `json:"schemaVersion"` + Adapter string `json:"adapter"` + Operation string `json:"operation"` + Host string `json:"host"` + Port int `json:"port"` + UnitID int `json:"unitId"` + OK bool `json:"ok"` + Result any `json:"result,omitempty"` + Error *clientError `json:"error,omitempty"` +} + +func runClient(args []string) int { + if len(args) == 0 { + return usage("client needs an operation") + } + op := args[0] + switch op { + case "scan": + case "write": + // go-sunspec's client does not write (its LIMITATIONS.md), and the + // adapter does not write registers behind its back. + return usage("write: go-sunspec's client does not write; clientOperations is [\"scan\"]") + default: + return usage("unknown operation " + op) + } + f, err := flags(args[1:], "host", "port", "unit-id", "timeout") + if err != nil { + return usage(err.Error()) + } + host, ok := f["host"] + if !ok || host == "" { + return usage("--host is required") + } + port, unit, timeout := uint64(1502), uint64(1), 5.0 + if v, ok := f["port"]; ok { + if port, err = number(v, 16); err != nil { + return usage("--port: " + v) + } + } + if v, ok := f["unit-id"]; ok { + if unit, err = number(v, 8); err != nil { + return usage("--unit-id: " + v) + } + } + if v, ok := f["timeout"]; ok { + if timeout, err = strconv.ParseFloat(v, 64); err != nil || !(timeout > 0) || math.IsInf(timeout, 0) { + return usage("--timeout: " + v) + } + } + + doc := clientDoc{SchemaVersion: schema, Adapter: adapterName, Operation: op, Host: host, Port: int(port), UnitID: int(unit)} + result, kind, err := scan(host, int(port), uint8(unit), time.Duration(timeout*float64(time.Second))) + code := exitOK + if err != nil { + doc.Error = &clientError{Kind: kind, Message: err.Error()} + code = exitFailed + if kind == "connect" { + code = exitConnect + } + } else { + doc.OK, doc.Result = true, result + } + if c := emit(doc); c != exitOK { + return c + } + return code +} + +// scan finds the device and reads every model: what go-sunspec's Discover +// and ReadModels return, in the contract's document. +func scan(host string, port int, unit uint8, timeout time.Duration) (any, string, error) { + client, err := modbus.New(modbus.Config{ + URL: "tcp://" + net.JoinHostPort(host, strconv.Itoa(port)), Timeout: timeout, DialTimeout: timeout, Logger: modbus.NopLogger(), + }) + if err != nil { + return nil, "connect", err + } + if err := client.Open(); err != nil { + return nil, "connect", err + } + defer func() { _ = client.Close() }() + ctx, cancel := context.WithTimeout(context.Background(), 4*timeout+30*time.Second) + defer cancel() + dev, err := sunspec.Discover(ctx, client, &sunspec.DiscoverOptions{UnitID: unit, BaseAddresses: baseAddresses}) + if err != nil { + return nil, kindOf(err), err + } + results, _ := dev.ReadModels(ctx, dev.Discovery.Models) + models := make([]map[string]any, 0, len(results)) + for _, r := range results { + h := r.Instance.Header + entry := map[string]any{"id": h.ID, "name": r.Instance.Name, "address": h.StartAddress, "length": h.Length} + if r.Model != nil { + if r.Model.Name != "" { + entry["name"] = r.Model.Name + } + if r.Model.Group != nil { + entry["values"], entry["scaled"] = groupDoc(r.Model.Group) + } + } + if _, ok := entry["values"]; !ok { + entry["values"], entry["scaled"] = map[string]any{}, map[string]any{} + } + if r.Err != nil { + entry["error"] = r.Err.Error() + } else if r.Model != nil && len(r.Model.Warnings) > 0 { + entry["error"] = fmt.Sprint(r.Model.Warnings) + } + models = append(models, entry) + } + return map[string]any{"baseAddress": dev.Discovery.BaseAddress, "models": models}, "", nil +} + +// kindOf tells a device that was not reached from one that refused. +func kindOf(err error) string { + var ne net.Error + switch { + case errors.Is(err, sunspec.ErrNotSunSpec), errors.Is(err, context.DeadlineExceeded), + errors.Is(err, modbus.ErrRequestTimedOut), errors.As(err, &ne), errors.Is(err, os.ErrDeadlineExceeded): + return "connect" + } + return "failed" +} + +// groupDoc gives the raw and the scaled values of a group instance by point +// name, nested instances as lists; a point not implemented is null. +func groupDoc(g *sunspec.DecodedGroup) (map[string]any, map[string]any) { + raw, scaled := map[string]any{}, map[string]any{} + for _, p := range g.Points { + if !p.Implemented { + raw[p.Name], scaled[p.Name] = nil, nil + continue + } + raw[p.Name] = finite(p.RawValue) + if p.ScaledValue != nil { + scaled[p.Name] = finite(*p.ScaledValue) + } else { + scaled[p.Name] = raw[p.Name] + } + } + for _, c := range g.Groups { + r, s := groupDoc(c) + rl, _ := raw[c.Name].([]any) + sl, _ := scaled[c.Name].([]any) + raw[c.Name], scaled[c.Name] = append(rl, r), append(sl, s) + } + return raw, scaled +} + +func finite(v any) any { + switch x := v.(type) { + case float32: + if math.IsNaN(float64(x)) || math.IsInf(float64(x), 0) { + return nil + } + case float64: + if math.IsNaN(x) || math.IsInf(x, 0) { + return nil + } + } + return v +} diff --git a/own/go-sunspec/fixture.go b/own/go-sunspec/fixture.go new file mode 100644 index 0000000..9fcb736 --- /dev/null +++ b/own/go-sunspec/fixture.go @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/otfabric/go-modbus" + sunspec "github.com/otfabric/go-sunspec" + "github.com/otfabric/go-sunspec/registry" +) + +// fixture is a fixtures//fixture.json: models in pysunspec2's device +// JSON, with raw point values (docs/FIXTURES.md). +type fixture struct { + Name string + UnitID uint8 + BaseAddress uint16 + Models []map[string]any + doc any // as read, for print-fixture +} + +func decodeJSON(data []byte, into any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + if err := dec.Decode(into); err != nil { + return err + } + if dec.More() { + return fmt.Errorf("data after the document") + } + return nil +} + +func loadFixture(path string) (*fixture, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc any + if err := decodeJSON(data, &doc); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + top, ok := doc.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: not an object", path) + } + fx := &fixture{doc: doc} + if fx.Name, ok = top["name"].(string); !ok { + return nil, fmt.Errorf("%s: name is missing or has the wrong type", path) + } + unit, err := integer(top["unitId"], 1, 255) + if err != nil { + return nil, fmt.Errorf("%s: unitId: %w", path, err) + } + base, err := integer(top["baseAddress"], 0, 65535) + if err != nil { + return nil, fmt.Errorf("%s: baseAddress: %w", path, err) + } + fx.UnitID, fx.BaseAddress = uint8(unit), uint16(base) + abs, err := filepath.Abs(path) + if err != nil { + return nil, err + } + if filepath.Base(filepath.Dir(abs)) != fx.Name { + return nil, fmt.Errorf("%s: name %q is not the name of its directory", path, fx.Name) + } + models, ok := top["models"].([]any) + if !ok || len(models) == 0 { + return nil, fmt.Errorf("%s: no models", path) + } + for i, m := range models { + mm, ok := m.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: model %d is not an object", path, i) + } + fx.Models = append(fx.Models, mm) + } + return fx, nil +} + +func integer(v any, lo, hi int64) (int64, error) { + n, ok := v.(json.Number) + if !ok { + return 0, fmt.Errorf("missing or not a number") + } + i, err := strconv.ParseInt(string(n), 10, 64) + if err != nil || i < lo || i > hi { + return 0, fmt.Errorf("%s is not an integer from %d to %d", n, lo, hi) + } + return i, nil +} + +// server lays out the go-sunspec Server of the fixture: go-sunspec encodes +// every register. +func (fx *fixture) server() (*sunspec.Server, error) { + models := make([]sunspec.ServerModel, len(fx.Models)) + for i, m := range fx.Models { + id, err := integer(m["ID"], 1, 65534) + if err != nil { + return nil, fmt.Errorf("model %d: ID: %w", i, err) + } + meta := registry.ByID(uint16(id)) + if meta == nil || meta.Group == nil { + return nil, fmt.Errorf("model %d: go-sunspec has no definition of model %d", i, id) + } + values, err := groupValues(meta.Group, m, nil, true) + if err != nil { + return nil, fmt.Errorf("model %d (%d): %w", i, id, err) + } + models[i] = sunspec.ServerModel{ID: uint16(id), Schema: meta, Values: values} + } + srv, err := sunspec.NewServer(&sunspec.ServerOptions{UnitID: fx.UnitID, BaseAddress: fx.BaseAddress}, models...) + if err != nil { + return nil, fmt.Errorf("go-sunspec rejects the fixture: %w", err) + } + return srv, nil +} + +// scope holds the scale factors (raw sunssf values) of one group instance and +// the instances that enclose it. +type scope struct { + sf map[string]int64 + parent *scope +} + +func (s *scope) lookup(name string) (int64, bool) { + for ; s != nil; s = s.parent { + if v, ok := s.sf[name]; ok { + return v, true + } + } + return 0, false +} + +// groupValues turns one group instance of the fixture into go-sunspec Values. +// The fixture holds raw values and go-sunspec takes engineering values for a +// scaled point, so a scaled point gets raw * 10^sf, written as an exact +// decimal; the encoder divides it out again. A scale factor go-sunspec does +// not apply (zero, unset, outside -10..10) leaves the raw value as it is. +func groupValues(g *registry.GroupMeta, inst map[string]any, parent *scope, top bool) (*sunspec.Values, error) { + points := map[string]*registry.PointMeta{} + for i := range g.Points { + points[g.Points[i].Name] = &g.Points[i] + } + groups := map[string]*registry.GroupMeta{} + for _, c := range g.Groups { + groups[c.Name] = c + } + sc := &scope{sf: map[string]int64{}, parent: parent} + for name, v := range inst { + if pm := points[name]; pm != nil && pm.Type == "sunssf" { + if i, err := integer(v, -32768, 32767); err == nil { + sc.sf[name] = i + } + } + } + values := &sunspec.Values{} + for name, v := range inst { + if top && (name == "ID" || name == "L") { + continue + } + if pm := points[name]; pm != nil { + if v == nil { + continue + } + values.Set(name, engineering(pm, v, sc)) + continue + } + child := groups[name] + if child == nil { + return nil, fmt.Errorf("%s has no point or group %q", g.Name, name) + } + list, ok := v.([]any) + if !ok { + list = []any{v} + } + for i, item := range list { + m, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s[%d] is not an object", name, i) + } + cv, err := groupValues(child, m, sc, false) + if err != nil { + return nil, fmt.Errorf("%s[%d]: %w", name, i, err) + } + values.Add(name, cv) + } + } + return values, nil +} + +func engineering(pm *registry.PointMeta, v any, sc *scope) any { + n, ok := v.(json.Number) + if !ok || pm.SF == "" || pm.Type == "sunssf" { + return v + } + var sf int64 + if pm.SFIsLiteral { + sf = int64(pm.SFLiteral) + } else if sf, ok = sc.lookup(pm.SF); !ok { + return v + } + if sf == 0 || sf < -10 || sf > 10 { + return v + } + return shift(n, int(sf)) +} + +// shift multiplies an integer by 10^exp, exactly, as a decimal string. +func shift(n json.Number, exp int) json.Number { + s := string(n) + if _, err := strconv.ParseInt(s, 10, 64); err != nil { + if _, err := strconv.ParseUint(s, 10, 64); err != nil { + return n // not an integer: go-sunspec judges it + } + } + sign := "" + if strings.HasPrefix(s, "-") { + sign, s = "-", s[1:] + } + if exp > 0 { + return json.Number(sign + s + strings.Repeat("0", exp)) + } + digits := -exp + if len(s) <= digits { + s = strings.Repeat("0", digits-len(s)+1) + s + } + return json.Number(sign + s[:len(s)-digits] + "." + s[len(s)-digits:]) +} + +// registerMap is the whole map the Server serves, from the base address +// through the end model, read through its own request handler. +func registerMap(srv *sunspec.Server) ([]uint16, error) { + n := 4 + for _, h := range srv.Models() { + n += 2 + int(h.Length) + } + out := make([]uint16, 0, n) + for off := 0; off < n; off += 125 { + q := min(125, n-off) + regs, err := srv.HandleHoldingRegisters(context.Background(), &modbus.HoldingRegistersRequest{ + UnitID: srv.UnitID(), FunctionCode: 3, Addr: srv.BaseAddress() + uint16(off), Quantity: uint16(q), + }) + if err != nil { + return nil, fmt.Errorf("reading the map at %d: %w", int(srv.BaseAddress())+off, err) + } + out = append(out, regs...) + } + return out, nil +} diff --git a/own/go-sunspec/go.mod b/own/go-sunspec/go.mod new file mode 100644 index 0000000..73969fe --- /dev/null +++ b/own/go-sunspec/go.mod @@ -0,0 +1,10 @@ +module github.com/otfabric/sunspec-interop/own/go-sunspec + +go 1.26.0 + +require ( + github.com/otfabric/go-modbus v1.11.1 + github.com/otfabric/go-sunspec v0.9.0 +) + +require github.com/otfabric/go-serial v0.2.1 // indirect diff --git a/own/go-sunspec/go.sum b/own/go-sunspec/go.sum new file mode 100644 index 0000000..bbffae5 --- /dev/null +++ b/own/go-sunspec/go.sum @@ -0,0 +1,6 @@ +github.com/otfabric/go-modbus v1.11.1 h1:9a5sMUwcpN49VVfCJRSgW72QggcfF7KnqiWNcbmnzSk= +github.com/otfabric/go-modbus v1.11.1/go.mod h1:FcVASTIAl1MNwPVW8nCMMubztiMueKHIFeVyHm0BDeY= +github.com/otfabric/go-serial v0.2.1 h1:PCOdnCDAHIHFaxTBNmisC/4SlD+NVd74d+EJPnXrCOw= +github.com/otfabric/go-serial v0.2.1/go.mod h1:TqbYpWWg6W+IuCLs8YM2cndlMAEaTyaKyUX6Z08gtq8= +github.com/otfabric/go-sunspec v0.9.0 h1:hKsK7I6wzQpVwibrkBv+wXD6LJxcuvxflTDUHl0J690= +github.com/otfabric/go-sunspec v0.9.0/go.mod h1:WqX973xM4bij9cqFr9leeXCCkXpMD8LJAe7SSbBOKKg= diff --git a/own/go-sunspec/main.go b/own/go-sunspec/main.go new file mode 100644 index 0000000..0ad65a5 --- /dev/null +++ b/own/go-sunspec/main.go @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MIT + +// Command sunspec-interop is the sunspec-interop-otfabric image: go-sunspec +// behind the container contract (docs/CONTAINER_CONTRACT.md). +// +// It is OT Fabric's own stack, not an independent implementation: results +// against it are never independent evidence for go-sunspec (README.md). +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strconv" + "strings" +) + +const ( + adapterName = "otfabric" + fixtureRoot = "/fixtures" + contract = "1.1" + schema = "1.0" +) + +// Set with -ldflags by the Dockerfile. +var ( + adapterVersion = "dev" + upstreamVersion = "dev" + upstreamRevision = "dev" +) + +const ( + exitOK = 0 + exitFailed = 1 + exitUsage = 2 + exitConnect = 3 + exitFixture = 4 +) + +// baseAddresses are probed in the contract's order. +var baseAddresses = []uint16{40000, 0, 50000} + +func main() { + os.Exit(run(os.Args[1:])) +} + +func run(args []string) int { + if len(args) == 0 { + return runServer(nil) + } + switch args[0] { + case "server": + return runServer(args[1:]) + case "client": + return runClient(args[1:]) + case "print-capabilities": + if len(args) > 1 { + return usage("print-capabilities takes no arguments") + } + return emit(capabilities()) + case "print-fixture": + return printFixture(args[1:]) + case "print-registers": + return printRegisters(args[1:]) + case "version": + if len(args) > 1 { + return usage("version takes no arguments") + } + fmt.Printf("sunspec-interop %s (go-sunspec %s)\n", adapterVersion, upstreamVersion) + return exitOK + } + return usage("unknown command " + args[0]) +} + +func usage(message string) int { + fmt.Fprintln(os.Stderr, "usage error:", message) + return exitUsage +} + +func emit(v any) int { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, err) + return exitFailed + } + fmt.Println(string(b)) + return exitOK +} + +// number parses a decimal number, or a hexadecimal one with a 0x prefix. +func number(text string, bits int) (uint64, error) { + if h, ok := strings.CutPrefix(strings.ToLower(text), "0x"); ok { + return strconv.ParseUint(h, 16, bits) + } + return strconv.ParseUint(text, 10, bits) +} + +// fixtureName takes the optional NAME of print-fixture and print-registers. +func fixtureName(args []string) (string, int) { + switch { + case len(args) == 0: + return "baseline", exitOK + case len(args) > 1: + return "", usage("at most one fixture name") + case strings.HasPrefix(args[0], "-"): + return "", usage("unknown flag " + args[0]) + } + return args[0], exitOK +} + +func fixturePath(name string) string { return fixtureRoot + "/" + name + "/fixture.json" } + +func printFixture(args []string) int { + name, code := fixtureName(args) + if code != exitOK { + return code + } + fx, err := loadFixture(fixturePath(name)) + if err != nil { + fmt.Fprintln(os.Stderr, "fixture error:", err) + return exitFixture + } + return emit(fx.doc) +} + +func printRegisters(args []string) int { + name, code := fixtureName(args) + if code != exitOK { + return code + } + fx, err := loadFixture(fixturePath(name)) + if err != nil { + fmt.Fprintln(os.Stderr, "fixture error:", err) + return exitFixture + } + srv, err := fx.server() + if err != nil { + fmt.Fprintln(os.Stderr, "fixture error:", err) + return exitFixture + } + regs, err := registerMap(srv) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return exitFailed + } + return emit(map[string]any{ + "schemaVersion": schema, "adapter": adapterName, "fixture": fx.Name, "unitId": fx.UnitID, + "baseAddress": fx.BaseAddress, "registers": regs, + }) +} + +func fixtureNames() []string { + entries, err := os.ReadDir(fixtureRoot) + if err != nil { + return []string{} + } + names := []string{} + for _, e := range entries { + if st, err := os.Stat(fixturePath(e.Name())); err == nil && st.Mode().IsRegular() { + names = append(names, e.Name()) + } + } + sort.Strings(names) + return names +} + +// capabilities is the document of docs/CAPABILITIES.md. The client offers +// only what go-sunspec's public API offers: it reads and does not write. +func capabilities() map[string]any { + return map[string]any{ + "schemaVersion": schema, + "adapter": adapterName, + "adapterVersion": adapterVersion, + "contractVersion": contract, + "upstream": map[string]any{ + "name": "go-sunspec", + "version": upstreamVersion, + "revision": upstreamRevision, + "artifact": "go:github.com/otfabric/go-sunspec@" + upstreamVersion, + "independentEngine": false, + }, + "roles": []string{"server", "client"}, + "clientOperations": []string{"scan"}, + "serverFunctions": []int{3, 6, 16}, + "baseAddresses": baseAddresses, + "fixtures": fixtureNames(), + } +} + +// errUsage marks a usage error of the command line. +var errUsage = errors.New("usage error") + +// flags parses "--name value" pairs, all of them known in advance. +func flags(args []string, known ...string) (map[string]string, error) { + out := map[string]string{} + for i := 0; i < len(args); i++ { + name, ok := strings.CutPrefix(args[i], "--") + if !ok || !contains(known, name) { + return nil, fmt.Errorf("%w: unknown argument %s", errUsage, args[i]) + } + if _, dup := out[name]; dup { + return nil, fmt.Errorf("%w: --%s given twice", errUsage, name) + } + if i+1 >= len(args) { + return nil, fmt.Errorf("%w: --%s needs a value", errUsage, name) + } + out[name] = args[i+1] + i++ + } + return out, nil +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} diff --git a/own/go-sunspec/main_test.go b/own/go-sunspec/main_test.go new file mode 100644 index 0000000..7e6f0d0 --- /dev/null +++ b/own/go-sunspec/main_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestShift(t *testing.T) { + for _, c := range []struct { + in string + exp int + want string + }{ + {"500", 1, "5000"}, {"-30", 2, "-3000"}, {"6001", -2, "60.01"}, {"5", -3, "0.005"}, + {"-985", -1, "-98.5"}, {"0", -2, "0.00"}, {"1.5", -1, "1.5"}, + } { + if got := shift(json.Number(c.in), c.exp); string(got) != c.want { + t.Errorf("shift(%s, %d) = %s, want %s", c.in, c.exp, got, c.want) + } + } +} + +func TestNumber(t *testing.T) { + for in, want := range map[string]uint64{"1502": 1502, "0x5DE": 1502, "0X10": 16} { + if got, err := number(in, 16); err != nil || got != want { + t.Errorf("number(%s) = %d, %v", in, got, err) + } + } + for _, in := range []string{"", "-1", "65536", "1e3", "0x"} { + if _, err := number(in, 16); err == nil { + t.Errorf("number(%q) has no error", in) + } + } +} + +// The raw values of a fixture are what go-sunspec serves: scaled points pass +// through engineering values and back, with scale factors of either sign, in +// a repeating group too. +func TestFixtureRawValuesAreServed(t *testing.T) { + const doc = `{"name": "probe", "unitId": 3, "baseAddress": 40000, "models": [ + {"ID": 1, "Mn": "OT Fabric", "Md": "Probe", "SN": "SN-1", "DA": 3}, + {"ID": 103, "A": 123, "A_SF": -1, "W": 500, "W_SF": 1, "Hz": 6001, "Hz_SF": -2, "WH": 1234567, "WH_SF": 0, "VAr": -30, "VAr_SF": 1, "St": 4}, + {"ID": 160, "DCA_SF": -2, "DCV_SF": -1, "DCW_SF": 0, "N": 2, + "module": [{"ID": 1, "DCA": 1011, "DCV": 4005}, {"ID": 2, "DCA": 999, "DCV": 3990}]} + ]}` + dir := filepath.Join(t.TempDir(), "probe") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "fixture.json") + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + fx, err := loadFixture(path) + if err != nil { + t.Fatal(err) + } + srv, err := fx.server() + if err != nil { + t.Fatal(err) + } + regs, err := registerMap(srv) + if err != nil { + t.Fatal(err) + } + if regs[0] != 0x5375 || regs[1] != 0x6E53 || regs[len(regs)-2] != 0xFFFF || regs[len(regs)-1] != 0 { + t.Fatalf("no marker or end model: %v", regs) + } + for i, m := range fx.Models { + dm, err := srv.Model(i) + if err != nil { + t.Fatal(err) + } + raw, _ := groupDoc(dm.Group) + for name, want := range m { + if name == "module" { + continue + } + if got := fmt.Sprint(raw[name]); got != fmt.Sprint(want) { + t.Errorf("model %v %s: served %s, fixture %v", m["ID"], name, got, want) + } + } + } + dm, _ := srv.Model(2) + raw, _ := groupDoc(dm.Group) + modules, _ := raw["module"].([]any) + if len(modules) != 2 || fmt.Sprint(modules[1].(map[string]any)["DCA"]) != "999" || fmt.Sprint(modules[0].(map[string]any)["DCV"]) != "4005" { + t.Errorf("module group: %v", raw["module"]) + } +} + +func TestFixtureErrors(t *testing.T) { + for name, doc := range map[string]string{ + "broken": `{ not json`, + "nomodel": `{"name": "nomodel", "unitId": 1, "baseAddress": 0, "models": []}`, + "unit": `{"name": "unit", "unitId": 0, "baseAddress": 0, "models": [{"ID": 1}]}`, + "other": `{"name": "elsewhere", "unitId": 1, "baseAddress": 0, "models": [{"ID": 1}]}`, + } { + dir := filepath.Join(t.TempDir(), name) + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "fixture.json") + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadFixture(path); err == nil { + t.Errorf("%s: loaded", name) + } + } +} + +func TestUsage(t *testing.T) { + for _, args := range [][]string{ + {"no-such-command"}, {"client"}, {"client", "scan"}, {"client", "write", "--host", "x"}, + {"client", "scan", "--host", "x", "--nope", "1"}, {"server", "--nope", "1"}, {"print-fixture", "a", "b"}, + } { + if code := run(args); code != exitUsage { + t.Errorf("%v: exit %d, want %d", args, code, exitUsage) + } + } +} diff --git a/own/go-sunspec/server.go b/own/go-sunspec/server.go new file mode 100644 index 0000000..477be18 --- /dev/null +++ b/own/go-sunspec/server.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "os/signal" + "strconv" + "syscall" + + "github.com/otfabric/go-modbus" + sunspec "github.com/otfabric/go-sunspec" +) + +// device is the go-sunspec Server as the contract wants it served. go-sunspec +// answers function 4 with exception 2 (the other register type); the +// contract's device keeps its map in holding registers and answers it with +// exception 1, as pysunspec2's does. Everything else is go-sunspec's. +type device struct { + *sunspec.Server +} + +func (device) HandleInputRegisters(context.Context, *modbus.InputRegistersRequest) ([]uint16, error) { + return nil, modbus.ErrIllegalFunction +} + +func runServer(args []string) int { + f, err := flags(args, "fixture", "bind-address", "port", "ready-file") + if err != nil { + return usage(err.Error()) + } + path, bind, readyFile := fixturePath("baseline"), "0.0.0.0", "/run/sunspec-interop/ready" + if v, ok := f["fixture"]; ok { + path = v + } + if v, ok := f["bind-address"]; ok { + bind = v + } + if v, ok := f["ready-file"]; ok { + readyFile = v + } + port := uint64(1502) + if v, ok := f["port"]; ok { + if port, err = number(v, 16); err != nil { + return usage("--port: " + v) + } + } + + fx, err := loadFixture(path) + if err != nil { + fmt.Fprintln(os.Stderr, "fixture error:", err) + return exitFixture + } + srv, err := fx.server() + if err != nil { + fmt.Fprintln(os.Stderr, "fixture error:", err) + return exitFixture + } + + address := net.JoinHostPort(bind, strconv.FormatUint(port, 10)) + ms, err := modbus.NewServer(&modbus.ServerConfig{URL: "tcp://" + address, MaxClients: 64, Logger: modbus.NopLogger()}, device{srv}) + if err != nil { + fmt.Fprintln(os.Stderr, "server:", err) + return exitFailed + } + if err := ms.Start(); err != nil { + fmt.Fprintln(os.Stderr, "server:", err) + return exitFailed + } + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) + if err := os.WriteFile(readyFile, []byte("ready\n"), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "ready file:", err) + _ = ms.Stop() + return exitFailed + } + n := 4 + for _, h := range srv.Models() { + n += 2 + int(h.Length) + } + fmt.Fprintf(os.Stderr, "serving %d registers at %d, unit %d, on %s\n", n, fx.BaseAddress, fx.UnitID, address) + <-stop + _ = os.Remove(readyFile) + if err := ms.Stop(); err != nil && !errors.Is(err, net.ErrClosed) { + fmt.Fprintln(os.Stderr, "stop:", err) + } + return exitOK +} diff --git a/schemas/capabilities.schema.json b/schemas/capabilities.schema.json index 95d2589..3a8b304 100644 --- a/schemas/capabilities.schema.json +++ b/schemas/capabilities.schema.json @@ -49,6 +49,10 @@ }, "artifact": { "type": "string" + }, + "independentEngine": { + "type": "boolean", + "description": "False for OT Fabric's own stack (own/): a result against it is not independent evidence. Absent means true (contract 1.0)." } } }, diff --git a/scripts/interop.sh b/scripts/interop.sh new file mode 100755 index 0000000..6fc1d65 --- /dev/null +++ b/scripts/interop.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Cross-stack self-test: every client adapter against every server adapter. +# +# Usage: interop.sh [ ...] e.g. interop.sh pysunspec2 +# +# Runs every case of tests/cases.tsv for each client/server pairing, the same +# adapter on both sides included: scans that must read every fixture as it is +# written, a write of a read-write point, a refused write of a read-only one +# and a request for another unit. A client that does not declare an operation +# is "unsupported" for its cases, never a pass. +# +# This tests the adapters against each other. It says nothing about any +# library that consumes the images. +# +# Environment: REGISTRY, VERSION (image tag, default dev), ADAPTER_IMAGE_, +# REPORT_DIR (keeps every result document when set, and writes +# results.json, interop-result-v1: only a pass counts as passing). + +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib.sh +source "${SCRIPT_DIR}/lib.sh" + +[[ $# -ge 1 ]] || { echo "usage: interop.sh [ ...]" >&2; exit 2; } +require_tools +pairs=() +for adapter in "$@"; do + pairs+=("${adapter}=$(image_of "${adapter}")") +done +log "adapters: $* (${VERSION})" +exec python3 "${SCRIPT_DIR}/selftest.py" interop "${pairs[@]}" diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100644 index 0000000..d73d9e6 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Shared helpers for the self-test scripts. Source, do not execute. +# +# Servers and clients run as containers on a private Docker network and reach +# each other by container name (scripts/selftest.py), so no host port is +# published and parallel runs do not collide. + +REGISTRY="${REGISTRY:-ghcr.io/otfabric}" +VERSION="${VERSION:-dev}" +export REGISTRY VERSION + +log() { echo "[$(basename "$0" .sh)] $*" >&2; } + +# image_of : ADAPTER_IMAGE_ names an image outside the release +# set, which this repository's scripts know nothing about. +image_of() { + local override="ADAPTER_IMAGE_$1" + if [[ -n "${!override:-}" ]]; then echo "${!override}"; else echo "${REGISTRY}/sunspec-interop-$1:${VERSION}"; fi +} + +require_tools() { + local t + for t in docker python3; do + command -v "$t" >/dev/null || { log "missing required tool: $t"; exit 2; } + done +} diff --git a/scripts/selftest.py b/scripts/selftest.py index 9c90e0c..073a48a 100755 --- a/scripts/selftest.py +++ b/scripts/selftest.py @@ -1,16 +1,22 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT -"""Self-test of one adapter image: the contract, then client against device. - - scripts/selftest.py - -VERSION and REGISTRY name the image (the Makefile exports both). REPORT_DIR -keeps every document; results.json goes there, or to the current directory. -Runs on a private Docker network and publishes nothing. +"""The self-tests behind scripts/smoke.sh and scripts/interop.sh. + + scripts/selftest.py smoke + scripts/selftest.py interop = [= ...] + +smoke checks the contract of one image on its own. interop runs every case of +tests/cases.tsv for every client against every server, the same adapter on +both sides included, and writes results.json (interop-result-v1). The shell +scripts name the images (scripts/lib.sh, image_of); this file knows no image +of its own. REPORT_DIR keeps every document; results.json goes there, or to +the current directory. Runs on a private Docker network and publishes +nothing. """ import json import os +import shlex import subprocess import sys import time @@ -22,70 +28,92 @@ ROOT = Path(__file__).resolve().parent.parent LABEL = "sunspec-interop.run" FIXTURES = ("baseline", "edge", "altbase") -WRITE_MODEL, WRITE_POINT, WRITE_VALUE = 123, "WMaxLimPct", 750 -READONLY_MODEL, READONLY_POINT = 103, "W" -FLOOR = 8 +CASES = ROOT / "tests" / "cases.tsv" +FLOOR = 6 + + +def docker(*args, timeout=60): + return subprocess.run(["docker", *args], capture_output=True, text=True, timeout=timeout) class Run: - def __init__(self, adapter): - self.adapter = adapter - self.image = f"{os.environ.get('REGISTRY', 'ghcr.io/otfabric')}/sunspec-interop-{adapter}:{os.environ.get('VERSION', 'dev')}" + """One Docker network, the containers on it, and what the tests found.""" + + def __init__(self, images): + self.images = images self.network = f"sunspec-interop-{uuid.uuid4().hex[:8]}" self.containers = [] self.results = [] + self.failed = 0 self.reports = Path(os.environ.get("REPORT_DIR", ".")) self.reports.mkdir(parents=True, exist_ok=True) self.count = 0 + self.caps = {} + for name, image in images.items(): + p = docker("run", "--rm", image, "print-capabilities") + if p.returncode != 0: + raise SystemExit(f"{name}: print-capabilities of {image} failed: {p.stderr}") + self.caps[name] = json.loads(p.stdout) + docker("network", "create", self.network) - def docker(self, *args, timeout=60): - return subprocess.run(["docker", *args], capture_output=True, text=True, timeout=timeout) + def run(self, adapter, *args): + return docker("run", "--rm", "--network", self.network, "--label", LABEL, self.images[adapter], *args) - def run(self, *args): - return self.docker("run", "--rm", "--network", self.network, "--label", LABEL, self.image, *args) + def keep(self, label, stdout): + if os.environ.get("REPORT_DIR"): + self.count += 1 + (self.reports / f"{label}-{self.count:03d}.json").write_text(stdout) - def keep(self, stdout): - self.count += 1 - (self.reports / f"{self.adapter}-{self.count:03d}.json").write_text(stdout) + def has_role(self, adapter, role): + return role in self.caps[adapter].get("roles", []) - def case(self, scenario, fn): + def has_operation(self, adapter, operation): + return operation in self.caps[adapter].get("clientOperations", []) + + def case(self, scenario, roles, fn): started = time.monotonic() try: reason = fn() outcome = "pass" if reason is None else "fail" - except (subprocess.TimeoutExpired, OSError, ValueError, KeyError, RuntimeError) as e: + except (subprocess.TimeoutExpired, OSError, ValueError, KeyError, RuntimeError, StopIteration) as e: outcome, reason = "infra-error", repr(e) - r = {"id": scenario, "suite": scenario.split(".", 1)[0], "roles": {"client": self.adapter, "server": self.adapter}, - "outcome": outcome, "durationMs": int((time.monotonic() - started) * 1000)} + self.record(scenario, roles, outcome, reason, started) + + def record(self, scenario, roles, outcome, reason, started=None): + r = {"id": scenario, "suite": scenario.split(".", 1)[0], "roles": roles, "outcome": outcome, + "durationMs": int((time.monotonic() - (started or time.monotonic())) * 1000)} if reason is not None: r["reason"] = reason self.results.append(r) - print(f"[selftest] {outcome:11} {scenario} {reason or ''}", flush=True) - - def server(self, fixture): - name = f"{self.network}-{fixture}-{len(self.containers)}" - p = self.docker("run", "-d", "--rm", "--network", self.network, "--network-alias", name, "--name", name, "--label", LABEL, self.image, - "server", "--fixture", f"/fixtures/{fixture}/fixture.json") + if outcome in ("fail", "infra-error"): + self.failed += 1 + pair = "->".join(roles[k] for k in ("client", "server") if k in roles) + print(f"[{sys.argv[1]}] {outcome:11} {scenario} {pair} {reason or ''}", flush=True) + + def server(self, adapter, fixture): + name = f"{self.network}-{adapter}-{fixture}-{len(self.containers)}" + p = docker("run", "-d", "--rm", "--network", self.network, "--network-alias", name, "--name", name, "--label", LABEL, + self.images[adapter], "server", "--fixture", f"/fixtures/{fixture}/fixture.json") if p.returncode != 0: raise RuntimeError(p.stderr) self.containers.append(name) - for _ in range(100): - if self.docker("exec", name, "test", "-f", "/run/sunspec-interop/ready").returncode == 0: + for _ in range(300): + if docker("exec", name, "test", "-f", "/run/sunspec-interop/ready").returncode == 0: return name time.sleep(0.1) - raise RuntimeError(f"{name} never became ready: " + self.docker("logs", name).stderr) + raise RuntimeError(f"{name} never became ready: " + docker("logs", name).stderr) - def client(self, *args): - p = self.run("client", *args) + def client(self, adapter, *args): + p = self.run(adapter, "client", *args) doc = json.loads(p.stdout) if p.stdout.strip() else None if doc is not None: - self.keep(p.stdout) + self.keep(adapter, p.stdout) return p.returncode, doc, p.stderr def close(self): for c in self.containers: - self.docker("rm", "-f", c) - self.docker("network", "rm", self.network) + docker("rm", "-f", c) + docker("network", "rm", self.network) def compare(want, got, where=""): @@ -98,6 +126,8 @@ def compare(want, got, where=""): return d return None if isinstance(want, dict): + if not isinstance(got, dict): + return f"{where}: read {got!r}" for k, w in want.items(): if (d := compare(w, got.get(k), f"{where}.{k}")) is not None: return d @@ -107,119 +137,182 @@ def compare(want, got, where=""): return None if want == got else f"{where}: want {want!r}, read {got!r}" -def contract(run): +def fixture(name): + return json.loads((ROOT / "fixtures" / name / "fixture.json").read_text()) + + +def point(doc, model, name): + return next(m for m in doc["result"]["models"] if m["id"] == model)["values"][name] + + +def flag(args, name): + return int(args[args.index(name) + 1], 0) + + +# ---- smoke: the contract of one image ---- + +def smoke(adapter, image): + run = Run({adapter: image}) + roles = {"harness": "smoke", "server": adapter} + caps = run.caps[adapter] + if os.environ.get("REPORT_DIR"): + (run.reports / f"capabilities-{adapter}.json").write_text(json.dumps(caps, indent=1, sort_keys=True) + "\n") + def capabilities(): - p = run.run("print-capabilities") - run.keep(p.stdout) - doc = json.loads(p.stdout) - if doc["adapter"] != run.adapter or doc["upstream"]["version"] in ("", "unknown"): - return f"capabilities: {doc}" - return None if sorted(doc["fixtures"]) == sorted(FIXTURES) else f"fixtures: {doc['fixtures']}" + if caps["adapter"] != adapter or caps["upstream"]["version"] in ("", "unknown"): + return f"capabilities: {caps}" + engine = caps["upstream"].get("independentEngine", True) + if not isinstance(engine, bool): + return f"upstream.independentEngine is {engine!r}" + return None if sorted(caps["fixtures"]) == sorted(FIXTURES) else f"fixtures: {caps['fixtures']}" + + def fixtures(): + for f in FIXTURES: + if json.loads(run.run(adapter, "print-fixture", f).stdout) != fixture(f): + return f"print-fixture {f} differs from fixtures/{f}/fixture.json" + return None def registers(): for f in FIXTURES: - doc = json.loads(run.run("print-registers", f).stdout) + doc = json.loads(run.run(adapter, "print-registers", f).stdout) r = doc["registers"] if r[:2] != [0x5375, 0x6E53] or r[-2:] != [0xFFFF, 0]: return f"{f}: no marker or end model" + if doc["baseAddress"] != fixture(f)["baseAddress"] or doc["unitId"] != fixture(f)["unitId"]: + return f"{f}: base address {doc['baseAddress']}, unit {doc['unitId']}" return None def usage(): - codes = (run.run("no-such-command").returncode, run.run("client", "scan").returncode, - run.run("client", "scan", "--host", "x", "--nope").returncode) + codes = (run.run(adapter, "no-such-command").returncode, run.run(adapter, "client", "scan").returncode, + run.run(adapter, "client", "scan", "--host", "x", "--nope").returncode) return None if codes == (2, 2, 2) else f"exit codes {codes}" + def unsupported(): + # An operation the adapter does not declare is a usage error, never a fake success. + for op in ("scan", "write"): + if run.has_operation(adapter, op): + continue + code = run.run(adapter, "client", op, "--host", "x", "--model", "1", "--point", "Mn", "--value", "1").returncode + if code != 2: + return f"undeclared {op}: exit {code}" + return None + def connect(): - code, doc, _ = run.client("scan", "--host", "no-such-host.invalid", "--timeout", "1") - return None if code == 3 and doc and not doc["ok"] else f"exit {code}: {doc}" + code, doc, _ = run.client(adapter, "scan", "--host", "no-such-host.invalid", "--timeout", "1") + return None if code == 3 and doc and not doc["ok"] and doc["error"]["kind"] == "connect" else f"exit {code}: {doc}" def bad_fixture(): - p = run.run("server", "--fixture", "/nonexistent/fixture.json") + p = run.run(adapter, "server", "--fixture", "/nonexistent/fixture.json") return None if p.returncode == 4 else f"exit {p.returncode}" - run.case("contract.capabilities", capabilities) - run.case("contract.registers", registers) - run.case("contract.usage-error", usage) - run.case("contract.connect-error", connect) - run.case("contract.fixture-error", bad_fixture) - - -def scans(run): - for name in FIXTURES: - fx = json.loads((ROOT / "fixtures" / name / "fixture.json").read_text()) - - def scan(fx=fx, name=name): - host = run.server(name) - code, doc, err = run.client("scan", "--host", host, "--unit-id", str(fx["unitId"])) - if code != 0: - return f"exit {code}: {doc} {err}" - res = doc["result"] - if res["baseAddress"] != fx["baseAddress"]: - return f"base address {res['baseAddress']}" - if [m["id"] for m in res["models"]] != [m["ID"] for m in fx["models"]]: - return f"models {[m['id'] for m in res['models']]}" - for want, got in zip(fx["models"], res["models"]): - if "error" in got: - return f"model {got['id']}: {got['error']}" - if (d := compare(want, got["values"], f"model {got['id']}")) is not None: - return d - return None - - run.case(f"device.scan.{name}", scan) - - -def writes(run): - fx = json.loads((ROOT / "fixtures" / "baseline" / "fixture.json").read_text()) - - def write(): - host = run.server("baseline") - code, doc, err = run.client("write", "--host", host, "--model", str(WRITE_MODEL), "--point", WRITE_POINT, "--value", str(WRITE_VALUE)) - if code != 0 or doc["result"]["value"] != WRITE_VALUE: - return f"exit {code}: {doc} {err}" - _, doc, _ = run.client("scan", "--host", host) - got = next(m for m in doc["result"]["models"] if m["id"] == WRITE_MODEL)["values"][WRITE_POINT] - return None if got == WRITE_VALUE else f"read back {got}" + def ready(): + name = run.server(adapter, "baseline") + user = docker("exec", name, "id", "-u").stdout.strip() + health = docker("inspect", "--format", "{{if .State.Health}}yes{{end}}", name).stdout.strip() + docker("stop", "-t", "10", name) + return None if user == "10001" and health == "yes" else f"uid {user}, health check {health or 'none'}" - def readonly(): - host = run.server("baseline") - code, doc, _ = run.client("write", "--host", host, "--model", str(READONLY_MODEL), "--point", READONLY_POINT, "--value", "1") + try: + run.case("contract.capabilities", roles, capabilities) + run.case("contract.fixtures", roles, fixtures) + run.case("contract.registers", roles, registers) + run.case("contract.usage-error", roles, usage) + run.case("contract.unsupported-operation", roles, unsupported) + run.case("contract.fixture-error", roles, bad_fixture) + run.case("contract.ready", roles, ready) + if run.has_role(adapter, "client"): + run.case("contract.connect-error", roles, connect) + finally: + run.close() + passed = sum(r["outcome"] == "pass" for r in run.results) + print(f"[smoke] {adapter}: {passed} passed, {run.failed} failed") + return 1 if run.failed or not passed else 0 + + +# ---- interop: every client against every server ---- + +def cases(): + out = [] + for line in CASES.read_text().splitlines(): + if not line.strip() or line.startswith("#"): + continue + cid, fx, operation, args, check = line.split("\t") + out.append((cid, fx, operation, [] if args == "-" else shlex.split(args), check)) + return out + + +def device_case(run, client, server, fx_name, operation, args, check): + fx = fixture(fx_name) + host = run.server(server, fx_name) + unit = [] if "--unit-id" in args else ["--unit-id", str(fx["unitId"])] + code, doc, err = run.client(client, operation, "--host", host, *unit, *args) + if check == "fixture": + if code != 0: + return f"exit {code}: {doc} {err}" + res = doc["result"] + if res["baseAddress"] != fx["baseAddress"]: + return f"base address {res['baseAddress']}" + if [m["id"] for m in res["models"]] != [m["ID"] for m in fx["models"]]: + return f"models {[m['id'] for m in res['models']]}" + for want, got in zip(fx["models"], res["models"]): + if "error" in got: + return f"model {got['id']}: {got['error']}" + if (d := compare(want, got["values"], f"model {got['id']}")) is not None: + return d + return None + if check == "refused": + return None if code != 0 and doc and not doc["ok"] else f"exit {code}: {doc}" + model, name, value = flag(args, "--model"), args[args.index("--point") + 1], flag(args, "--value") + if check == "readback": + if code != 0 or doc["result"]["value"] != value: + return f"exit {code}: {doc} {err}" + _, doc, _ = run.client(client, "scan", "--host", host, *unit) + got = point(doc, model, name) + return None if got == value else f"read back {got}" + if check == "refused-unchanged": if code != 1 or doc["ok"]: return f"a read-only point was written: exit {code}: {doc}" - _, doc, _ = run.client("scan", "--host", host) - want = next(m for m in fx["models"] if m["ID"] == READONLY_MODEL)[READONLY_POINT] - got = next(m for m in doc["result"]["models"] if m["id"] == READONLY_MODEL)["values"][READONLY_POINT] + _, doc, _ = run.client(client, "scan", "--host", host, *unit) + want = next(m for m in fx["models"] if m["ID"] == model)[name] + got = point(doc, model, name) return None if got == want else f"value changed to {got}" - - def wrong_unit(): - host = run.server("baseline") - code, doc, _ = run.client("scan", "--host", host, "--unit-id", "9") - return None if code != 0 and doc and not doc["ok"] else f"exit {code}: {doc}" - - run.case("device.write.read-write-point", write) - run.case("device.write.read-only-point", readonly) - run.case("device.unit-id.other-unit", wrong_unit) + raise ValueError(f"unknown check {check}") -def main(argv): - if len(argv) != 1: - print(__doc__, file=sys.stderr) - return 2 - run = Run(argv[0]) +def interop(pairs): + images = dict(p.split("=", 1) for p in pairs) started = ir.now() - upstream = json.loads(subprocess.run(["docker", "run", "--rm", run.image, "print-capabilities"], capture_output=True, text=True, check=True).stdout) - run.docker("network", "create", run.network) + run = Run(images) try: - contract(run) - scans(run) - writes(run) + for server in images: + if not run.has_role(server, "server"): + continue + for client in images: + if not run.has_role(client, "client"): + continue + roles = {"client": client, "server": server} + for cid, fx_name, operation, args, check in cases(): + if not run.has_operation(client, operation): + run.record(cid, roles, "unsupported", f"{client} does not declare the client operation {operation}") + continue + run.case(cid, roles, lambda c=client, s=server, f=fx_name, o=operation, a=args, k=check: device_case(run, c, s, f, o, a, k)) finally: run.close() - implementation = {"name": run.adapter, "version": upstream["upstream"]["version"]} - if digest := ir.repo_digest(run.image): - implementation["image"] = digest + implementations = [] + for name, image in images.items(): + up = run.caps[name]["upstream"] + i = {"name": name, "version": up["version"], "independent": up.get("independentEngine", True) is not False} + if digest := ir.repo_digest(image): + i["image"] = digest + implementations.append(i) doc = { - "schemaVersion": "1.0", "repository": "otfabric/sunspec-interop", "gitSha": ir.git_sha(str(ROOT)), "startedAt": started, "finishedAt": ir.now(), - "host": ir.host(), "implementations": [implementation], "reproduce": f"make image-{run.adapter} interop", "results": run.results, + "schemaVersion": "1.0", "repository": "otfabric/sunspec-interop", + "interopVersion": ir.version_field(str(ROOT), "interop_version"), + "contractVersion": ir.version_field(str(ROOT), "contract_version"), + "gitSha": ir.git_sha(str(ROOT)), "startedAt": started, "finishedAt": ir.now(), "host": ir.host(), + "implementations": implementations, + "reproduce": f"REPORT_DIR=reports VERSION={os.environ.get('VERSION', 'dev')} scripts/interop.sh {' '.join(images)}", + "results": run.results, } path = run.reports / "results.json" ir.write(str(path), doc) @@ -230,5 +323,14 @@ def main(argv): return 1 if bad else 0 +def main(argv): + if len(argv) == 3 and argv[0] == "smoke": + return smoke(argv[1], argv[2]) + if len(argv) >= 2 and argv[0] == "interop" and all("=" in a for a in argv[1:]): + return interop(argv[1:]) + print(__doc__, file=sys.stderr) + return 2 + + if __name__ == "__main__": sys.exit(main(sys.argv[1:])) diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..f4e76f0 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Container-contract smoke test for one adapter image. +# +# Usage: smoke.sh e.g. smoke.sh pysunspec2 +# +# Checks the parts of docs/CONTAINER_CONTRACT.md that do not need a second +# adapter: capabilities, the baked fixtures and register maps, usage errors, +# an undeclared operation, an invalid fixture, readiness, the user and the +# health check, and a connection that cannot be made. +# +# Environment: REGISTRY, VERSION (image tag, default dev), ADAPTER_IMAGE_, +# REPORT_DIR (keeps the capability and client documents when set). + +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib.sh +source "${SCRIPT_DIR}/lib.sh" + +ADAPTER="${1:?usage: smoke.sh }" +require_tools +IMAGE="$(image_of "${ADAPTER}")" +log "${IMAGE}" +exec python3 "${SCRIPT_DIR}/selftest.py" smoke "${ADAPTER}" "${IMAGE}" diff --git a/tests/cases.tsv b/tests/cases.tsv new file mode 100644 index 0000000..d25df76 --- /dev/null +++ b/tests/cases.tsv @@ -0,0 +1,16 @@ +# Cases of scripts/interop.sh: each runs for every client against every server. +# +# Columns, separated by tabs: +# 1 stable scenario ID (results.json) +# 2 fixture the server serves +# 3 client operation; a client that does not declare it is "unsupported" +# 4 flags of the operation, or "-"; --host and the fixture's --unit-id are added +# 5 check: fixture (every model and raw value as the fixture sets it), +# readback (the write lands and a new scan reads it), refused-unchanged +# (exit 1, and a new scan reads the fixture's value), refused (ok false) +device.scan.baseline baseline scan - fixture +device.scan.edge edge scan - fixture +device.scan.altbase altbase scan - fixture +device.write.read-write-point baseline write --model 123 --point WMaxLimPct --value 750 readback +device.write.read-only-point baseline write --model 103 --point W --value 1 refused-unchanged +device.unit-id.other-unit baseline scan --unit-id 9 refused diff --git a/tests/known-bugs.tsv b/tests/known-bugs.tsv index 0a2957a..11cb45a 100644 --- a/tests/known-bugs.tsv +++ b/tests/known-bugs.tsv @@ -1,4 +1,4 @@ -# Known bugs of upstream versions, for scripts/selftest.py. +# Known bugs of upstream versions, for scripts/interop.sh (scripts/selftest.py). # # A released pin has no entry here: an adapter is published on a version that # passes every case. Entries exist for versions that are built as candidates, diff --git a/versions.yaml b/versions.yaml index 8118bd2..c8a9624 100644 --- a/versions.yaml +++ b/versions.yaml @@ -3,8 +3,8 @@ # The Dockerfiles are the source of truth for a build: # scripts/check-versions.sh fails when this file disagrees with them. -interop_version: "0.1.0" -contract_version: "1.0" # docs/CONTAINER_CONTRACT.md +interop_version: "0.2.0" +contract_version: "1.1" # docs/CONTAINER_CONTRACT.md fixture_schema: "1.0" # schemas/fixture.schema.json protocol: @@ -27,7 +27,35 @@ implementations: roles: [server, client] published: true +# OT Fabric's own stack behind the contract, for others to test against +# (own/go-sunspec/README.md). Not a reference, not in a release or +# manifest.json, and no pin: the Own stack (go-sunspec) workflow builds the +# latest go-sunspec release by hand. +own_implementations: + otfabric: + upstream: "go-sunspec" + repository: "https://github.com/otfabric/go-sunspec" + artifact: "go:github.com/otfabric/go-sunspec" + version: "latest release, resolved when the image is built by hand" + license: "MIT" + language: "Go" + independent_engine: false + roles: [server, client] + image: "ghcr.io/otfabric/sunspec-interop-otfabric" + build: "own/go-sunspec" + base_images: + # Multi-arch index digests. Each Dockerfile names them as *_DIGEST build + # arguments. python: tag: "3.14.8-slim-trixie" digest: "sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170" + used_by: [pysunspec2] + golang: + tag: "1.27.2-trixie" + digest: "sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d" + used_by: [otfabric] + debian: + tag: "trixie-slim" + digest: "sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4" + used_by: [otfabric] From b4e96182ddc5920c5616ef678fcb63686465fe85 Mon Sep 17 00:00:00 2001 From: bartvanbos Date: Fri, 6 Nov 2026 01:29:41 +0100 Subject: [PATCH 2/2] own/go-sunspec: scaled is the exact integer where the scale factor does nothing go-sunspec's suite (TestPeerReadsGoServer/otfabric/edge) found TotWhInj of model 701, an acc64 with sf 0, one off after a float64. Co-Authored-By: Claude Opus 5.5 --- own/go-sunspec/README.md | 4 +++- own/go-sunspec/client.go | 19 ++++++++++++++++--- own/go-sunspec/main_test.go | 21 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/own/go-sunspec/README.md b/own/go-sunspec/README.md index 57780a1..a641546 100644 --- a/own/go-sunspec/README.md +++ b/own/go-sunspec/README.md @@ -83,7 +83,9 @@ make -f own/go-sunspec/Makefile image smoke interop VERSION=dev # against refer "the other register type" to it). - **Client.** `scan` is go-sunspec's `Discover` with the contract's base addresses (40000, 0, 50000) and `ReadModels`; `values` are the decoded raw - values, `scaled` the scaled ones, a point not implemented is `null`. + values, `scaled` the scaled ones, a point not implemented is `null`. Where a + scale factor leaves the value as it is, `scaled` is the exact raw integer, not + go-sunspec's `float64` (which cannot hold every 64-bit accumulator). - `print-registers` reads the served map back through the `Server`'s own request handler. diff --git a/own/go-sunspec/client.go b/own/go-sunspec/client.go index e89a642..68186f4 100644 --- a/own/go-sunspec/client.go +++ b/own/go-sunspec/client.go @@ -9,6 +9,7 @@ import ( "math" "net" "os" + "reflect" "strconv" "time" @@ -156,10 +157,12 @@ func groupDoc(g *sunspec.DecodedGroup) (map[string]any, map[string]any) { continue } raw[p.Name] = finite(p.RawValue) - if p.ScaledValue != nil { + scaled[p.Name] = raw[p.Name] + // go-sunspec gives a scaled value as a float64. A scale factor of 0 + // leaves the raw integer, which a float64 cannot always hold (a + // 64-bit accumulator): then the exact raw value is the scaled one. + if p.ScaledValue != nil && !sameAsInteger(p.RawValue, *p.ScaledValue) { scaled[p.Name] = finite(*p.ScaledValue) - } else { - scaled[p.Name] = raw[p.Name] } } for _, c := range g.Groups { @@ -171,6 +174,16 @@ func groupDoc(g *sunspec.DecodedGroup) (map[string]any, map[string]any) { return raw, scaled } +// sameAsInteger is true when raw is an integer whose float64 is f: the scale +// factor did nothing. +func sameAsInteger(raw any, f float64) bool { + switch v := raw.(type) { + case int8, int16, int32, int64, int, uint8, uint16, uint32, uint64, uint: + return float64(reflect.ValueOf(v).Convert(reflect.TypeFor[float64]()).Float()) == f + } + return false +} + func finite(v any) any { switch x := v.(type) { case float32: diff --git a/own/go-sunspec/main_test.go b/own/go-sunspec/main_test.go index 7e6f0d0..8212a23 100644 --- a/own/go-sunspec/main_test.go +++ b/own/go-sunspec/main_test.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "testing" + + sunspec "github.com/otfabric/go-sunspec" ) func TestShift(t *testing.T) { @@ -125,3 +127,22 @@ func TestUsage(t *testing.T) { } } } + +// A 64-bit accumulator with scale factor 0 is reported exactly, not through +// a float64. +func TestScaledKeepsExactIntegers(t *testing.T) { + big := float64(uint64(18446744073709551)) + sf := int16(0) + raw, scaled := groupDoc(&sunspec.DecodedGroup{Points: []sunspec.DecodedPoint{ + {Name: "TotWhInj", Type: "acc64", RawValue: uint64(18446744073709551), ScaledValue: &big, SFRawValue: &sf, Implemented: true}, + {Name: "W", Type: "int16", RawValue: int16(-30), ScaledValue: ptr(-300.0), Implemented: true}, + }}) + if scaled["TotWhInj"] != uint64(18446744073709551) || raw["TotWhInj"] != uint64(18446744073709551) { + t.Errorf("TotWhInj: raw %v, scaled %v", raw["TotWhInj"], scaled["TotWhInj"]) + } + if scaled["W"] != -300.0 { + t.Errorf("W: scaled %v", scaled["W"]) + } +} + +func ptr(f float64) *float64 { return &f }