Skip to content

Commit f7dad8a

Browse files
committed
feat(devices): add API to query for senML import logs
1 parent 170a1df commit f7dad8a

13 files changed

+390
-92
lines changed

cdk/BackendLambdas.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ type BackendLambdas = {
1212
createCredentials: PackedLambda
1313
openSSL: PackedLambda
1414
senMLToLwM2M: PackedLambda
15+
senMLImportLogs: PackedLambda
1516
}

cdk/baseLayer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const dependencies: Array<keyof (typeof pJson)['dependencies']> = [
1616
'@aws-lambda-powertools/metrics',
1717
'@hello.nrfcloud.com/nrfcloud-api-helpers',
1818
'@hello.nrfcloud.com/lambda-helpers',
19+
'id128',
20+
'p-retry',
1921
]
2022

2123
export const pack = async (): Promise<PackedLayer> =>

cdk/packBackendLambdas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ export const packBackendLambdas = async (): Promise<BackendLambdas> => ({
1717
createCredentials: await pack('createCredentials'),
1818
openSSL: await pack('openSSL'),
1919
senMLToLwM2M: await pack('senMLToLwM2M'),
20+
senMLImportLogs: await pack('senMLImportLogs'),
2021
})

cdk/resources/SenMLMessage.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,20 @@ import {
77
aws_iam as IAM,
88
aws_iot as IoT,
99
aws_lambda as Lambda,
10+
aws_logs as Logs,
11+
RemovalPolicy,
12+
Stack,
1013
} from 'aws-cdk-lib'
1114
import { Construct } from 'constructs'
1215
import type { BackendLambdas } from '../BackendLambdas.js'
1316
import type { PublicDevices } from './PublicDevices.js'
17+
import { RetentionDays } from 'aws-cdk-lib/aws-logs'
1418

1519
/**
1620
* Handle incoming SenML messages
1721
*/
1822
export class SenMLMessages extends Construct {
23+
public readonly importLogsFn: Lambda.IFunction
1924
constructor(
2025
parent: Construct,
2126
{
@@ -24,12 +29,19 @@ export class SenMLMessages extends Construct {
2429
publicDevices,
2530
}: {
2631
baseLayer: Lambda.ILayerVersion
27-
lambdaSources: Pick<BackendLambdas, 'senMLToLwM2M'>
32+
lambdaSources: Pick<BackendLambdas, 'senMLToLwM2M' | 'senMLImportLogs'>
2833
publicDevices: PublicDevices
2934
},
3035
) {
3136
super(parent, 'senml-messages')
3237

38+
const importLogs = new Logs.LogGroup(this, 'importLogs', {
39+
logGroupName: `${Stack.of(this).stackName}/senml-device-message-import`,
40+
retention: RetentionDays.ONE_MONTH,
41+
logGroupClass: Logs.LogGroupClass.INFREQUENT_ACCESS,
42+
removalPolicy: RemovalPolicy.DESTROY,
43+
})
44+
3345
const fn = new Lambda.Function(this, 'fn', {
3446
handler: lambdaSources.senMLToLwM2M.handler,
3547
architecture: Lambda.Architecture.ARM_64,
@@ -43,6 +55,7 @@ export class SenMLMessages extends Construct {
4355
environment: {
4456
VERSION: this.node.getContext('version'),
4557
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
58+
IMPORT_LOGGROUP_NAME: importLogs.logGroupName,
4659
},
4760
initialPolicy: [
4861
new IAM.PolicyStatement({
@@ -53,6 +66,7 @@ export class SenMLMessages extends Construct {
5366
...new LambdaLogGroup(this, 'fnLogs'),
5467
})
5568
publicDevices.publicDevicesTable.grantReadData(fn)
69+
importLogs.grantWrite(fn)
5670

5771
const rule = new IoT.CfnTopicRule(this, 'rule', {
5872
topicRulePayload: {
@@ -86,5 +100,28 @@ export class SenMLMessages extends Construct {
86100
) as IAM.IPrincipal,
87101
sourceArn: rule.attrArn,
88102
})
103+
104+
this.importLogsFn = new Lambda.Function(this, 'importLogsFn', {
105+
handler: lambdaSources.senMLImportLogs.handler,
106+
architecture: Lambda.Architecture.ARM_64,
107+
runtime: Lambda.Runtime.NODEJS_20_X,
108+
timeout: Duration.minutes(1),
109+
memorySize: 1792,
110+
code: Lambda.Code.fromAsset(lambdaSources.senMLImportLogs.zipFile),
111+
description: 'Returns the last import messages for a device.',
112+
layers: [baseLayer],
113+
environment: {
114+
VERSION: this.node.getContext('version'),
115+
IMPORT_LOGGROUP_NAME: importLogs.logGroupName,
116+
},
117+
...new LambdaLogGroup(this, 'importLogsFnLogs'),
118+
initialPolicy: [
119+
new IAM.PolicyStatement({
120+
actions: ['logs:StartQuery', 'logs:GetQueryResults'],
121+
resources: [importLogs.logGroupArn],
122+
}),
123+
],
124+
})
125+
importLogs.grantRead(this.importLogsFn)
89126
}
90127
}

cdk/stacks/BackendStack.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,25 +60,29 @@ export class BackendStack extends Stack {
6060

6161
const publicDevices = new PublicDevices(this)
6262

63+
const api = new API(this)
64+
6365
new LwM2MShadow(this, {
6466
baseLayer,
6567
lambdaSources,
6668
publicDevices,
6769
})
6870

69-
new SenMLMessages(this, {
71+
const senMLMessages = new SenMLMessages(this, {
7072
baseLayer,
7173
lambdaSources,
7274
publicDevices,
7375
})
76+
api.addRoute(
77+
'GET /device/{id}/senml-import-logs',
78+
senMLMessages.importLogsFn,
79+
)
7480

7581
new ConnectionInformationGeoLocation(this, {
7682
baseLayer,
7783
lambdaSources,
7884
})
7985

80-
const api = new API(this)
81-
8286
const shareAPI = new ShareAPI(this, {
8387
baseLayer,
8488
lambdaSources,

cli/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { configureNrfCloudAccount } from './commands/configure-nrfcloud-account.
1717
import { logsCommand } from './commands/logs.js'
1818
import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs'
1919
import { configureHello } from './commands/configure-hello.js'
20+
import { shareDevice } from './commands/share-device.js'
2021

2122
const ssm = new SSMClient({})
2223
const db = new DynamoDBClient({})
@@ -72,6 +73,10 @@ const CLI = async ({ isCI }: { isCI: boolean }) => {
7273
env: accountEnv,
7374
stackName: STACK_NAME,
7475
}),
76+
shareDevice({
77+
db,
78+
publicDevicesTableName: mapOutputs.publicDevicesTableName,
79+
}),
7580
)
7681
} catch (error) {
7782
console.warn(chalk.yellow('⚠️'), chalk.yellow((error as Error).message))

cli/commands/register-custom-device.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,12 @@ export const registerCustomMapDevice = ({
4848
deviceId,
4949
model,
5050
email,
51-
generateToken: () => '123456',
51+
confirmed: true,
5252
})
5353
if ('error' in maybePublished) {
5454
console.error(maybePublished.error)
5555
throw new Error(`Failed to register custom device.`)
5656
}
57-
const maybeConfirmed = await publicDevice.confirmOwnership({
58-
deviceId,
59-
ownershipConfirmationToken: '123456',
60-
})
61-
if ('error' in maybeConfirmed) {
62-
console.error(maybeConfirmed.error)
63-
throw new Error(`Failed to confirm custom device.`)
64-
}
6557

6658
const certDir = path.join(
6759
process.cwd(),

cli/commands/share-device.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { DynamoDBClient } from '@aws-sdk/client-dynamodb'
2+
import { models } from '@hello.nrfcloud.com/proto-map'
3+
import chalk from 'chalk'
4+
import { publicDevicesRepo } from '../../sharing/publicDevicesRepo.js'
5+
import type { CommandDefinition } from './CommandDefinition.js'
6+
7+
const modelIDs = Object.keys(models)
8+
9+
export const shareDevice = ({
10+
db,
11+
publicDevicesTableName,
12+
}: {
13+
db: DynamoDBClient
14+
publicDevicesTableName: string
15+
}): CommandDefinition => ({
16+
command: `share-device <deviceId> <model> <email>`,
17+
action: async (deviceId, model, email) => {
18+
console.log(publicDevicesTableName)
19+
if (!modelIDs.includes(model))
20+
throw new Error(
21+
`Unknown model ${model}. Known models are ${modelIDs.join(', ')}.`,
22+
)
23+
if (!/.+@.+/.test(email)) {
24+
throw new Error(`Must provide valid email.`)
25+
}
26+
console.debug(chalk.yellow('Device ID:'), chalk.blue(deviceId))
27+
const publicDevice = publicDevicesRepo({
28+
db,
29+
TableName: publicDevicesTableName,
30+
})
31+
const maybePublished = await publicDevice.share({
32+
deviceId,
33+
model,
34+
email,
35+
confirmed: true,
36+
})
37+
if ('error' in maybePublished) {
38+
console.error(maybePublished.error)
39+
throw new Error(`Failed to share device.`)
40+
}
41+
},
42+
help: 'Shares an existing device to be shown on the map',
43+
})

lambda/senMLImportLogs.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
CloudWatchLogsClient,
3+
GetQueryResultsCommand,
4+
QueryStatus,
5+
StartQueryCommand,
6+
type ResultField,
7+
} from '@aws-sdk/client-cloudwatch-logs'
8+
import { aProblem } from '@hello.nrfcloud.com/lambda-helpers/aProblem'
9+
import { aResponse } from '@hello.nrfcloud.com/lambda-helpers/aResponse'
10+
import { addVersionHeader } from '@hello.nrfcloud.com/lambda-helpers/addVersionHeader'
11+
import { corsOPTIONS } from '@hello.nrfcloud.com/lambda-helpers/corsOPTIONS'
12+
import {
13+
formatTypeBoxErrors,
14+
validateWithTypeBox,
15+
} from '@hello.nrfcloud.com/proto'
16+
import { Context, DeviceId } from '@hello.nrfcloud.com/proto-map/api'
17+
import middy from '@middy/core'
18+
import { fromEnv } from '@nordicsemiconductor/from-env'
19+
import { Type } from '@sinclair/typebox'
20+
import type {
21+
APIGatewayProxyEventV2,
22+
APIGatewayProxyResultV2,
23+
} from 'aws-lambda'
24+
import pRetry from 'p-retry'
25+
26+
const { importLogGroupName, version } = fromEnv({
27+
importLogGroupName: 'IMPORT_LOGGROUP_NAME',
28+
version: 'VERSION',
29+
})(process.env)
30+
31+
const logs = new CloudWatchLogsClient({})
32+
33+
const validateInput = validateWithTypeBox(
34+
Type.Object({
35+
id: DeviceId,
36+
}),
37+
)
38+
39+
const h = async (
40+
event: APIGatewayProxyEventV2,
41+
): Promise<APIGatewayProxyResultV2> => {
42+
const maybeValidQuery = validateInput(event.pathParameters)
43+
44+
if ('errors' in maybeValidQuery) {
45+
return aProblem({
46+
title: 'Validation failed',
47+
status: 400,
48+
detail: formatTypeBoxErrors(maybeValidQuery.errors),
49+
})
50+
}
51+
52+
const queryString = `filter @logStream LIKE '${maybeValidQuery.value.id}-'
53+
| fields @timestamp, @message
54+
| sort @timestamp desc
55+
| limit 100`
56+
const { queryId } = await logs.send(
57+
new StartQueryCommand({
58+
logGroupName: importLogGroupName,
59+
queryString,
60+
startTime: Date.now() - 24 * 60 * 60 * 1000,
61+
endTime: Date.now(),
62+
}),
63+
)
64+
console.debug({ queryId, queryString })
65+
66+
const results = await pRetry(
67+
async () => {
68+
const result = await logs.send(
69+
new GetQueryResultsCommand({
70+
queryId,
71+
}),
72+
)
73+
switch (result.status) {
74+
case QueryStatus.Cancelled:
75+
return []
76+
case QueryStatus.Complete:
77+
return result.results
78+
case QueryStatus.Failed:
79+
console.error(`Query failed!`)
80+
return []
81+
case QueryStatus.Timeout:
82+
console.error(`Query timed out!`)
83+
return []
84+
case QueryStatus.Running:
85+
case QueryStatus.Scheduled:
86+
throw new Error(`Running!`)
87+
case QueryStatus.Unknown:
88+
default:
89+
console.debug('Unknown query status.')
90+
return []
91+
}
92+
},
93+
{
94+
factor: 1,
95+
minTimeout: 1000,
96+
retries: 10,
97+
},
98+
)
99+
100+
return aResponse(
101+
200,
102+
{
103+
'@context': Context.named('senml-import-logs'),
104+
results: (results ?? []).map((fields) => {
105+
const result = JSON.parse((fields[1] as ResultField).value as string)
106+
return {
107+
...result,
108+
ts: new Date(
109+
(fields[0] as ResultField).value as string,
110+
).toISOString(),
111+
}
112+
}),
113+
},
114+
60,
115+
)
116+
}
117+
118+
export const handler = middy()
119+
.use(addVersionHeader(version))
120+
.use(corsOPTIONS('GET'))
121+
.handler(h)

0 commit comments

Comments
 (0)