diff --git a/quickstart/hono_commands.py b/quickstart/hono_commands.py index e9e322be..97843236 100644 --- a/quickstart/hono_commands.py +++ b/quickstart/hono_commands.py @@ -53,7 +53,7 @@ def on_message(self, event): print('[got response]') response = json.loads(event.message.body) print(json.dumps(response, indent=2)) - if response["status"] == 204: + if 200 <= response["status"] <= 299: print('[ok]', command) else: print('[error]') diff --git a/quickstart/hono_commands_um.py b/quickstart/hono_commands_um.py new file mode 100644 index 00000000..9fe0b36b --- /dev/null +++ b/quickstart/hono_commands_um.py @@ -0,0 +1,237 @@ +# Copyright (c) 2023 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +# which is available at https://www.apache.org/licenses/LICENSE-2.0. +# +# SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +import getopt +import json +import os +import signal +import sys +import threading +import time +import uuid + +from string import Template +from proton import Message +from proton.handlers import MessagingHandler +from proton.reactor import Container + +alpine_container_template = """ +{ +"id": "alpine", +"version": "latest", +"config": [ + { + "key": "image", + "value": "docker.io/library/alpine:latest" + }, + { + "key": "restartPolicy", + "value": "no" + } + ] +},""" + +containers_desired_state = Template(""" +{ + "topic": "$namespace/$name/things/live/messages/apply", + "headers": { + "content-type": "application/json", + "correlation-id": "$correlation_id", + "response-required": true + }, + "path": "/features/UpdateManager/inbox/messages/apply", + "value": { + "activityId": "$activity_id", + "desiredState": { + "domains": [ + { + "id": "containers", + "config": [], + "components": [ + { + "id": "influxdb", + "version": "2.7.1", + "config": [ + { + "key": "image", + "value": "docker.io/library/influxdb:$influxdb_version" + } + ] + }, + $alpine_container + { + "id": "hello-world", + "version": "latest", + "config": [ + { + "key": "image", + "value": "docker.io/library/hello-world:latest" + }, + { + "key": "restartPolicy", + "value": "no" + } + ] + } + ] + } + ] + } +} +} +""") + +containers_desired_state_clean_up = Template(""" +{ + "topic": "$namespace/$name/things/live/messages/apply", + "headers": { + "content-type": "application/json", + "correlation-id": "$correlation_id", + "response-required": true + }, + "path": "/features/UpdateManager/inbox/messages/apply", + "value": { + "activityId": "$activity_id", + "desiredState": { + "domains": [ + { + "id": "containers", + "config": [], + "components": [] + } + ] + } +} +} +""") + +um_refresh_state = Template(""" +{ + "topic": "$namespace/$name/things/live/messages/apply", + "headers": { + "content-type": "application/json", + "correlation-id": "$correlation_id", + "response-required": true + }, + "path": "/features/UpdateManager/inbox/messages/refresh", + "value": { + "activityId": "$activity_id", + } +} +""") + + +class CommandResponsesHandler(MessagingHandler): + def __init__(self, server, address): + super(CommandResponsesHandler, self).__init__() + self.server = server + self.address = address + + def on_start(self, event): + conn = event.container.connect(self.server, user="consumer@HONO", password="verysecret") + event.container.create_receiver(conn, self.address) + print('[connected]') + + def on_message(self, event): + print('[got response]') + response = json.loads(event.message.body) + print(json.dumps(response, indent=2)) + if response["status"] == 204: + print('[ok]', "um") + else: + print('[error]') + event.receiver.close() + event.connection.close() + + def on_connection_closed(self, event): + print('[connection closed]') + os.kill(os.getpid(), signal.SIGINT) + + +class CommandsInvoker(MessagingHandler): + def __init__(self, server, address): + super(CommandsInvoker, self).__init__() + self.server = server + self.address = address + + def on_start(self, event): + conn = event.container.connect(self.server, sasl_enabled=True, allowed_mechs="PLAIN", allow_insecure_mechs=True, + user="consumer@HONO", password="verysecret") + event.container.create_sender(conn, self.address) + + def on_sendable(self, event): + print('[sending command]') + correlation_id = str(uuid.uuid4()) + namespaced_id = device_id.split(':', 1) + activity_id = str(uuid.uuid4()) + + influxdb_version = "1.8.4" + alpine_container = alpine_container_template + if operation == "update": + influxdb_version = "1.8.5" + alpine_container = "" + if operation == "clean": + payload = containers_desired_state_clean_up.substitute(namespace=namespaced_id[0], + name=namespaced_id[1], + correlation_id=correlation_id, + activity_id=activity_id) + else: + payload = containers_desired_state.substitute(namespace=namespaced_id[0], name=namespaced_id[1], + correlation_id=correlation_id, + influxdb_version=influxdb_version, + alpine_container=alpine_container, + activity_id=activity_id) + print(json.dumps(json.loads(payload), indent=2)) + msg = Message(body=payload, address='{}/{}'.format(address, device_id), + content_type="application/json", + subject="um", reply_to=reply_to_address, correlation_id=correlation_id, id=str(uuid.uuid4())) + event.sender.send(msg) + event.sender.close() + event.connection.close() + print('[sent]') + + +# Parse command line args +options, reminder = getopt.getopt(sys.argv[1:], 't:d:o:') +opts_dict = dict(options) +tenant_id = os.environ.get("TENANT") or opts_dict['-t'] +device_id = os.environ.get("DEVICE_ID") or opts_dict['-d'] +operation = opts_dict['-o'] + +# AMQP global configurations +uri = 'amqps://hono.eclipseprojects.io:15671' +address = 'command/{}'.format(tenant_id) +reply_to_address = 'command_response/{}/replies'.format(tenant_id) +print('[starting] demo update manager app for tenant [{}], device [{}] at [{}]'.format(tenant_id, device_id, uri)) + +# Create command invoker and handler +response_handler = Container(CommandResponsesHandler(uri, reply_to_address)) +commands_invoker = Container(CommandsInvoker(uri, address)) +thread = threading.Thread(target=lambda: response_handler.run(), daemon=True) +thread.start() +# Give it some time to link +time.sleep(2) +# Send the command +commands_invoker.run() + + +def handler(signum, frame): + print('[stopping] demo update manager app for tenant [{}], device [{}] at [{}]'.format(tenant_id, device_id, uri)) + response_handler.stop() + thread.join(timeout=5) + print('[stopped]') + exit(0) + + +signal.signal(signal.SIGINT, handler) +while True: + pass diff --git a/quickstart/hono_events.py b/quickstart/hono_events.py index ff9115c9..1a61def8 100644 --- a/quickstart/hono_events.py +++ b/quickstart/hono_events.py @@ -32,16 +32,24 @@ def on_start(self, event): print('[connected]') def on_message(self, event): - print('[event received]') if event.message.body is not None: - print(json.dumps(json.loads(event.message.body), indent=2)) + body = json.loads(event.message.body) + if topic_filter != "" and topic_filter != body['topic']: + return + print('[event received]') + print(json.dumps(body, indent=2)) else: - print('') + print('[empty event received]') # Parse command line args -options, reminder = getopt.getopt(sys.argv[1:], 't:') -tenant_id = os.environ.get("TENANT") or dict(options)['-t'] +options, reminder = getopt.getopt(sys.argv[1:], 't:f:') +opts_dict = dict(options) +tenant_id = os.environ.get("TENANT") or opts_dict['-t'] +if '-f' in opts_dict: + topic_filter = opts_dict['-f'] +else: + topic_filter = "" uri = 'amqps://hono.eclipseprojects.io:15671' address = 'event/{}'.format(tenant_id) diff --git a/web/site/content/docs/getting-started/install.md b/web/site/content/docs/getting-started/install.md index 36d17198..d586d8e7 100644 --- a/web/site/content/docs/getting-started/install.md +++ b/web/site/content/docs/getting-started/install.md @@ -37,7 +37,8 @@ container-management.service \ software-update.service \ file-upload.service \ file-backup.service \ -system-metrics.service +system-metrics.service \ +kanto-update-manager.service ``` All listed services must be in an active running state. diff --git a/web/site/content/docs/how-to-guides/perform-ota-update.md b/web/site/content/docs/how-to-guides/perform-ota-update.md new file mode 100644 index 00000000..6cc46c42 --- /dev/null +++ b/web/site/content/docs/how-to-guides/perform-ota-update.md @@ -0,0 +1,148 @@ +--- +title: "Perform OTA update" +type: docs +description: > + Perform an OTA update on your edge device. +weight: 3 +--- + +By following the steps below you will publish a simple `Desired State` specification via a publicly available Eclipse Hono sandbox and then the specification will be handled by the Eclipse Kanto Update Manager, which will trigger an OTA update on +the edge device. + +A simple monitoring application will track the progress and the status of the update process. +### Before you begin + +To ensure that all steps in this guide can be executed, you need: + +* Debian-based linux distribution and the `apt` command line tool + +* If you don't have an installed and running Eclipse Kanto, follow {{% relrefn "install" %}} Install Eclipse Kanto {{% /relrefn %}} + +* If you don't have a connected Eclipse Kanto to Eclipse Hono sandbox, + follow {{% relrefn "hono" %}} Explore via Eclipse Hono {{% /relrefn %}} + +* The {{% refn "https://github.com/eclipse-kanto/kanto/blob/main/quickstart/hono_commands_um.py" %}} + update manager application {{% /refn %}} + + Navigate to the `quickstart` folder where the resources from the {{% relrefn "hono" %}} Explore via Eclipse Hono {{% /relrefn %}} + guide are located and execute the following script: + + ```shell + wget https://github.com/eclipse-kanto/kanto/raw/main/quickstart/hono_commands_um.py + +* Enable the `containers update agent` service of the `Container Management` by adding the ` "update_agent": {"enable": true}` property to the `container-management` service configuration (by default located at `/etc/container-management/config.json`) + and restart the service: + ```shell + systemctl restart container-management + ``` + +### Publish the `Desired State` specification + +First, start the monitoring application that requires the configured Eclipse Hono tenant (`-t`) and an optional filter parameter (`-f`). It will print all +received feedback events triggered by the device: + +```shell +python3 hono_events.py -t demo -f demo/device/things/live/messages/feedback +``` + +The starting point of the OTA update process is to publish the example `Desired State` specification: +```shell +python3 hono_commands_um.py -t demo -d demo:device -o apply +``` + +The `Desired State` specification in this case consists of single domain section definition for the containers domain and a three container components - `influxdb`, `hello-world` and `alpine` image. + +### Apply `Desired State` specification + +The Update Manager receives the published `Desired State` to the local Mosquitto broker, splits the specification (in this case into single domain) and then +distributes the processed specification to the domain agents which initiates the actual update process logic on the domain agents side. + +The update process is organized into multiple phases, which are triggered by sending specific `Desired State` commands (`DOWNLOAD/UPDATE/ACTIVATE`). + +In the example scenario, the three images for the three container components will be pulled (if not available in the cache locally), created as containers during the `UPDATING` phase and +started in the `ACTIVATING` phase. + +### Monitor OTA update progress + +During the OTA update, the progress can be tracked in the monitoring application fot the `Desired State` feedback messages, started in the prerequisite section above. + +The Update Manager reports at a time interval of a second the status of the active update process. For example: +``` +{ + "activityId":"e5c858cc-2057-41b0-bd5f-83aee0aad22e", + "timestamp":1693201088401, + "desiredStateFeedback":{ + "status":"RUNNING", + "actions":[ + { + "component":{ + "id":"containers:alpine", + "version":"latest" + }, + "status":"UPDATE_SUCCESS", + "message":"New container instance is started." + }, + { + "component":{ + "id":"containers:hello-world", + "version":"latest" + }, + "status":"UPDATE_SUCCESS", + "message":"New container instance is started." + }, + { + "component":{ + "id":"containers:influxdb", + "version":"2.7.1" + }, + "status":"UPDATING", + "message":"New container created." + } + ] + } +} +``` + +### List containers + +After the update process is completed, list the installed containers by executing the command `kanto-cm list` to verify if the `Desired State` is applied correctly. + +The output of the command should display the info about the three containers, described in the `Desired State` specification. The `influxdb` is expected to be in `RUNNING` state and +the other containers in status `EXITED`. For example : +``` +ID |Name |Image |Status |Finished At |Exit Code +|-------------------------------------|-------------|------------------------------------|----------------------|--------- +7fe6b689-eb76-476d-a730-c2f422d6e8ea |influxdb |docker.io/library/influxdb:1.8.4 |Running | |0 +c36523d7-8d17-4255-ae0c-37f11003f658 |hello-world |docker.io/library/hello-world:latest|Exited | |0 +9b99978b-2593-4736-bb52-7a07be4a7ed1 |alpine |docker.io/library/alpine:latest |Exited | |0 +``` + +### Update `Desired State` specification + +To update the existing `Desired State` run the command below. The update changes affect two containers - `alpine` and `influxdb`. Being not present in the updated `Desired State` specification, the `alpine` container will be removed from the system. The `influxdb` will be updated to version 1.8.5. The last container - `hello-world` is not affected and any events will be not reported from the container update agent for this particular container. + +```shell +python3 hono_commands_um.py -t demo -d demo:device -o update +``` + +### List updated containers + +After the update process of the existing `Desired State` is completed, list again the available containers to the verify the `Desired State` is updated correctly. + +The output of the command should display the info about the two containers, described in the `Desired State` specification. The `influxdb` is expected to be updated with the version 1.8.5 and in `RUNNING` state and `hello-world` container to be status `EXITED` with version unchanged. The `alpine` container must be removed and not displayed. +``` +ID |Name |Image |Status |Finished At |Exit Code +|-------------------------------------|-------------|------------------------------------|----------------------|--------- +7fe6b689-eb76-476d-a730-c2f422d6e8ea |influxdb |docker.io/library/influxdb:1.8.5 |Running | |0 +c36523d7-8d17-4255-ae0c-37f11003f658 |hello-world |docker.io/library/hello-world:latest|Exited | |0 +``` + +### Remove all containers + +To remove all containers, publish an empty `Desired State` specification (with empty `components` section): +```shell +python3 hono_commands_um.py -t demo -d demo:device -o clean +``` + +As a final step, execute the command `kanto-cm list` to verify that the containers are actually removed from the Kanto container management. +The expected output is `No found containers.`. \ No newline at end of file