Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

discovery node improvements - bacnet points, cli, integration test #1029

Merged
merged 19 commits into from
Nov 27, 2024
2 changes: 1 addition & 1 deletion .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -394,4 +394,4 @@ jobs:
done
ls -l var/tmp/pod_ready.txt 2>&1
- name: Run Tests
run: misc/discoverynode/testing/e2e/test_local //mqtt/localhost || true
run: misc/discoverynode/testing/e2e/test_local site_model || true
2 changes: 1 addition & 1 deletion misc/discoverynode/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1 @@
venv/*
venv/
50 changes: 47 additions & 3 deletions misc/discoverynode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,53 @@

## Notes

`vendor` discovery family is a counter which increments every second
`vendor` discovery family is actually sequential number generator at one second increments.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

number generator for what?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For testing. At the moment, I'm forced to have some "vendor" discovery because that's what sequencer uses, and it makes testing a whole lot easier, e.g. run "vendor" discovery for ten seconds, should receive ten events [1,2,3,4,5,6,7,8,9,10], etc.


## Running

**NOTE** Below commands are run from within the `discoverynode` directory

1. Setup

```
bin/setup
```

2. Run

```
bin/run SITE_MODEL TARGET DEVICE_ID
```

- SITE_MODEL - Path to site model
- TARGET - e.g. `//mqtt/localhost` or `//gbos/bos-platform-testing`
- DEVICE_ID - device ID from site model

3. (Optional) Running with UDMIS Locally

**NOTE** This can be destructive to the site model, and file permissions may change
```
bin/container build
IMAGE_TAG=udmis:latest udmis/bin/actualize ../sites/udmi_site_model
sudo bin/keygen CERT sites/udmi_site_model/devices/AHU-1
bin/registrar sites/udmi_site_model //mqtt/localhost
```

### Troubleshooting


#### `ssl.SSLError: [SSL] PEM lib (_ssl.c:3874)`

The device certificate was not signed by the CA certificate. This occurs if UDMIS is restarted without regenerating certificates,
because the UDMIS script (bin/setup_ca) recreates the CA certificate.

To fix, run `sudo bin/keygen CERT sites/udmi_site_model/devices/GAT-1`

Run the script as root

## Standalone (advanced)

## Standalone

**TODO** - Create `bin/setup` script

1. Setup a python virtual environment and install the required dependencies

Expand Down Expand Up @@ -125,3 +167,5 @@ TODO

- Unit tests - `~/venv/bin/python3 -m pytest tests/`
- Integration tests - TODO

##
92 changes: 89 additions & 3 deletions misc/discoverynode/bin/run
Original file line number Diff line number Diff line change
@@ -1,7 +1,93 @@
#!/bin/bash -e
set -x

ROOT_DIR=$(realpath $(dirname $0)/../..)
function error {
echo $*
false
}

cd $ROOT_DIR
ROOT_DIR=$(realpath $(dirname $0)/..)

sudo venv/bin/python3 discoverynode/src/main.py
BASE_CONFIG=$(realpath $ROOT_DIR/etc/base_config.json)
TMP_CONFIG="/tmp/discoverynode_config.json"

if [[ $# -ne 3 ]]; then
error Usage: $0 SITE_MODEL TARGET DEVICE_ID
fi

site_model=$1
target=$2
device_id=$3

if ! [[ -f $site_model/cloud_iot_config.json ]]; then
error "$site_model/cloud_iot_config.json does not exist"
fi

if ! [[ -f $site_model/devices/$device_id/rsa_private.pem ]]; then
echo $site_model/devices/$device_id/rsa_private.pem not found
error note - only RS256 keys supported
fi

cp $BASE_CONFIG $TMP_CONFIG

if [[ -z $SUDO_USER ]]; then
# reset owner of tmp config to the user to avoid file permission issues when next running not as root
chown $SUDO_USER:$SUDO_USER $TMP_CONFIG
fi

registry_id=$(cat $site_model/cloud_iot_config.json | jq -r '.registry_id')
region=$(cat $site_model/cloud_iot_config.json | jq -r '.cloud_region')

provider=$(cut -d'/' -f3 <<< $target)
project=$(cut -d'/' -f4 <<< $target)
namespace=$(cut -d'/' -f5 <<< $target)
substitutions=

if [[ $provider == gbos ]]; then

if [[ -n $namespace ]]; then
actual_registry=$registry_id~$namespace
else
actual_registry=$registry_id
fi

substitutions=$(cat <<EOF
.mqtt.host|="mqtt.bos.goog" |
.mqtt.port|=8883 |
.mqtt.region|="$region" |
.mqtt.project_id|="$project" |
.mqtt.authentication_mechanism|="jwt_gcp" |
.mqtt.registry_id|="$actual_registry" |
.mqtt.key_file|="$site_model/devices/$device_id/rsa_private.pem" |
.mqtt.algorithm|="RS256" |
.mqtt.device_id|="$device_id"
EOF
)

elif [[ $provider == mqtt ]]; then
if [[ $project != localhost ]]; then
error only localhost supported
fi

substitutions=$(cat <<EOF
.mqtt.host|="localhost" |
.mqtt.port|=8883 |
.mqtt.region|="$region" |
.mqtt.project_id|="$project" |
.mqtt.authentication_mechanism|="udmi_local" |
.mqtt.registry_id|="$registry_id" |
.mqtt.key_file|="$site_model/devices/$device_id/rsa_private.pem" |
.mqtt.ca_file|="$site_model/reflector/ca.crt" |
.mqtt.cert_file|="$site_model/devices/$device_id/rsa_private.crt" |
.mqtt.algorithm|="RS256" |
.mqtt.device_id|="$device_id"
EOF
)
else
echo error
exit
fi


cat $TMP_CONFIG | jq -r "$substitutions" | sponge $TMP_CONFIG
$ROOT_DIR/venv/bin/python3 $ROOT_DIR/src/main.py --config_file=$TMP_CONFIG
2 changes: 2 additions & 0 deletions misc/discoverynode/bin/setup
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/bash -e
ROOT_DIR=$(realpath $(dirname $0)/..)
14 changes: 14 additions & 0 deletions misc/discoverynode/etc/base_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"mqtt": {
},
"udmi": {
"discovery": {
"ipv4": "false",
"ether": false,
"bacnet": false
}
},
"bacnet": {
"ip": "192.168.11.251"
}
}
71 changes: 65 additions & 6 deletions misc/discoverynode/src/tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,32 @@
from unittest import mock
from typing import Callable
import pytest
import udmi.schema.state as state
import udmi.discovery.discovery as discovery
import udmi.discovery.numbers
import time
import datetime
import udmi.schema.state
import udmi.schema.util
import logging
import sys

stdout = logging.StreamHandler(sys.stdout)
stdout.addFilter(lambda log: log.levelno < logging.WARNING)
stdout.setLevel(logging.INFO)
stderr = logging.StreamHandler(sys.stderr)
stderr.setLevel(logging.WARNING)
logging.basicConfig(
format="%(asctime)s|%(levelname)s|%(module)s:%(funcName)s %(message)s",
handlers=[stderr, stdout],
level=logging.INFO,
)
logging.root.setLevel(logging.DEBUG)


def make_timestamp(*,seconds_from_now = 0):
return udmi.schema.util.datetime_serializer(udmi.schema.util.current_time_utc() + datetime.timedelta(seconds=seconds_from_now))


def until_true(func: Callable, message: str, timeout: int = 0):
"""Blocks until given func returns True
Expand All @@ -25,18 +48,36 @@ def until_true(func: Callable, message: str, timeout: int = 0):
raise Exception(f"Timed out waiting {timeout}s for {message}")


def test_number_discovery_e2e():
def test_number_discovery_start_and_stop():
mock_state = mock.MagicMock()
mock_publisher = mock.MagicMock()
numbers = udmi.discovery.numbers.NumberDiscovery(mock_state, mock_publisher)
numbers._start()
assert numbers.state.phase == discovery.states.STARTED
assert numbers.state.phase == state.Phase.active
time.sleep(5)
numbers._stop()
assert numbers.state.phase == discovery.states.CANCELLED
assert numbers.state.phase == state.Phase.stopped
#until_true(lambda: numbers.state.phase == discovery.states.FINISHED, "phase to be finished", 8)
# maybe flakey?
assert [0, 1, 2, 3, 4] == [x[0].scan_addr for (x, _) in mock_publisher.call_args_list]
assert [1, 2, 3, 4, 5] == [x[0].scan_addr for (x, _) in mock_publisher.call_args_list]


def test_event_counts():
mock_state = udmi.schema.state.State()
mock_publisher = mock.MagicMock()
numbers = udmi.discovery.numbers.NumberDiscovery(mock_state, mock_publisher)
numbers._start()
time.sleep(5)
numbers._stop()
assert mock_publisher.call_count == 5
assert [1, 2, 3, 4, 5] == [x[0].event_no for (x, _) in mock_publisher.call_args_list]

numbers.on_state_update_hook()

assert mock_state.discovery.families["vendor"].active_count == 5




def test_add_discovery_block_triggers_discovery_start():
mock_state = mock.MagicMock()
Expand All @@ -45,21 +86,39 @@ def test_add_discovery_block_triggers_discovery_start():
with (
mock.patch.object(numbers, "start_discovery") as mock_start,
):
numbers.controller({"discovery": {"families": {"number" : {"generation": "ts"}}}})
numbers.controller({"discovery": {"families": {"vendor" : {"generation": make_timestamp()}}}})
print(mock_state)
time.sleep(1)
mock_start.assert_called()

def test_having_no_config_then_recieve_repeated_identical_configs():
mock_state = mock.MagicMock()
mock_publisher = mock.MagicMock()
numbers = udmi.discovery.numbers.NumberDiscovery(mock_state, mock_publisher)

generation_timestamp = make_timestamp()

with mock.patch.object(numbers, "start_discovery") as mock_start:

for _ in range(5):
numbers.controller({"discovery": {"families": {"number" : {"generation": "ts"}}}})
numbers.controller({"discovery": {"families": {"vendor" : {"generation": generation_timestamp}}}})
time.sleep(1)

mock_start.assert_called_once()

def test_past_generation():
mock_state = udmi.schema.state.State()
mock_publisher = mock.MagicMock()

numbers = udmi.discovery.numbers.NumberDiscovery(mock_state, mock_publisher)
generation = make_timestamp(seconds_from_now = -8)
numbers.controller({"discovery": {"families": {"vendor" : {"generation": generation}}}})
time.sleep(3)

assert mock_state.discovery.families["vendor"].generation == generation
assert all(x[0].generation == generation for (x, _) in mock_publisher.call_args_list)


def test_stopping_completed_discovery():
# should not go through "stopping" because it's done .. i.e. ignore!
pass
Expand Down
25 changes: 12 additions & 13 deletions misc/discoverynode/src/tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,22 @@
import udmi.core
import udmi.schema.util
import udmi.discovery.discovery
import os

# This test runs inside a discoverynode container as a command
assert "I_AM_INTEGRATION_TEST" in os.environ

def timestamp_now():
return udmi.schema.util.datetime_serializer(udmi.schema.util.current_time_utc())

def test_bacnet_integration():
try:
with open("docker_config.json") as f:
docker_config = json.load(f)
except FileNotFoundError:
raise Exception("Test must be run inside a docker container")

# This is the "config.json" which is passed to `main.py` typically
test_config = collections.defaultdict()
test_config["mqtt"] = dict(device_id="THUNDERBIRD-2")
test_config["mqtt"] = dict(device_id="THUNDERBIRD-2")
test_config["bacnet"] = dict(ip=None, port=None, interface=None)
test_config["nmap"] = dict(targets="127.0.0.1/32")
test_config["udmi"] = {"discovery": dict(ipv4=False,vendor=False,ether=False,bacnet=True)}

# Container for storing all discovery messages
messages = []
Expand All @@ -40,23 +41,21 @@ def test_bacnet_integration():
"timestamp": timestamp_now(),
"discovery": {
"families": {
"bacnet": {"generation": timestamp_now(), "scan_duration_sec": 20},
"ipv4": {"generation": timestamp_now(), "scan_duration_sec": 20}
"bacnet": {"generation": timestamp_now(), "scan_duration_sec": 20}
}
},
})
)

time.sleep(30)

# check has stopped
assert udmi_client.state.discovery.families["bacnet"].phase == udmi.discovery.discovery.states.CANCELLED
assert udmi_client.state.discovery.families["ipv4"].phase == udmi.discovery.discovery.states.CANCELLED
time.sleep(10)

for message in messages:
print(message.to_json())
print("----")

return
assert False, "failing so messages are printed to terminal"

expected_ethmacs = set(d["ether"] for d in docker_config.values())
seen_ethmac_toplevel = set(m.families["ether"].addr for m in messages if "ether" in m.families)

Expand Down
Loading
Loading