From add9c30e38301595e8ff49177d09e564f78267ec Mon Sep 17 00:00:00 2001 From: Elvina Valieva Date: Sat, 15 Jan 2022 13:19:37 +0100 Subject: [PATCH] :sparkles: Add Jenkins Node (#2345) * feat: initial jenkins setup * feat: trigger job functionality * feat: copy a job * feat: basic Jenkins instance operations * feat: create job from xml * feat: trigger with params * feat: basic build list * feat: list build with params * feat: basic credentials test * chore: linting fixes * feat: use baseUrl from credentials * chore: naming fixes * feat: filters collection for getall buils * fix: better ui and credentials * chore: alphabetize params and fix typos * :zap: Small changes * :zap: Improvements * :zap: Improvements * :zap: Improvements * :zap: Improvements * :zap: Improvements * :zap: Improvements * :zap: Some improvements Co-authored-by: ricardo Co-authored-by: Jan Oberhauser --- .../credentials/JenkinsApi.credentials.ts | 30 + .../nodes/Jenkins/GenericFunctions.ts | 45 ++ .../nodes/Jenkins/Jenkins.node.json | 20 + .../nodes-base/nodes/Jenkins/Jenkins.node.ts | 682 ++++++++++++++++++ packages/nodes-base/nodes/Jenkins/jenkins.svg | 72 ++ packages/nodes-base/package.json | 2 + 6 files changed, 851 insertions(+) create mode 100644 packages/nodes-base/credentials/JenkinsApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Jenkins/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Jenkins/Jenkins.node.json create mode 100644 packages/nodes-base/nodes/Jenkins/Jenkins.node.ts create mode 100644 packages/nodes-base/nodes/Jenkins/jenkins.svg diff --git a/packages/nodes-base/credentials/JenkinsApi.credentials.ts b/packages/nodes-base/credentials/JenkinsApi.credentials.ts new file mode 100644 index 0000000000000..cdaea3961afc3 --- /dev/null +++ b/packages/nodes-base/credentials/JenkinsApi.credentials.ts @@ -0,0 +1,30 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class JenkinsApi implements ICredentialType { + name = 'jenkinsApi'; + displayName = 'Jenkins API'; + documentationUrl = 'jenkins'; + properties: INodeProperties[] = [ + { + displayName: 'Jenking Username', + name: 'username', + type: 'string', + default: '', + }, + { + displayName: 'Personal API Token', + name: 'apiKey', + type: 'string', + default: '', + }, + { + displayName: 'Jenkins Instance URL', + name: 'baseUrl', + type: 'string', + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts b/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts new file mode 100644 index 0000000000000..85ad9b8eb93d1 --- /dev/null +++ b/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts @@ -0,0 +1,45 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + NodeApiError, +} from 'n8n-workflow'; + +export async function jenkinsApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, uri: string, qs: IDataObject = {}, body: any = '', option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = await this.getCredentials('jenkinsApi') as IDataObject; + let options: OptionsWithUri = { + headers: { + 'Accept': 'application/json', + }, + method, + auth: { + username: credentials.username as string, + password: credentials.apiKey as string, + }, + uri: `${tolerateTrailingSlash(credentials.baseUrl as string)}${uri}`, + json: true, + qs, + body, + }; + options = Object.assign({}, options, option); + try { + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export function tolerateTrailingSlash(baseUrl: string) { + return baseUrl.endsWith('/') + ? baseUrl.substr(0, baseUrl.length - 1) + : baseUrl; +} diff --git a/packages/nodes-base/nodes/Jenkins/Jenkins.node.json b/packages/nodes-base/nodes/Jenkins/Jenkins.node.json new file mode 100644 index 0000000000000..8848a7cf2b5c5 --- /dev/null +++ b/packages/nodes-base/nodes/Jenkins/Jenkins.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.jenkins", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Development" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/jenkins" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.jenkins/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Jenkins/Jenkins.node.ts b/packages/nodes-base/nodes/Jenkins/Jenkins.node.ts new file mode 100644 index 0000000000000..b131070229fc8 --- /dev/null +++ b/packages/nodes-base/nodes/Jenkins/Jenkins.node.ts @@ -0,0 +1,682 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + ICredentialsDecrypted, + ICredentialTestFunctions, + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + NodeApiError, + NodeCredentialTestResult, +} from 'n8n-workflow'; + +import { + jenkinsApiRequest, + tolerateTrailingSlash +} from './GenericFunctions'; + +export type JenkinsApiCredentials = { + username: string; + apiKey: string; + baseUrl: string; +}; + +export class Jenkins implements INodeType { + description: INodeTypeDescription = { + displayName: 'Jenkins', + name: 'jenkins', + icon: 'file:jenkins.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Jenkins API', + defaults: { + name: 'Jenkins', + color: '#04AA51', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'jenkinsApi', + required: true, + testedBy: 'jenkinApiCredentialTest', + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Build', + value: 'build', + }, + { + name: 'Instance', + value: 'instance', + }, + { + name: 'Job', + value: 'job', + }, + ], + default: 'job', + noDataExpression: true, + }, + + // -------------------------------------------------------------------------------------------------------- + // Job Operations + // -------------------------------------------------------------------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'job', + ], + }, + }, + options: [ + { + name: 'Copy', + value: 'copy', + description: 'Copy a specific job', + }, + { + name: 'Create', + value: 'create', + description: 'Create a new job', + }, + { + name: 'Trigger', + value: 'trigger', + description: 'Trigger a specific job', + }, + { + name: 'Trigger with Parameters', + value: 'triggerParams', + description: 'Trigger a specific job', + }, + ], + default: 'trigger', + description: 'Possible operations', + noDataExpression: true, + }, + { + displayName: 'Make sure the job is setup to support triggering with parameters. More info', + name: 'triggerParamsNotice', + type: 'notice', + displayOptions: { + show: { + resource: [ + 'job', + ], + operation: [ + 'triggerParams', + ], + }, + }, + default: '', + }, + { + displayName: 'Job Name', + name: 'job', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getJobs', + }, + displayOptions: { + show: { + resource: [ + 'job', + ], + operation: [ + 'trigger', + 'triggerParams', + 'copy', + ], + }, + }, + required: true, + default: '', + description: 'Name of the job', + }, + + // -------------------------------------------------------------------------------------------------------- + // Trigger a Job + // -------------------------------------------------------------------------------------------------------- + { + displayName: 'Parameters', + name: 'param', + type: 'fixedCollection', + placeholder: 'Add Parameter', + displayOptions: { + show: { + resource: [ + 'job', + ], + operation: [ + 'triggerParams', + ], + }, + }, + required: true, + default: '', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'params', + displayName: 'Parameters', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getJobParameters', + loadOptionsDependsOn: [ + 'job', + ], + }, + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + description: 'Parameters for Jenkins job', + }, + + // -------------------------------------------------------------------------------------------------------- + // Copy or Create a Job + // -------------------------------------------------------------------------------------------------------- + { + displayName: 'New Job Name', + name: 'newJob', + type: 'string', + displayOptions: { + show: { + resource: [ + 'job', + ], + operation: [ + 'copy', + 'create', + ], + }, + }, + required: true, + default: '', + description: 'Name of the new Jenkins job', + }, + { + displayName: 'XML', + name: 'xml', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + resource: [ + 'job', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + default: '', + description: 'XML of Jenkins config', + }, + { + displayName: 'To get the XML of an existing job, add ‘config.xml’ to the end of the job URL', + name: 'createNotice', + type: 'notice', + default: '', + displayOptions: { + show: { + resource: [ + 'job', + ], + operation: [ + 'create', + ], + }, + }, + }, + + // -------------------------------------------------------------------------------------------------------- + // Jenkins operations + // -------------------------------------------------------------------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'instance', + ], + }, + }, + options: [ + { + name: 'Cancel Quiet Down', + value: 'cancelQuietDown', + description: 'Cancel quiet down state', + }, + { + name: 'Quiet Down', + value: 'quietDown', + description: 'Put Jenkins in quiet mode, no builds can be started, Jenkins is ready for shutdown', + }, + { + name: 'Restart', + value: 'restart', + description: 'Restart Jenkins immediately on environments where it is possible', + }, + { + name: 'Safely Restart', + value: 'safeRestart', + description: 'Restart Jenkins once no jobs are running on environments where it is possible', + }, + { + name: 'Safely Shutdown', + value: 'safeExit', + description: 'Shutdown once no jobs are running', + }, + { + name: 'Shutdown', + value: 'exit', + description: 'Shutdown Jenkins immediately', + }, + ], + default: 'safeRestart', + description: 'Jenkins instance operations', + noDataExpression: true, + }, + { + displayName: 'Reason', + name: 'reason', + type: 'string', + displayOptions: { + show: { + resource: [ + 'instance', + ], + operation: [ + 'quietDown', + ], + }, + }, + required: false, + default: '', + description: 'Freeform reason for quiet down mode', + }, + { + displayName: 'Instance operation can shutdown Jenkins instance and make it unresponsive. Some commands may not be available depending on instance implementation.', + name: 'instanceNotice', + type: 'notice', + default: '', + displayOptions: { + show: { + resource: [ + 'instance', + ], + }, + }, + }, + + // -------------------------------------------------------------------------------------------------------- + // Builds operations + // -------------------------------------------------------------------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'build', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'List Builds', + }, + ], + default: 'getAll', + noDataExpression: true, + }, + { + displayName: 'Job Name', + name: 'job', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getJobs', + }, + displayOptions: { + show: { + resource: [ + 'build', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + default: '', + description: 'Name of the job', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + displayOptions: { + show: { + resource: [ + 'build', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'build', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + description: 'Max number of results to return', + }, + ], + }; + + methods = { + credentialTest: { + async jenkinApiCredentialTest( + this: ICredentialTestFunctions, + credential: ICredentialsDecrypted, + ): Promise { + const { baseUrl, username, apiKey } = credential.data as JenkinsApiCredentials; + + const url = tolerateTrailingSlash(baseUrl); + const endpoint = '/api/json'; + + const options = { + auth: { + username: username, + password: apiKey, + }, + method: 'GET', + body: {}, + qs: {}, + uri: `${url}${endpoint}`, + json: true, + }; + + try { + await this.helpers.request(options); + return { + status: 'OK', + message: 'Authentication successful', + }; + } catch (error) { + return { + status: 'Error', + message: error.message, + }; + } + }, + }, + loadOptions: { + async getJobs(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const endpoint = `/api/json`; + const { jobs } = await jenkinsApiRequest.call(this, 'GET', endpoint); + for (const job of jobs) { + returnData.push({ + name: job.name, + value: job.name, + }); + } + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + + return returnData; + }, + async getJobParameters(this: ILoadOptionsFunctions): Promise { + const job = this.getCurrentNodeParameter('job') as string; + const returnData: INodePropertyOptions[] = []; + const endpoint = `/job/${job}/api/json?tree=actions[parameterDefinitions[*]]`; + const { actions } = await jenkinsApiRequest.call(this, 'GET', endpoint); + for (const { _class, parameterDefinitions } of actions) { + if (_class?.includes('ParametersDefinitionProperty')) { + for (const { name, type } of parameterDefinitions) { + returnData.push({ + name: `${name} - (${type})`, + value: name, + }); + } + } + } + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length as unknown as number; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < length; i++) { + try { + if (resource === 'job') { + if (operation === 'trigger') { + const job = this.getNodeParameter('job', i) as string; + const endpoint = `/job/${job}/build`; + await jenkinsApiRequest.call(this, 'POST', endpoint); + responseData = { success: true }; + } + if (operation === 'triggerParams') { + const job = this.getNodeParameter('job', i) as string; + const params = this.getNodeParameter('param.params', i, []) as []; + let body = {}; + if (params.length) { + body = params.reduce((body: IDataObject, param: { name: string; value: string }) => { + body[param.name] = param.value; + return body; + }, {}); + } + const endpoint = `/job/${job}/buildWithParameters`; + await jenkinsApiRequest.call(this, 'POST', endpoint, {}, {}, + { + form: body, + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + }, + ); + responseData = { success: true }; + } + if (operation === 'copy') { + const job = this.getNodeParameter('job', i) as string; + const name = this.getNodeParameter('newJob', i) as string; + const queryParams = { + name, + mode: 'copy', + from: job, + }; + + const endpoint = `/createItem`; + try { + await jenkinsApiRequest.call(this, 'POST', endpoint, queryParams); + responseData = { success: true }; + } + catch (error) { + if (error.httpCode === '302') { + responseData = { success: true }; + } else { + throw new NodeApiError(this.getNode(), error); + } + } + } + if (operation === 'create') { + const name = this.getNodeParameter('newJob', i) as string; + const queryParams = { + name, + }; + const headers = { + 'content-type': 'application/xml', + }; + + const body = this.getNodeParameter('xml', i) as string; + + const endpoint = `/createItem`; + await jenkinsApiRequest.call(this, 'POST', endpoint, queryParams, body, { headers, json: false }); + responseData = { success: true }; + } + } + + if (resource === 'instance') { + if (operation === 'quietDown') { + const reason = this.getNodeParameter('reason', i) as string; + + let queryParams; + if (reason) { + queryParams = { + reason, + }; + } + + const endpoint = `/quietDown`; + await jenkinsApiRequest.call(this, 'POST', endpoint, queryParams); + responseData = { success: true }; + } + if (operation === 'cancelQuietDown') { + const endpoint = `/cancelQuietDown`; + await jenkinsApiRequest.call(this, 'POST', endpoint); + responseData = { success: true }; + } + if (operation === 'restart') { + const endpoint = `/restart`; + try { + await jenkinsApiRequest.call(this, 'POST', endpoint); + } catch (error) { + if (error.httpCode === '503') { + responseData = { success: true }; + } else { + throw new NodeApiError(this.getNode(), error); + } + } + } + if (operation === 'safeRestart') { + const endpoint = `/safeRestart`; + try { + await jenkinsApiRequest.call(this, 'POST', endpoint); + } catch (error) { + if (error.httpCode === '503') { + responseData = { success: true }; + } else { + throw new NodeApiError(this.getNode(), error); + } + } + } + if (operation === 'exit') { + const endpoint = `/exit`; + await jenkinsApiRequest.call(this, 'POST', endpoint); + responseData = { success: true }; + } + if (operation === 'safeExit') { + const endpoint = `/safeExit`; + await jenkinsApiRequest.call(this, 'POST', endpoint); + responseData = { success: true }; + } + } + + if (resource === 'build') { + if (operation === 'getAll') { + const job = this.getNodeParameter('job', i) as string; + let endpoint = `/job/${job}/api/json?tree=builds[*]`; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (!returnAll) { + const limit = this.getNodeParameter('limit', i) as number; + endpoint += `{0,${limit}}`; + } + + responseData = await jenkinsApiRequest.call(this, 'GET', endpoint); + responseData = responseData.builds; + } + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Jenkins/jenkins.svg b/packages/nodes-base/nodes/Jenkins/jenkins.svg new file mode 100644 index 0000000000000..ed609c41d5a19 --- /dev/null +++ b/packages/nodes-base/nodes/Jenkins/jenkins.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 7cad706b2a8f0..1ee9ff7b7dd2d 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -149,6 +149,7 @@ "dist/credentials/IntercomApi.credentials.js", "dist/credentials/InvoiceNinjaApi.credentials.js", "dist/credentials/IterableApi.credentials.js", + "dist/credentials/JenkinsApi.credentials.js", "dist/credentials/JiraSoftwareCloudApi.credentials.js", "dist/credentials/JiraSoftwareServerApi.credentials.js", "dist/credentials/JotFormApi.credentials.js", @@ -472,6 +473,7 @@ "dist/nodes/InvoiceNinja/InvoiceNinjaTrigger.node.js", "dist/nodes/ItemLists/ItemLists.node.js", "dist/nodes/Iterable/Iterable.node.js", + "dist/nodes/Jenkins/Jenkins.node.js", "dist/nodes/Jira/Jira.node.js", "dist/nodes/Jira/JiraTrigger.node.js", "dist/nodes/JotForm/JotFormTrigger.node.js",