Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
194 changes: 194 additions & 0 deletions .github/workflows/own-go-sunspec.yml
Original file line number Diff line number Diff line change
@@ -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 :<go-sunspec version>
# 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 <version>-<arch>
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"
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand Down
14 changes: 9 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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 |
Expand Down
23 changes: 23 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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 <adapter>` and `scripts/interop.sh <adapters>`, with `scripts/lib.sh`: an image comes from `ADAPTER_IMAGE_<adapter>` 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
Expand Down
Loading
Loading