diff --git a/api/.env.example b/api/.env.example index d59f43e4..1804918f 100644 --- a/api/.env.example +++ b/api/.env.example @@ -25,7 +25,7 @@ LDAP_USERS_BASE_DN = LDAP_GROUPS_BASE_DN = #default value is 100 -MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100 +MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100 #default value is 10 MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10 diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index dc73202a..922b2f42 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -98,17 +98,47 @@ components: properties: code: type: string - description: 'Code of program' - example: '* Code HERE;' + description: 'The code to be executed' + example: '* Your Code HERE;' runTime: $ref: '#/components/schemas/RunTimeType' - description: 'runtime for program' + description: 'The runtime for the code - eg SAS, JS, PY or R' example: js required: - code - runTime type: object additionalProperties: false + TriggerCodeResponse: + properties: + sessionId: + type: string + description: "The SessionId is the name of the temporary folder used to store the outputs.\nFor SAS, this would be the SASWORK folder. Can be used to poll job status.\nThis session ID should be used to poll job status." + example: '{ sessionId: ''20241028074744-54132-1730101664824'' }' + required: + - sessionId + type: object + additionalProperties: false + TriggerCodePayload: + properties: + code: + type: string + description: 'The code to be executed' + example: '* Your Code HERE;' + runTime: + $ref: '#/components/schemas/RunTimeType' + description: 'The runtime for the code - eg SAS, JS, PY or R' + example: sas + expiresAfterMins: + type: number + format: double + description: "Amount of minutes after the completion of the job when the session must be\ndestroyed." + example: 15 + required: + - code + - runTime + type: object + additionalProperties: false MemberType.folder: enum: - folder @@ -805,6 +835,30 @@ paths: application/json: schema: $ref: '#/components/schemas/ExecuteCodePayload' + /SASjsApi/code/trigger: + post: + operationId: TriggerCode + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/TriggerCodeResponse' + description: 'Trigger Code on the Specified Runtime' + summary: 'Triggers code and returns SessionId immediately - does not wait for job completion' + tags: + - Code + security: + - + bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TriggerCodePayload' /SASjsApi/drive/deploy: post: operationId: Deploy @@ -1789,7 +1843,7 @@ paths: anyOf: - {type: string} - {type: string, format: byte} - description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms" + description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts additional URL parameters (converted to session variables)\nand file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms" summary: 'Execute a Stored Program, returns _webout and (optionally) log.' tags: - STP @@ -1798,7 +1852,7 @@ paths: bearerAuth: [] parameters: - - description: 'Location of the Stored Program in SASjs Drive' + description: 'Location of Stored Program in SASjs Drive.' in: query name: _program required: true @@ -1806,7 +1860,7 @@ paths: type: string example: /Projects/myApp/some/program - - description: 'Optional query param for setting debug mode (returns the session log in the response body)' + description: 'Optional query param for setting debug mode (returns the session log in the response body).' in: query name: _debug required: false diff --git a/api/src/controllers/code.ts b/api/src/controllers/code.ts index 72fa376d..4620ba56 100644 --- a/api/src/controllers/code.ts +++ b/api/src/controllers/code.ts @@ -1,27 +1,55 @@ import express from 'express' import { Request, Security, Route, Tags, Post, Body } from 'tsoa' -import { ExecutionController } from './internal' +import { ExecutionController, getSessionController } from './internal' import { getPreProgramVariables, getUserAutoExec, ModeType, - parseLogToArray, RunTimeType } from '../utils' interface ExecuteCodePayload { /** - * Code of program - * @example "* Code HERE;" + * The code to be executed + * @example "* Your Code HERE;" */ code: string /** - * runtime for program + * The runtime for the code - eg SAS, JS, PY or R * @example "js" */ runTime: RunTimeType } +interface TriggerCodePayload { + /** + * The code to be executed + * @example "* Your Code HERE;" + */ + code: string + /** + * The runtime for the code - eg SAS, JS, PY or R + * @example "sas" + */ + runTime: RunTimeType + /** + * Amount of minutes after the completion of the job when the session must be + * destroyed. + * @example 15 + */ + expiresAfterMins?: number +} + +interface TriggerCodeResponse { + /** + * The SessionId is the name of the temporary folder used to store the outputs. + * For SAS, this would be the SASWORK folder. Can be used to poll job status. + * This session ID should be used to poll job status. + * @example "{ sessionId: '20241028074744-54132-1730101664824' }" + */ + sessionId: string +} + @Security('bearerAuth') @Route('SASjsApi/code') @Tags('Code') @@ -44,6 +72,18 @@ export class CodeController { ): Promise { return executeCode(request, body) } + + /** + * Trigger Code on the Specified Runtime + * @summary Triggers code and returns SessionId immediately - does not wait for job completion + */ + @Post('/trigger') + public async triggerCode( + @Request() request: express.Request, + @Body() body: TriggerCodePayload + ): Promise { + return triggerCode(request, body) + } } const executeCode = async ( @@ -76,3 +116,49 @@ const executeCode = async ( } } } + +const triggerCode = async ( + req: express.Request, + { code, runTime, expiresAfterMins }: TriggerCodePayload +): Promise<{ sessionId: string }> => { + const { user } = req + const userAutoExec = + process.env.MODE === ModeType.Server + ? user?.autoExec + : await getUserAutoExec() + + // get session controller based on runTime + const sessionController = getSessionController(runTime) + + // get session + const session = await sessionController.getSession() + + // add expiresAfterMins to session if provided + if (expiresAfterMins) { + // expiresAfterMins.used is set initially to false + session.expiresAfterMins = { mins: expiresAfterMins, used: false } + } + + try { + // call executeProgram method of ExecutionController without awaiting + new ExecutionController().executeProgram({ + program: code, + preProgramVariables: getPreProgramVariables(req), + vars: { ...req.query, _debug: 131 }, + otherArgs: { userAutoExec }, + runTime: runTime, + includePrintOutput: true, + session // session is provided + }) + + // return session id + return { sessionId: session.id } + } catch (err: any) { + throw { + code: 400, + status: 'failure', + message: 'Job execution failed.', + error: typeof err === 'object' ? err.toString() : err + } + } +} diff --git a/api/src/controllers/internal/Session.ts b/api/src/controllers/internal/Session.ts index 31ee7f3f..3000c076 100644 --- a/api/src/controllers/internal/Session.ts +++ b/api/src/controllers/internal/Session.ts @@ -14,8 +14,7 @@ import { createFile, fileExists, generateTimestamp, - readFile, - isWindows + readFile } from '@sasjs/utils' const execFilePromise = promisify(execFile) @@ -194,12 +193,29 @@ ${autoExecContent}` async () => { if (session.inUse) { // adding 10 more minutes - const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000 + const newDeathTimeStamp = + parseInt(session.deathTimeStamp) + 10 * 60 * 1000 session.deathTimeStamp = newDeathTimeStamp.toString() this.scheduleSessionDestroy(session) } else { - await this.deleteSession(session) + const { expiresAfterMins } = session + + // delay session destroy if expiresAfterMins present + if (expiresAfterMins && !expiresAfterMins.used) { + // calculate session death time using expiresAfterMins + const newDeathTimeStamp = + parseInt(session.deathTimeStamp) + + expiresAfterMins.mins * 60 * 1000 + session.deathTimeStamp = newDeathTimeStamp.toString() + + // set expiresAfterMins to true to avoid using it again + session.expiresAfterMins!.used = true + + this.scheduleSessionDestroy(session) + } else { + await this.deleteSession(session) + } } }, parseInt(session.deathTimeStamp) - new Date().getTime() - 100 diff --git a/api/src/controllers/stp.ts b/api/src/controllers/stp.ts index bd6719e3..dbc881da 100644 --- a/api/src/controllers/stp.ts +++ b/api/src/controllers/stp.ts @@ -30,8 +30,8 @@ export class STPController { * https://server.sasjs.io/storedprograms * * @summary Execute a Stored Program, returns _webout and (optionally) log. - * @param _program Location of code in SASjs Drive - * @param _debug Optional query param for setting debug mode, which will return the session log. + * @param _program Location of Stored Program in SASjs Drive. + * @param _debug Optional query param for setting debug mode (returns the session log in the response body). * @example _program "/Projects/myApp/some/program" * @example _debug 131 */ diff --git a/api/src/controllers/user.ts b/api/src/controllers/user.ts index 424df900..d8790d9f 100644 --- a/api/src/controllers/user.ts +++ b/api/src/controllers/user.ts @@ -285,7 +285,7 @@ const getUser = async ( username: user.username, isActive: user.isActive, isAdmin: user.isAdmin, - autoExec: getAutoExec ? user.autoExec ?? '' : undefined, + autoExec: getAutoExec ? (user.autoExec ?? '') : undefined, groups: user.groups } } diff --git a/api/src/routes/api/code.ts b/api/src/routes/api/code.ts index 09171c06..c8239500 100644 --- a/api/src/routes/api/code.ts +++ b/api/src/routes/api/code.ts @@ -1,5 +1,5 @@ import express from 'express' -import { runCodeValidation } from '../../utils' +import { runCodeValidation, triggerCodeValidation } from '../../utils' import { CodeController } from '../../controllers/' const runRouter = express.Router() @@ -28,4 +28,22 @@ runRouter.post('/execute', async (req, res) => { } }) +runRouter.post('/trigger', async (req, res) => { + const { error, value: body } = triggerCodeValidation(req.body) + if (error) return res.status(400).send(error.details[0].message) + + try { + const response = await controller.triggerCode(req, body) + + res.status(200) + res.send(response) + } catch (err: any) { + const statusCode = err.code + + delete err.code + + res.status(statusCode).send(err) + } +}) + export default runRouter diff --git a/api/src/types/Session.ts b/api/src/types/Session.ts index 0507a6d9..4fa1cfba 100644 --- a/api/src/types/Session.ts +++ b/api/src/types/Session.ts @@ -8,4 +8,5 @@ export interface Session { consumed: boolean completed: boolean crashed?: string + expiresAfterMins?: { mins: number; used: boolean } } diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index 66070fbf..a1e33107 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -178,6 +178,13 @@ export const runCodeValidation = (data: any): Joi.ValidationResult => runTime: Joi.string().valid(...process.runTimes) }).validate(data) +export const triggerCodeValidation = (data: any): Joi.ValidationResult => + Joi.object({ + code: Joi.string().required(), + runTime: Joi.string().valid(...process.runTimes), + expiresAfterMins: Joi.number().greater(0) + }).validate(data) + export const executeProgramRawValidation = (data: any): Joi.ValidationResult => Joi.object({ _program: Joi.string().required(),