Skip to content

Commit 28164cf

Browse files
author
mbay
committed
initial commit
1 parent fb5f813 commit 28164cf

13 files changed

+373
-1
lines changed

API/__init__.py

Whitespace-only changes.

API/authentication.py

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import logging
2+
import os
3+
from base64 import b64encode
4+
from urllib.request import Request
5+
from urllib.request import urlopen
6+
import urllib.request, json, base64
7+
from flask import Flask, jsonify, request
8+
9+
10+
# values provided into environment by cumulocity platform during deployment
11+
C8Y_BASEURL = os.getenv('C8Y_BASEURL')
12+
C8Y_BOOTSTRAP_USER = os.getenv('C8Y_BOOTSTRAP_USER')
13+
C8Y_BOOTSTRAP_TENANT = os.getenv('C8Y_BOOTSTRAP_TENANT')
14+
C8Y_BOOTSTRAP_PASSWORD = os.getenv('C8Y_BOOTSTRAP_PASSWORD')
15+
16+
17+
class Authentication(object):
18+
def __init__(self):
19+
self.logger = logging.getLogger('Authentication')
20+
self.logger.debug('Logger for authentication was initialised')
21+
self.logger.debug('Starting get_authorization')
22+
self.auth = self.get_authorization()
23+
self.logger.debug(f'Got auth: {self.auth}')
24+
self.logger.debug('Getting tenantID')
25+
self.tenantID = os.getenv('C8Y_BOOTSTRAP_TENANT')
26+
self.logger.debug(f'TenantID is: {self.tenantID}')
27+
self.logger.debug('Getting Base URL')
28+
self.tenant = C8Y_BASEURL
29+
self.logger.debug(f'Base URL is: {self.tenant}')
30+
self.payload = {}
31+
self.headers = {}
32+
self.headers['Authorization'] = self.auth
33+
self.headers['Content-Type'] = 'application/json'
34+
self.headers['Accept'] = 'application/json'
35+
36+
37+
def base64_credentials(self, tenant, user, password):
38+
str_credentials = tenant + "/" + user + ":" + password
39+
self.logger.debug(f'Returning {str_credentials}')
40+
return 'Basic ' + base64.b64encode(str_credentials.encode()).decode()
41+
42+
43+
def get_subscriber_for(self, tenant_id):
44+
req = Request(C8Y_BASEURL + '/application/currentApplication/subscriptions')
45+
req.add_header('Accept', 'application/vnd.com.nsn.cumulocity.applicationUserCollection+json')
46+
req.add_header('Authorization', self.base64_credentials(C8Y_BOOTSTRAP_TENANT, C8Y_BOOTSTRAP_USER, C8Y_BOOTSTRAP_PASSWORD))
47+
response = urlopen(req)
48+
subscribers = json.loads(response.read().decode())["users"]
49+
self.logger.debug(subscribers)
50+
return [s for s in subscribers if s["tenant"] == tenant_id][0]
51+
52+
53+
def get_authorization(self):
54+
tenant_id = os.getenv('C8Y_BOOTSTRAP_TENANT')
55+
subscriber = self.get_subscriber_for(tenant_id)
56+
auth = self.base64_credentials(subscriber["tenant"], subscriber["name"], subscriber["password"])
57+
return auth
58+
59+
60+
61+
if __name__ == '__main__':
62+
pass

API/inventory.py

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import requests
2+
import logging
3+
import json
4+
from datetime import datetime, date, time, timedelta
5+
from base64 import b64encode
6+
import API.authentication as auth
7+
8+
9+
logger = logging.getLogger('Inventory API')
10+
logger.info('Logger for Inventory was initialised')
11+
Auth = auth.Authentication()
12+
13+
14+
def create_device(externalID, domain):
15+
try:
16+
logger.debug('Checking for managed object in c8y with external ID %s' + externalID)
17+
url = '%s/inventory/managedObjects'%(Auth.tenant)
18+
payload = json.loads('{"com_cumulocity_model_Agent": {},"c8y_IsDevice": {}, "c8y_metering": {}}')
19+
payload['name'] = f'{domain} - ({externalID})'
20+
response = requests.request("POST", url, headers=Auth.headers, data = json.dumps(payload))
21+
logger.debug('Requesting the following url: ' + str(url))
22+
logger.debug('Response from request: ' + str(response.text))
23+
logger.debug('Response from request with code : ' + str(response.status_code))
24+
if response.status_code == 200 or 201:
25+
logger.info(f'Created a device for the following tenant: {externalID}')
26+
internal_id = json.loads(response.text)['id']
27+
if create_external_ID(externalID,internal_id,'c8y_Serial'):
28+
logger.debug('Returning the internal ID')
29+
return internal_id
30+
else:
31+
logger.error('Raising Exception, external ID was not registered properly')
32+
raise Exception
33+
else:
34+
logger.warning('Response from request: ' + str(response.text))
35+
logger.warning('Got response with status_code: ' + str(response.status_code))
36+
logger.warning('Device was not created properly')
37+
raise Exception
38+
except Exception as e:
39+
logger.error('The following error occured: %s' % (str(e)))
40+
41+
42+
def check_external_ID(external_id, domain):
43+
logger.debug('Checking if external ID exists')
44+
try:
45+
url = f'{Auth.tenant}/identity/externalIds/c8y_Serial/{external_id}'
46+
response = requests.request("GET", url, headers=Auth.headers)
47+
logger.debug('Sending data to the following url: ' + str(url))
48+
logger.debug('Response from request: ' + str(response.text))
49+
logger.debug('Response from request with code : ' + str(response.status_code))
50+
if response.status_code == 200 or response.status_code == 201:
51+
logger.debug('Inventory exists')
52+
logger.debug(json.loads(response.text))
53+
internal_id = json.loads(response.text)['managedObject']['id']
54+
return internal_id
55+
elif response.status_code == 404:
56+
logger.info('Device does not exist, creating it')
57+
return create_device(external_id, domain)
58+
else:
59+
logger.warning('Response from request: ' + str(response.text))
60+
logger.warning('Got response with status_code: ' + str(response.status_code))
61+
raise Exception
62+
except Exception as e:
63+
logger.error('The following error occured: %s' % (str(e)))
64+
65+
66+
def create_external_ID(deviceID,internalID,type):
67+
logger.debug('Create an external id for an existing managed object')
68+
try:
69+
url = "%s/identity/globalIds/%s/externalIds"%(Auth.tenant, internalID)
70+
payload = {}
71+
payload['externalId'] = deviceID
72+
payload['type'] = type
73+
response = requests.request("POST", url, headers=Auth.headers, data = json.dumps(payload))
74+
logger.debug('Response from request: ' + str(response.text))
75+
logger.debug('Response from request with code : ' + str(response.status_code))
76+
if response.status_code == 200 or response.status_code == 201:
77+
logger.debug('Receiving the following response %s'%(str(response.text)))
78+
return True
79+
else:
80+
logger.warning('Response from request: ' + str(response.text))
81+
logger.warning('Got response with status_code: ' + str(response.status_code))
82+
return False
83+
except Exception as e:
84+
logger.error('The following error occured: %s' % (str(e)))
85+
86+
if __name__ == '__main__':
87+
pass
88+

API/measurement.py

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import requests
2+
import logging
3+
import json
4+
import API.authentication as auth
5+
import datetime
6+
7+
8+
logger = logging.getLogger('Measurement API')
9+
logger.info('Logger for Measurements was initialised')
10+
Auth = auth.Authentication()
11+
12+
def send_measurement(payload):
13+
logger.info('Creating measurements in c8y')
14+
try:
15+
url = "%s/measurement/measurements"%(Auth.tenant)
16+
Auth.headers['Accept'] = 'application/vnd.com.nsn.cumulocity.measurementCollection+json'
17+
response = requests.request("POST", url, headers=Auth.headers, data = payload)
18+
logger.debug('Sending data to the following url: ' + str(url))
19+
logger.debug('Response from request: ' + str(response.text))
20+
logger.debug('Response from request with code : ' + str(response.status_code))
21+
if response.status_code == 200 or 201:
22+
logger.info('Measurment send')
23+
return True
24+
else:
25+
logger.warning('Response from request: ' + str(response.text))
26+
logger.warning('Got response with status_code: ' +
27+
str(response.status_code))
28+
except Exception as e:
29+
logger.error('The following error occured: %s' % (str(e)))
30+
31+
32+
def create_c8y_payload(message, internalID):
33+
payload = {}
34+
payload['source'] = {"id": str(internalID)}
35+
payload['type'] = "statistics"
36+
payload['time'] = datetime.datetime.strptime(str(datetime.datetime.utcnow()), '%Y-%m-%d %H:%M:%S.%f').isoformat() + "Z"
37+
38+
payload['StorageSize'] = {}
39+
payload['StorageSize']['Current'] = {'value': message['storageSize']}
40+
payload['StorageSize']['Peak'] = {'value': message['peakStorageSize']}
41+
42+
payload['DevicesWithChildren'] = {}
43+
payload['DevicesWithChildren']['Current'] = {'value': message['deviceWithChildrenCount']}
44+
payload['DevicesWithChildren']['Peak'] = {'value': message['peakDeviceWithChildrenCount']}
45+
46+
payload['Devices'] = {}
47+
payload['Devices']['Current'] = {'value': message['deviceCount']}
48+
payload['Devices']['Peak'] = {'value': message['peakDeviceCount']}
49+
50+
payload['Inventories'] = {}
51+
payload['Inventories']['Updated'] = {'value': message['inventoriesUpdatedCount']}
52+
payload['Inventories']['Created'] = {'value': message['inventoriesCreatedCount']}
53+
54+
payload['Events'] = {}
55+
payload['Events']['Updated'] = {'value': message['eventsUpdatedCount']}
56+
payload['Events']['Created'] = {'value': message['eventsCreatedCount']}
57+
58+
payload['Alarms'] = {}
59+
payload['Alarms']['Updated'] = {'value': message['alarmsUpdatedCount']}
60+
payload['Alarms']['Created'] = {'value': message['alarmsCreatedCount']}
61+
62+
payload['Measurements'] = {}
63+
payload['Measurements']['Created'] = {'value': message['measurementsCreatedCount']}
64+
65+
payload['ResourceCreateAndUpdate'] = {}
66+
payload['ResourceCreateAndUpdate']['Total'] = {'value': message['totalResourceCreateAndUpdateCount']}
67+
68+
payload['Requests'] = {}
69+
payload['Requests']['Total'] = {'value': message['requestCount']}
70+
payload['Requests']['Devices'] = {'value': message['deviceRequestCount']}
71+
72+
logger.debug(f'Created the following payload: {json.dumps(payload)}')
73+
return payload
74+
75+
76+
if __name__ == '__main__':
77+
pass

API/statistics.py

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import requests
2+
import logging
3+
import json
4+
import API.authentication as auth
5+
from datetime import datetime, date, time, timedelta
6+
from API.inventory import check_external_ID
7+
8+
9+
logger = logging.getLogger('Stats API')
10+
logger.info('Logger for Statistics was initialised')
11+
Auth = auth.Authentication()
12+
13+
def get_tenant_stats():
14+
try:
15+
url = f'{Auth.tenant}/tenant/statistics/allTenantsSummary'
16+
logger.debug('Requesting the following url: ' + str(url))
17+
response = requests.request("GET", url, headers=Auth.headers)
18+
logger.debug('Response from request: ' + str(response.text))
19+
logger.debug('Response from request with code : ' + str(response.status_code))
20+
if response.status_code == 200 or response.status_code == 201:
21+
json_data = json.loads(response.text)
22+
return json_data
23+
else:
24+
logger.warning('Response from request: ' + str(response.text))
25+
logger.warning('Got response with status_code: ' + str(response.status_code))
26+
return [{}]
27+
except Exception as e:
28+
logger.error('The following error occured in Stats: %s' % (str(e)))

Dockerfile

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
FROM python:alpine3.8
2+
COPY ./ /app
3+
WORKDIR /app
4+
RUN pip install requests
5+
RUN pip install jsonify
6+
RUN pip install flask
7+
ENTRYPOINT ["python3"]
8+
CMD ["run.py"]

README.md

+48-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,49 @@
11
# cumulocity-subtenant-usage-metering-microservice
2-
This project is a microservice in python that requests tenant statistics for all its subtenants and historicizes them as measurements.
2+
3+
4+
This project is an microservice that requests tenant statistics for all its subtenants and historicizes them as measurements on a created device. With that the approach it can be monitored on a timeseries bases what happens in the subtenants. Furthermore e.g. Smart Rules or Analytics Builder can be used to alert due to certain usage behaviours.
5+
6+
# Content
7+
- [cumulocity-subtenant-usage-metering-microservice](#cumulocity-subtenant-usage-metering-microservice)
8+
- [Content](#content)
9+
- [Quick Start](#quick-start)
10+
- [Solution components](#solution-components)
11+
- [Installation from scratch](#installation-from-scratch)
12+
13+
# Quick Start
14+
Use the provided zip here in the release and upload it as microservice.
15+
16+
![Upload](/resources/upload.png)
17+
18+
# Solution components
19+
20+
The microservice consists of 4 modules and a main runtime:
21+
* `run.py`: Main runtime that opens an health endpoint at /health and also triggers the request of statistics including persisting it as measurements
22+
* `API/authentication.py`: Contains the Authentication class that requests the service user via the bootstrap user from within the microservice environment. See [documentation](https://cumulocity.com/guides/microservice-sdk/concept/#microservice-bootstrap) for more details.
23+
* `API/inventory.py`: Consists of the logic to deliver the internalId of the device representation of the subtenant or creates the device if a new subtenant appears. Currently the externalId is set as the tenantID. The name of the device representation of its particular subtenant is chosen to be {domain-name} - ({tenantID}).
24+
* `API/measurment.py`: Creates the measurement payload from the statistics retreived from API and sends it to Cumulocity.
25+
* `API/statistics.py`: Delivers statistics for all included subtenants via the following REST-API endpoint: /tenant/statistics/summary. See [openAPI description](https://cumulocity.com/api/10.11.0/#operation/getSummaryAllTenantsUsageStatistics) for more details about that.
26+
27+
Currently the sheduled request for statistics is set to be 900s which equals 15 minutes. Debug Level is set to be INFO. Feel free to adjust the resolution but keep in mind that a device is created for every subtenant as well as a certain device class is associated with that.
28+
29+
# Installation from scratch
30+
31+
To build the microservice run:
32+
```
33+
docker buildx build --platform linux/amd64 -t {NAMEOFSERVICE} .
34+
docker save {NAMEOFSERVICE} > image.tar
35+
zip {NAMEOFSERVICE} cumulocity.json image.tar
36+
```
37+
38+
You can upload the microservice via the UI or via [go-c8y-cli](https://github.com/reubenmiller/go-c8y-cli)
39+
40+
![Measurements](/resources/measurements.png)
41+
42+
43+
------------------------------
44+
45+
These tools are provided as-is and without warranty or support. They do not constitute part of the Software AG product suite. Users are free to use, fork and modify them, subject to the license agreement. While Software AG welcomes contributions, we cannot guarantee to include every contribution in the master project.
46+
_____________________
47+
For more information you can Ask a Question in the [TECHcommunity Forums](http://tech.forums.softwareag.com/techjforum/forums/list.page?product=cumulocity).
48+
49+
You can find additional information in the [Software AG TECHcommunity](http://techcommunity.softwareag.com/home/-/product/name/cumulocity).

built.sh

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
docker buildx build --platform linux/amd64 -t metering .
2+
docker save metering > image.tar
3+
zip metering cumulocity.json image.tar

cumulocity.json

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"apiVersion": "1",
3+
"version": "1.0.0",
4+
"provider": {
5+
"name": "Cumulocity"
6+
},
7+
"isolation": "MULTI_TENANT",
8+
"requiredRoles": [
9+
"ROLE_INVENTORY_READ",
10+
"ROLE_INVENTORY_ADMIN",
11+
"ROLE_MEASUREMENT_ADMIN",
12+
"ROLE_TENANT_MANAGEMENT_READ"
13+
],
14+
"roles": [
15+
]
16+
}

requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
requests == 2.24.0
2+
jsonify == 0.5

resources/measurements.png

205 KB
Loading

resources/upload.png

78.4 KB
Loading

run.py

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import sys
2+
import logging
3+
from API.statistics import get_tenant_stats
4+
from API.measurement import create_c8y_payload
5+
from API.measurement import send_measurement
6+
from API.inventory import check_external_ID
7+
from flask import Flask, jsonify, request
8+
import threading
9+
import time
10+
import json
11+
12+
logger = logging.getLogger('metering')
13+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
14+
logger.info('Logger for metering was initialised')
15+
16+
app = Flask(__name__)
17+
18+
@app.route('/health')
19+
def health():
20+
return '{"status":"UP"}'
21+
22+
23+
if __name__== "__main__":
24+
threading.Thread(target=lambda: app.run(host='0.0.0.0', port=80, debug=False, use_reloader=False)).start()
25+
while True:
26+
try:
27+
logger.info("Sending Stats")
28+
stats = get_tenant_stats()
29+
messages = []
30+
payload = {}
31+
for i in stats:
32+
logger.debug(f'Iterating over all stats elements, picking: {i}')
33+
logger.debug("Handing over to create_c8y_payload")
34+
messages.append(create_c8y_payload(i,check_external_ID(i['tenantId'],i['tenantDomain'])))
35+
payload['measurements'] = messages
36+
logger.debug(f'Received the following payload for the whole measurement: {json.dumps(payload)}')
37+
send_measurement(json.dumps(payload))
38+
logger.info("Sleeping")
39+
time.sleep(900)
40+
except Exception as e:
41+
logger.error('The following error occured: %s' % (str(e)))

0 commit comments

Comments
 (0)