diff --git a/Control/lib/api.js b/Control/lib/api.js index 667936635..ccb2bc2c7 100644 --- a/Control/lib/api.js +++ b/Control/lib/api.js @@ -21,10 +21,10 @@ const config = require('./config/configProvider.js'); const { DetectorId } = require('./common/detectorId.enum.js'); // middleware -const {minimumRoleMiddleware} = require('./middleware/minimumRole.middleware.js'); const {addDetectorIdMiddleware} = require('./middleware/addDetectorId.middleware.js'); +const {logDeploymentRequestMiddleware} = require('./middleware/logDeploymentRequest.middleware.js'); +const {minimumRoleMiddleware} = require('./middleware/minimumRole.middleware.js'); const {requireDetectorOrGlobalRoleMiddleware} = require('./middleware/requireDetectorOrGlobalRole.middleware.js'); - const { setDetectorsFromEnvironmentMiddlewareFactory } = require('./middleware/setDetectorsFromEnvironmentMiddlewareFactory.js'); @@ -34,6 +34,7 @@ const { // controllers const {ConsulController} = require('./controllers/Consul.controller.js'); +const {DeploymentController} = require('./controllers/Deployment.controller.js'); const {EnvironmentController} = require('./controllers/Environment.controller.js'); const {LockController} = require('./controllers/Lock.controller.js'); const {RunController} = require('./controllers/Run.controller.js'); @@ -45,8 +46,9 @@ const {WorkflowTemplateController} = require('./controllers/WorkflowTemplate.con const {BookkeepingService} = require('./services/Bookkeeping.service.js'); const {BroadcastService} = require('./services/Broadcast.service.js'); const {CacheService} = require('./services/Cache.service.js'); -const {EnvironmentCacheService} = require('./services/environment/EnvironmentCache.service.js'); +const {DeploymentService} = require('./services/Deployment.service.js'); const {DetectorService} = require('./services/Detector.service.js'); +const {EnvironmentCacheService} = require('./services/environment/EnvironmentCache.service.js'); const {EnvironmentService} = require('./services/Environment.service.js'); const {Intervals} = require('./services/Intervals.service.js'); const {LockService} = require('./services/Lock.service.js'); @@ -82,6 +84,11 @@ if (!config.grafana) { module.exports.setup = (http, ws) => { const eventEmitter = new EventEmitter(); + + /** + * Services are initialized with the configuration they need and in order of their dependencies. + * The services are then used by the controllers to perform actions. + */ let consulService; if (config.consul) { consulService = new ConsulService(config.consul); @@ -108,9 +115,14 @@ module.exports.setup = (http, ws) => { ctrlProxy, apricotService, cacheService, broadcastService, environmentCacheService ); const workflowService = new WorkflowTemplateService(ctrlProxy, apricotService); + const deploymentService = new DeploymentService(environmentService, workflowService); + /** + * Controllers are initialized with the services they depend on. + */ const envCtrl = new EnvironmentController(environmentService, workflowService, lockService, detectorService); const workflowController = new WorkflowTemplateController(workflowService); + const deploymentController = new DeploymentController(deploymentService); const aliecsReqHandler = new AliecsRequestHandler(ctrlService, apricotService); aliecsReqHandler.setWs(ws); @@ -188,6 +200,14 @@ module.exports.setup = (http, ws) => { envCtrl.destroyEnvironmentHandler.bind(envCtrl), ); + http.post('/deploy', + coreMiddleware, + logDeploymentRequestMiddleware, + minimumRoleMiddleware(Role.DETECTOR), + verifyLockOwnershipMiddleware, + deploymentController.newAsyncDeploymentHandler.bind(deploymentController) + ); + http.post('/core/environments/configuration/save', (req, res) => apricotService.saveCoreEnvConfig(req, res)); http.post('/core/environments/configuration/update', (req, res) => apricotService.updateCoreEnvConfig(req, res)); diff --git a/Control/lib/controllers/Deployment.controller.js b/Control/lib/controllers/Deployment.controller.js new file mode 100644 index 000000000..6c167e6b5 --- /dev/null +++ b/Control/lib/controllers/Deployment.controller.js @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. +*/ +const { + LogManager, + LogLevel, + updateAndSendExpressResponseFromNativeError, + InvalidInputError +} = require('@aliceo2/web-ui'); + +const {User} = require('./../dtos/User.js'); + +/** + * Controller Class for managing deployments via the AliECS system + */ +class DeploymentController { + + /** + * Constructor for initializing controller with a deployment service + * @param {DeploymentService} deploymentService - service to use to request AliECS a new deployment + */ + constructor(deploymentService) { + this._logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'cog'}/deployment-ctrl`); + + /** + * @type {DeploymentService} + */ + this._deploymentService = deploymentService; + } + + /** + * Handles the request to make a deployment by: + * - validating the user built request + * - preparing the request payload for ECS + * - calling the ECS service to deploy the environment + * + * User must be authenticated and authorized to perform this action and this is verified via middlewares + * + * The result of a deployment is an environment + * + * @param {Express.Request} req - the request object + * @param {Express.Response} res - the response object + * @returns {Promise} + */ + async newAsyncDeploymentHandler(req, res) { + /** + * @type {DeploymentRequest} + */ + const { workflowTemplate, selectedConfiguration, userVars } = req.body; + + if (!workflowTemplate && !selectedConfiguration) { + updateAndSendExpressResponseFromNativeError( + res, + new InvalidInputError('Invalid input: workflowTemplate or selectedConfiguration must be provided') + ) + return; + } + + const { personid, name, username } = req.session || {}; + const user = new User(username, name, personid); + + try { + const environment = await this._deploymentService.deployEnvironment({ + userVars, + selectedConfiguration, + workflowTemplate, + user, + }); + res.status(201).json(environment); + } catch (error) { + this._logger.errorMessage(error, { level: LogLevel.SUPPORT }); + updateAndSendExpressResponseFromNativeError(res, error); + } + } +} + +module.exports = { DeploymentController }; diff --git a/Control/lib/middleware/logDeploymentRequest.middleware.js b/Control/lib/middleware/logDeploymentRequest.middleware.js new file mode 100644 index 000000000..989a3c14e --- /dev/null +++ b/Control/lib/middleware/logDeploymentRequest.middleware.js @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2019-2024 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. +*/ + +const {LogManager, LogLevel} = require('@aliceo2/web-ui'); + +/** + * Middleware to log deployment requests with user and request context. + * @param {Express.Request} req - Request object + * @param {Express.Response} _ - Response object + * @param {Express.Next} next - Next middleware function + * @return {void} + */ +const logDeploymentRequestMiddleware = (req, _, next) => { + /** + * @type {DeploymentRequest} + */ + const { selectedConfiguration, workflowTemplate, detectors = [] } = req.body; + const { username } = req.session; + + const logMessage = `Deployment request from user: ${ username }` + + (workflowTemplate ? ` for workflow: ${workflowTemplate}` : '') + + (selectedConfiguration ? ` with configuration: ${selectedConfiguration}` : '') + + ` with detectors: ${detectors.length > 0 ? detectors.join(', ') : 'none'}`; + + LogManager + .getLogger(`${process.env.npm_config_log_label ?? 'cog'}/deployment-request`) + .infoMessage(logMessage, { + level: LogLevel.OPERATIONS, + }); + next(); +} + +module.exports = { logDeploymentRequestMiddleware }; diff --git a/Control/lib/typedefs/DeploymentRequest.js b/Control/lib/typedefs/DeploymentRequest.js new file mode 100644 index 000000000..440f06176 --- /dev/null +++ b/Control/lib/typedefs/DeploymentRequest.js @@ -0,0 +1,23 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * @typedef DeploymentRequest + * + * Deployment request as needed to be sent by the user to the API + * + * @property {string} workflowTemplate - the workflow template to use for the deployment, can be optional if selectedConfiguration is provided + * @property {string} [selectedConfiguration] - the selected configuration for the deployment, can be optional if workflowTemplate is provided + * @property {Map} [userVars] - user variables to be used in the deployment, if none provided, ECS will use the default ones + * @property {string[]} detectors - list of detectors to be deployed + */ diff --git a/Control/test/api/deployment/api-post-deployment.test.js b/Control/test/api/deployment/api-post-deployment.test.js new file mode 100644 index 000000000..884e92ef8 --- /dev/null +++ b/Control/test/api/deployment/api-post-deployment.test.js @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2019-2025 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. +*/ + +const request = require('supertest'); +const { DET_MID_TEST_TOKEN, GUEST_TEST_TOKEN, TEST_URL } = require('../generateToken.js'); +const { DetectorLockAction } = require('../../../lib/common/lock/detectorLockAction.enum.js'); + +describe('POST /deploy', function () { + it('should reject unauthenticated requests from WebUI server', async function () { + await request(`${TEST_URL}/api`) + .post('/deploy') + .send({ + workflowTemplate: 'test-template', + selectedConfiguration: 'test-config', + userVars: { foo: 'bar' } + }) + .expect(403, { + message: 'You must provide a JWT token', + error: '403 - Json Web Token Error' + }); + }); + + it('should reject deployment request due to user not being detector as minimum role', async function () { + await request(`${TEST_URL}/api`) + .post(`/deploy?token=${GUEST_TEST_TOKEN}`) + .send({ + workflowTemplate: 'test-template', + selectedConfiguration: 'test-config', + userVars: { foo: 'bar' } + }) + .expect(403, { + message: 'Not enough permissions for this operation', + status: 403, + title: 'Unauthorized Access' + }); + }); + + it('should reject deployment request due to missing lock ownership', async function () { + await request(`${TEST_URL}/api`) + .post(`/deploy?token=${DET_MID_TEST_TOKEN}`) + .send({ + workflowTemplate: 'test-template', + selectedConfiguration: 'test-config', + detectors: ['MID'], + userVars: { foo: 'bar' } + }) + .expect(403, { + message: 'Action not allowed for user Detector User due to missing ownership of lock(s)', + }); + }); + + it('should reject requests with missing workflowTemplate and selectedConfiguration', async function () { + // First we need to acquire the lock for MID detector + await request(`${TEST_URL}/api/locks`) + .put(`/${DetectorLockAction.TAKE}/MID?token=${DET_MID_TEST_TOKEN}`) + .expect(200, { + MID: { name: 'MID', state: 'TAKEN', owner: { username: 'det-mid', fullName: 'Detector User', personid: 2 } }, + DCS: { name: 'DCS', state: 'FREE' }, + ODC: { name: 'ODC', state: 'FREE' } + }); + + await request(`${TEST_URL}/api`) + .post(`/deploy?token=${DET_MID_TEST_TOKEN}`) + .send({ + detectors: ['MID'], + userVars: { foo: 'bar' } + }) + .expect(400, { + message: 'Invalid input: workflowTemplate or selectedConfiguration must be provided', + status: 400, + title: 'Invalid Input' + }); + + // Release the lock after the test so that tests are not chained + await request(`${TEST_URL}/api/locks`) + .put(`/${DetectorLockAction.RELEASE}/MID?token=${DET_MID_TEST_TOKEN}`) + .expect(200, { + MID: { name: 'MID', state: 'FREE' }, + DCS: { name: 'DCS', state: 'FREE' }, + ODC: { name: 'ODC', state: 'FREE' } + }); + }); +}); diff --git a/Control/test/lib/controllers/mocha-deployment.controller.js b/Control/test/lib/controllers/mocha-deployment.controller.js new file mode 100644 index 000000000..b9c259069 --- /dev/null +++ b/Control/test/lib/controllers/mocha-deployment.controller.js @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. +*/ + +const assert = require('assert'); +const sinon = require('sinon'); +const { User } = require('./../../../lib/dtos/User.js'); +const { DeploymentController } = require('./../../../lib/controllers/Deployment.controller.js'); + +describe('DeploymentController test suite', function() { + let deploymentController, req, res; + let mockDeploymentService; + + beforeEach(function () { + mockDeploymentService = { deployEnvironment: sinon.stub() }; + deploymentController = new DeploymentController(mockDeploymentService); + req = { + body: {}, + session: { username: 'testuser', name: 'Test User', personid: '123' } + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub() + }; + }); + + it('should return 400 if both workflowTemplate and selectedConfiguration are missing', async function() { + req.body = { workflowTemplate: null, selectedConfiguration: null }; + await deploymentController.newAsyncDeploymentHandler(req, res); + assert.ok(res.status.calledWith(400)); + assert.ok(res.json.calledWith({ + message: 'Invalid input: workflowTemplate or selectedConfiguration must be provided', + status: 400, + title: 'Invalid Input' + })); + }); + + it('should call deployEnvironment with correct parameters', async function() { + req.body = { + workflowTemplate: 'readout-dataflow', + userVars: { var1: 'value1' } + }; + mockDeploymentService.deployEnvironment.returns({ id: 'env123' }); + + await deploymentController.newAsyncDeploymentHandler(req, res); + + assert.ok(mockDeploymentService.deployEnvironment.calledOnce); + assert.deepStrictEqual(mockDeploymentService.deployEnvironment.firstCall.args[0], { + workflowTemplate: 'readout-dataflow', + selectedConfiguration: undefined, + userVars: { var1: 'value1' }, + user: new User(req.session.username, req.session.name, req.session.personid) + }); + + assert.ok(res.status.calledWith(201)); + assert.ok(res.json.calledWith({ id: 'env123' })); + }); + + it('should handle errors from deployEnvironment', async function () { + const error = new Error('Deployment failed'); + mockDeploymentService.deployEnvironment.throws(error); + req.body = { + workflowTemplate: 'readout-dataflow', + userVars: { var1: 'value1' } + }; + await deploymentController.newAsyncDeploymentHandler(req, res); + + assert.ok(res.status.calledWith(500)); + assert.ok(res.json.calledWith({ + message: 'Deployment failed', + status: 500, + title: 'Unknown Error' + })); + }); +}); diff --git a/Control/test/mocha-index.js b/Control/test/mocha-index.js index 239827c4f..33f16f813 100644 --- a/Control/test/mocha-index.js +++ b/Control/test/mocha-index.js @@ -170,8 +170,10 @@ describe('Control', function() { require('./public/page-hardware-mocha'); require('./public/page-lock-mocha'); + // API tests require('./api/lock/api-get-locks.test'); require('./api/lock/api-put-locks.test'); + require('./api/deployment/api-post-deployment.test'); beforeEach(() => this.ok = true);