diff --git a/README.md b/README.md index d9dd536..85819db 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Very much in Alpha. - [ ] Add artifact model to image versions - [ ] Link artifacts to objects - [x] (DDI) poll deployments -- [ ] (DDI) send deployment base +- [x] (DDI) send deployment base - [x] (DDI) send installed base - [ ] (DDI) deployment feedback - [ ] (DDI) send artifacts diff --git a/apps/server/src/ddi/ddi.controller.spec.ts b/apps/server/src/ddi/ddi.controller.spec.ts index 148643e..cbf533e 100644 --- a/apps/server/src/ddi/ddi.controller.spec.ts +++ b/apps/server/src/ddi/ddi.controller.spec.ts @@ -4,6 +4,9 @@ import { DdiService } from './ddi.service'; import { NotImplementedException, NotFoundException } from '@nestjs/common'; import { WorkspaceDeviceParams, WorkspaceDeviceDeploymentParams, WorkspaceDeviceImageVersionParams, WorkspaceDeviceImageVersionFilenameParams } from './dtos/path-params.dto'; import { ConfigDto, LinkDto, LinksDto, PollingConfigDto, RootDto } from './dtos/root-res.dto'; +import { FinishedEnum } from './dtos/deployment-feedback-req.dto'; +import { ExecutionEnum } from './dtos/deployment-feedback-req.dto'; +import { DeploymentBaseFeedbackDto } from './dtos/deployment-feedback-req.dto'; describe('DdiController', () => { let controller: DdiController; @@ -190,11 +193,27 @@ describe('DdiController', () => { deploymentId: mockDeploymentId, }; - mockDdiService.postDeploymentFeedback.mockResolvedValue({ hello: 'world' }); + const mockDeploymentBaseFeedback: DeploymentBaseFeedbackDto = { + status: { + execution: ExecutionEnum.CLOSED, + result: { + finished: FinishedEnum.SUCCESS, + progress: { + cnt: 10, + of: 100, + }, + }, + code: 0, + details: ['detail1', 'detail2'], + }, + time: new Date().toISOString(), + }; - const result = await controller.postDeploymentFeedback(params); - expect(result).toEqual({ hello: 'world' }); - expect(service.postDeploymentFeedback).toHaveBeenCalledWith(mockWorkspaceId, mockDeviceId, mockDeploymentId); + mockDdiService.postDeploymentFeedback.mockResolvedValue(null); + + const result = await controller.postDeploymentFeedback(params, mockDeploymentBaseFeedback); + expect(result).toBeNull(); + expect(service.postDeploymentFeedback).toHaveBeenCalledWith(mockWorkspaceId, mockDeviceId, mockDeploymentId, mockDeploymentBaseFeedback); }); }); diff --git a/apps/server/src/ddi/ddi.controller.ts b/apps/server/src/ddi/ddi.controller.ts index aa5c94f..d08b799 100644 --- a/apps/server/src/ddi/ddi.controller.ts +++ b/apps/server/src/ddi/ddi.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, NotImplementedException, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, NotImplementedException, Param, Post, Put } from '@nestjs/common'; import { WorkspaceDeviceDeploymentParams, WorkspaceDeviceImageVersionFilenameParams, WorkspaceDeviceImageVersionParams, WorkspaceDeviceParams } from './dtos/path-params.dto'; import { DdiService } from './ddi.service'; +import { DeploymentBaseFeedbackDto } from './dtos/deployment-feedback-req.dto'; @Controller('ddi/:workspaceId/controller/v1/:deviceId') export class DdiController { @@ -29,9 +30,10 @@ export class DdiController { return this.ddiService.getDeploymentBase(params.workspaceId, params.deviceId, params.deploymentId); } + @HttpCode(200) @Post('/deploymentBase/:deploymentId/feedback') - postDeploymentFeedback(@Param() params: WorkspaceDeviceDeploymentParams) { - return this.ddiService.postDeploymentFeedback(params.workspaceId, params.deviceId, params.deploymentId); + postDeploymentFeedback(@Param() params: WorkspaceDeviceDeploymentParams, @Body() deploymentBaseFeedback: DeploymentBaseFeedbackDto) { + return this.ddiService.postDeploymentFeedback(params.workspaceId, params.deviceId, params.deploymentId, deploymentBaseFeedback); } @Get('/softwaremodules/:imageVersionId/artifacts') diff --git a/apps/server/src/ddi/ddi.service.spec.ts b/apps/server/src/ddi/ddi.service.spec.ts index 85ca7df..ec43de8 100644 --- a/apps/server/src/ddi/ddi.service.spec.ts +++ b/apps/server/src/ddi/ddi.service.spec.ts @@ -328,10 +328,7 @@ describe('DdiService', () => { }); describe('postDeploymentFeedback', () => { - it('should return hello world object', async () => { - const result = await service.postDeploymentFeedback(mockWorkspaceId, mockDeviceId, mockDeploymentId); - expect(result).toEqual({ hello: 'world' }); - }); + }); describe('getArtifacts', () => { diff --git a/apps/server/src/ddi/ddi.service.ts b/apps/server/src/ddi/ddi.service.ts index fbbf155..218fba6 100644 --- a/apps/server/src/ddi/ddi.service.ts +++ b/apps/server/src/ddi/ddi.service.ts @@ -1,10 +1,12 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { GoneException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Deployment, DeploymentState } from '../deployment/entities/deployment.entity'; import { In, Not, Repository } from 'typeorm'; import { ConfigDto, LinkDto, LinksDto, PollingConfigDto, RootDto } from './dtos/root-res.dto'; import { Device } from '../device/entities/device.entity'; import { ConfigService } from '@nestjs/config'; +import { ExecutionEnum, FinishedEnum } from './dtos/deployment-feedback-req.dto'; +import { DeploymentBaseFeedbackDto } from './dtos/deployment-feedback-req.dto'; @Injectable() export class DdiService { @@ -92,10 +94,22 @@ export class DdiService { workspaceId: string, deviceId: string, deploymentId: string, + deploymentBaseFeedback: DeploymentBaseFeedbackDto, ) { - return { - hello: 'world', + const device = await this.getDeviceOrThrow(deviceId); + const deployment = await this.getDeploymentOrThrow(deploymentId); + + if(deployment.isInTerminalState()) { + throw new GoneException('Deployment is in a terminal state'); } + + const { state, messages } = this.handleDeploymentFeedback(deploymentBaseFeedback); + + await this.deploymentRepository.update(deployment.uuid, { + state, + }); + + return; } async getArtifacts( @@ -144,6 +158,18 @@ export class DdiService { return device; } + async getDeploymentOrThrow(deploymentId: string) { + const deployment = await this.deploymentRepository.findOne({ + where: { + uuid: deploymentId, + }, + }); + if (!deployment) { + throw new NotFoundException('Deployment not found'); + } + return deployment; + } + async findInFlightDeployment(deviceId: string): Promise { return this.deploymentRepository.findOne({ where: { @@ -234,5 +260,59 @@ export class DdiService { const baseUrl = this.buildBaseUrl(deviceId); return `${baseUrl}/deploymentBase/${deploymentId}`; } + + + handleDeploymentFeedback(deploymentBaseFeedback: DeploymentBaseFeedbackDto): { state: DeploymentState; messages: string[]} { + let state: DeploymentState; + const messages: string[] = []; + + const feedbackDetailMessages = deploymentBaseFeedback.status.details; + if (feedbackDetailMessages && feedbackDetailMessages.length > 0) { + messages.concat(feedbackDetailMessages); + } + + switch (deploymentBaseFeedback.status.execution) { + case ExecutionEnum.CANCELED: + state = DeploymentState.CANCELED; + messages.push("Server update: Device confirmed cancelation."); + break; + + case ExecutionEnum.REJECTED: + state = DeploymentState.WARNING; + messages.push("Server update: Device rejected update."); + break; + + case ExecutionEnum.CLOSED: + const result = deploymentBaseFeedback.status.result.finished; + if (result === FinishedEnum.FAILURE) { + state = DeploymentState.ERROR; + messages.push("Server update: Device reported result with error."); + } else { + state = DeploymentState.FINISHED; + messages.push("Server update: Device reported result with success."); + } + break; + + case ExecutionEnum.DOWNLOAD: + state = DeploymentState.DOWNLOAD; + messages.push("Server update: Device confirmed download start."); + break; + + case ExecutionEnum.DOWNLOADED: + state = DeploymentState.DOWNLOADED; + messages.push("Server update: Device confirmed download finished."); + break; + + default: + state = DeploymentState.RUNNING; + messages.push(`Server update: Device reported intermediate feedback ${deploymentBaseFeedback.status.execution}`); + break; + } + + return { + state, + messages + }; + } } diff --git a/apps/server/src/ddi/dtos/deployment-feedback-req.dto.ts b/apps/server/src/ddi/dtos/deployment-feedback-req.dto.ts index fcb3755..2a076a3 100644 --- a/apps/server/src/ddi/dtos/deployment-feedback-req.dto.ts +++ b/apps/server/src/ddi/dtos/deployment-feedback-req.dto.ts @@ -4,7 +4,7 @@ import { Type } from 'class-transformer'; export enum FinishedEnum { SUCCESS = 'success', FAILURE = 'failure', - NONE = 'none', + // NONE = 'none', } export enum ExecutionEnum { diff --git a/apps/server/src/deployment/entities/deployment.entity.spec.ts b/apps/server/src/deployment/entities/deployment.entity.spec.ts index 87eefb6..838c0ca 100644 --- a/apps/server/src/deployment/entities/deployment.entity.spec.ts +++ b/apps/server/src/deployment/entities/deployment.entity.spec.ts @@ -17,6 +17,32 @@ describe('Deployment', () => { deployment.updatedAt = new Date(); }); + describe('isInTerminalState', () => { + it.each([ + DeploymentState.FINISHED, + DeploymentState.ERROR, + DeploymentState.DOWNLOADED, + ])('should return true when state is %s', (state) => { + deployment.state = state; + expect(deployment.isInTerminalState()).toBe(true); + }); + + it.each([ + DeploymentState.RUNNING, + DeploymentState.SCHEDULED, + DeploymentState.CANCELING, + DeploymentState.CANCELED, + DeploymentState.WARNING, + DeploymentState.RETRIEVED, + DeploymentState.DOWNLOAD, + DeploymentState.CANCEL_REJECTED, + DeploymentState.WAIT_FOR_CONFIRMATION, + ])('should return false when state is %s', (state) => { + deployment.state = state; + expect(deployment.isInTerminalState()).toBe(false); + }); + }); + describe('getDownloadType', () => { it('should return ATTEMPT', () => { expect(deployment.getDownloadType()).toBe(DownloadUpdateEnum.ATTEMPT); diff --git a/apps/server/src/deployment/entities/deployment.entity.ts b/apps/server/src/deployment/entities/deployment.entity.ts index 53ac5c8..ee07dc8 100644 --- a/apps/server/src/deployment/entities/deployment.entity.ts +++ b/apps/server/src/deployment/entities/deployment.entity.ts @@ -51,6 +51,14 @@ export class Deployment { @ManyToOne(() => ImageVersion, (imageVersion) => imageVersion.deployments) imageVersion: ImageVersion; + + isInTerminalState(): boolean { + return [ + DeploymentState.FINISHED, + DeploymentState.ERROR, + DeploymentState.DOWNLOADED, + ].includes(this.state); + } getDownloadType(): DownloadUpdateEnum { return DownloadUpdateEnum.ATTEMPT; diff --git a/apps/server/test/factories/index.ts b/apps/server/test/factories/index.ts index db28c15..ad13d79 100644 --- a/apps/server/test/factories/index.ts +++ b/apps/server/test/factories/index.ts @@ -44,6 +44,7 @@ export const createMockDeployment = (overrides?: Partial): Deploymen updatedAt: new Date('2024-01-01'), device: createMockDevice(), imageVersion: createMockImageVersion(), + isInTerminalState: jest.fn(), getDownloadType: jest.fn(), getUpdateType: jest.fn(), getMaintenanceWindow: jest.fn(),