Skip to content

Commit 95ed330

Browse files
authored
feat(fota): abort fota jobs (#954)
Implement the ability for users to cancel multi-bundle FOTA jobs. This also cancels running FOTA jobs on nRF Cloud. Closes #962 Closes #957
1 parent 7344da4 commit 95ed330

File tree

15 files changed

+692
-298
lines changed

15 files changed

+692
-298
lines changed

cdk/BackendStack.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,11 @@ export class BackendStack extends Stack {
335335
mbff.startMultiBundleFOTAFlow.fn,
336336
)
337337

338+
api.addRoute(
339+
'DELETE /device/{deviceId}/fota/job/{jobId}',
340+
mbff.abortMultiBundleFOTAFlow.fn,
341+
)
342+
338343
const updateDevice = new UpdateDevice(this, {
339344
lambdaSources,
340345
layers: [baseLayerVersion],

cdk/packBackendLambdas.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ export type BackendLambdas = {
4141
createCNAMERecord: PackedLambda
4242
multiBundleFOTAFlow: {
4343
start: PackedLambda
44+
abort: PackedLambda
45+
onFail: PackedLambda
4446
getDeviceFirmwareDetails: PackedLambda
4547
getNextBundle: PackedLambda
4648
createFOTAJob: PackedLambda
49+
cancelFOTAJob: PackedLambda
4750
WaitForFOTAJobCompletionCallback: PackedLambda
4851
waitForFOTAJobCompletion: PackedLambda
4952
waitForUpdateAppliedCallback: PackedLambda
@@ -134,6 +137,14 @@ export const packBackendLambdas = async (): Promise<BackendLambdas> => ({
134137
'multiBundleFOTAFlowStart',
135138
'lambda/fota/multi-bundle-flow/start.ts',
136139
),
140+
abort: await packLambdaFromPath(
141+
'multiBundleFOTAFlowAbort',
142+
'lambda/fota/multi-bundle-flow/abort.ts',
143+
),
144+
onFail: await packLambdaFromPath(
145+
'multiBundleFOTAFlowOnFail',
146+
'lambda/fota/multi-bundle-flow/onFail.ts',
147+
),
137148
getDeviceFirmwareDetails: await packLambdaFromPath(
138149
'multiBundleFOTAFlowGetDeviceFirmareDetails',
139150
'lambda/fota/multi-bundle-flow/getDeviceFirmwareDetails.ts',
@@ -146,6 +157,10 @@ export const packBackendLambdas = async (): Promise<BackendLambdas> => ({
146157
'multiBundleFOTAFlowCreateFOTAJob',
147158
'lambda/fota/multi-bundle-flow/createFOTAJob.ts',
148159
),
160+
cancelFOTAJob: await packLambdaFromPath(
161+
'multiBundleFOTAFlowCancelFOTAJob',
162+
'lambda/fota/multi-bundle-flow/cancelFOTAJob.ts',
163+
),
149164
WaitForFOTAJobCompletionCallback: await packLambdaFromPath(
150165
'multiBundleFOTAFlowWaitForFOTAJobCompletionCallback',
151166
'lambda/fota/multi-bundle-flow/waitForFOTAJobCompletionCallback.ts',

cdk/resources/FOTA/MultiBundleFlow.ts

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import { definitions, LwM2MObjectID } from '@hello.nrfcloud.com/proto-map/lwm2m'
88
import { FOTAJobStatus } from '@hello.nrfcloud.com/proto/hello'
99
import {
1010
Duration,
11+
aws_dynamodb as DynamoDB,
12+
aws_events as Events,
1113
aws_lambda_event_sources as EventSources,
14+
aws_events_targets as EventsTargets,
1215
aws_iam as IAM,
1316
aws_iot as IoT,
1417
aws_lambda as Lambda,
18+
Stack,
1519
aws_stepfunctions_tasks as StepFunctionsTasks,
1620
type aws_logs as Logs,
1721
} from 'aws-cdk-lib'
@@ -27,7 +31,6 @@ import {
2731
StateMachineType,
2832
Succeed,
2933
TaskInput,
30-
type IStateMachine,
3134
} from 'aws-cdk-lib/aws-stepfunctions'
3235
import {
3336
DynamoAttributeValue,
@@ -44,7 +47,7 @@ import type { DeviceStorage } from '../DeviceStorage.js'
4447
* save the amount of data that needs to be transferred.
4548
*/
4649
export class MultiBundleFOTAFlow extends Construct {
47-
public readonly stateMachine: IStateMachine
50+
public readonly stateMachine: StateMachine
4851
public readonly GetDeviceFirmwareDetails: PackedLambdaFn
4952
public readonly GetNextBundle: PackedLambdaFn
5053
public readonly CreateFOTAJob: PackedLambdaFn
@@ -53,6 +56,7 @@ export class MultiBundleFOTAFlow extends Construct {
5356
public readonly WaitForUpdateAppliedCallback: PackedLambdaFn
5457
public readonly WaitForUpdateApplied: PackedLambdaFn
5558
public readonly startMultiBundleFOTAFlow: PackedLambdaFn
59+
public readonly abortMultiBundleFOTAFlow: PackedLambdaFn
5660

5761
public constructor(
5862
parent: Construct,
@@ -205,6 +209,9 @@ export class MultiBundleFOTAFlow extends Construct {
205209
nextUpdateAt: DynamoAttributeValue.fromString(
206210
JsonPath.stateEnteredTime,
207211
),
212+
parentJobId: DynamoAttributeValue.fromString(
213+
JsonPath.executionName,
214+
),
208215
},
209216
resultPath: '$.DynamoDB',
210217
}),
@@ -437,7 +444,7 @@ export class MultiBundleFOTAFlow extends Construct {
437444
})
438445
deviceFOTA.nrfCloudJobStatusTable.grantReadWriteData(this.stateMachine)
439446

440-
const startMultiBundleFOTAFlow = new PackedLambdaFn(
447+
this.startMultiBundleFOTAFlow = new PackedLambdaFn(
441448
this,
442449
'startMultiBundleFOTAFlow',
443450
lambdas.start,
@@ -459,10 +466,9 @@ export class MultiBundleFOTAFlow extends Construct {
459466
logGroup: deviceFOTA.logGroup,
460467
},
461468
)
462-
this.startMultiBundleFOTAFlow = startMultiBundleFOTAFlow
463-
this.stateMachine.grantStartExecution(startMultiBundleFOTAFlow.fn)
464-
deviceStorage.devicesTable.grantReadData(startMultiBundleFOTAFlow.fn)
465-
deviceFOTA.jobTable.grantWriteData(startMultiBundleFOTAFlow.fn)
469+
this.stateMachine.grantStartExecution(this.startMultiBundleFOTAFlow.fn)
470+
deviceStorage.devicesTable.grantReadData(this.startMultiBundleFOTAFlow.fn)
471+
deviceFOTA.jobTable.grantWriteData(this.startMultiBundleFOTAFlow.fn)
466472

467473
this.WaitForFOTAJobCompletion = new PackedLambdaFn(
468474
this,
@@ -556,6 +562,109 @@ export class MultiBundleFOTAFlow extends Construct {
556562
principal: new IAM.ServicePrincipal('iot.amazonaws.com'),
557563
sourceArn: waitForFirmwareVersionReportRule.attrArn,
558564
})
565+
566+
// Users can abort the FOTA flow
567+
this.abortMultiBundleFOTAFlow = new PackedLambdaFn(
568+
this,
569+
'abortMultiBundleFOTAFlow',
570+
lambdas.abort,
571+
{
572+
description: 'REST entry point for aborting running FOTA flows',
573+
environment: {
574+
DEVICES_TABLE_NAME: deviceStorage.devicesTable.tableName,
575+
STATE_MACHINE_ARN: this.stateMachine.stateMachineArn,
576+
},
577+
layers,
578+
logGroup: deviceFOTA.logGroup,
579+
initialPolicy: [
580+
new IAM.PolicyStatement({
581+
actions: ['states:DescribeExecution', 'states:StopExecution'],
582+
resources: [
583+
`arn:aws:states:${Stack.of(this).region}:${Stack.of(this).account}:execution:${this.stateMachine.stateMachineName}:*`,
584+
],
585+
}),
586+
],
587+
},
588+
)
589+
deviceStorage.devicesTable.grantReadData(this.abortMultiBundleFOTAFlow.fn)
590+
591+
// Handles failed or cancelled step function executions
592+
const onStepFunctionFail = new PackedLambdaFn(
593+
this,
594+
'onFail',
595+
lambdas.onFail,
596+
{
597+
description: 'Handles failed or cancelled step function executions',
598+
environment: {
599+
STATE_MACHINE_ARN: this.stateMachine.stateMachineArn,
600+
JOB_TABLE_NAME: deviceFOTA.jobTable.tableName,
601+
},
602+
layers,
603+
logGroup: deviceFOTA.logGroup,
604+
},
605+
)
606+
deviceFOTA.jobTable.grantReadWriteData(onStepFunctionFail.fn)
607+
new Events.Rule(this, 'onStepFunctionFailRule', {
608+
eventPattern: {
609+
source: ['aws.states'],
610+
detail: {
611+
status: ['FAILED', 'TIMED_OUT', 'ABORTED'],
612+
stateMachineArn: [this.stateMachine.stateMachineArn],
613+
},
614+
},
615+
targets: [new EventsTargets.LambdaFunction(onStepFunctionFail.fn)],
616+
})
617+
618+
// Cancel the FOTA job on nRF Cloud if the step function is cancelled
619+
const parentJobIdIndexName = 'parentJobIdIndex'
620+
deviceFOTA.nrfCloudJobStatusTable.addGlobalSecondaryIndex({
621+
indexName: parentJobIdIndexName,
622+
partitionKey: {
623+
name: 'parentJobId',
624+
type: DynamoDB.AttributeType.STRING,
625+
},
626+
sortKey: {
627+
name: 'jobId',
628+
type: DynamoDB.AttributeType.STRING,
629+
},
630+
projectionType: DynamoDB.ProjectionType.INCLUDE,
631+
nonKeyAttributes: ['status'],
632+
})
633+
const cancelFOTAJob = new PackedLambdaFn(
634+
this,
635+
'cancelFOTAJob',
636+
lambdas.cancelFOTAJob,
637+
{
638+
description:
639+
'Cancel the FOTA job on nRF Cloud if the step function is cancelled',
640+
layers,
641+
timeout: Duration.minutes(1),
642+
logGroup: deviceFOTA.logGroup,
643+
environment: {
644+
NRF_CLOUD_JOB_STATUS_TABLE_NAME:
645+
deviceFOTA.nrfCloudJobStatusTable.tableName,
646+
NRF_CLOUD_JOB_STATUS_TABLE_PARENT_JOB_ID_INDEX_NAME:
647+
parentJobIdIndexName,
648+
},
649+
},
650+
)
651+
deviceFOTA.nrfCloudJobStatusTable.grantReadData(cancelFOTAJob.fn)
652+
cancelFOTAJob.fn.addEventSource(
653+
new EventSources.DynamoEventSource(deviceFOTA.jobTable, {
654+
startingPosition: Lambda.StartingPosition.LATEST,
655+
filters: [
656+
Lambda.FilterCriteria.filter({
657+
dynamodb: {
658+
NewImage: {
659+
status: {
660+
S: [FOTAJobStatus.FAILED],
661+
},
662+
},
663+
},
664+
}),
665+
],
666+
}),
667+
)
559668
}
560669
}
561670

0 commit comments

Comments
 (0)