diff --git a/requests/Switcher API (dev).postman_environment.json b/requests/Switcher API (dev).postman_environment.json index ef98761..fec59a8 100644 --- a/requests/Switcher API (dev).postman_environment.json +++ b/requests/Switcher API (dev).postman_environment.json @@ -22,9 +22,33 @@ "value": "", "type": "default", "enabled": true + }, + { + "key": "gitopsToken", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "gitops_domain_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "gitops_github_pat", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "gitops_github_url", + "value": "", + "type": "default", + "enabled": true } ], "_postman_variable_scope": "environment", - "_postman_exported_at": "2024-06-30T22:51:27.484Z", - "_postman_exported_using": "Postman/11.2.26" + "_postman_exported_at": "2024-10-09T02:18:58.843Z", + "_postman_exported_using": "Postman/11.15.0" } \ No newline at end of file diff --git a/requests/Switcher API.postman_collection.json b/requests/Switcher API.postman_collection.json index d6c4a76..0f15eff 100644 --- a/requests/Switcher API.postman_collection.json +++ b/requests/Switcher API.postman_collection.json @@ -5172,6 +5172,517 @@ } ] }, + { + "name": "GitOps", + "item": [ + { + "name": "Push V1", + "item": [ + { + "name": "GitOps Push - Change Group", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"CHANGED\",\r\n \"diff\": \"GROUP\",\r\n \"path\": [\r\n \"Group Test\"\r\n ],\r\n \"content\": {\r\n \"activated\": false\r\n }\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - Change Switcher", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"CHANGED\",\r\n \"diff\": \"CONFIG\",\r\n \"path\": [\r\n \"Group Test\",\r\n \"NEW_SWITCHER_2\"\r\n ],\r\n \"content\": {\r\n \"activated\": false\r\n }\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - Change Strategy", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"CHANGED\",\r\n \"diff\": \"STRATEGY\",\r\n \"path\": [\r\n \"New Group\",\r\n \"NEW_SWITCHER_STRATEGY\",\r\n \"VALUE_VALIDATION\"\r\n\r\n ],\r\n \"content\": {\r\n \"values\": [\r\n \"A\",\r\n \"C\"\r\n ]\r\n }\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - New Group", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"NEW\",\r\n \"diff\": \"GROUP\",\r\n \"path\": [],\r\n \"content\": {\r\n \"name\": \"New Group\",\r\n \"activated\": true\r\n }\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - New Group / Switcher", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"NEW\",\r\n \"diff\": \"GROUP\",\r\n \"path\": [],\r\n \"content\": {\r\n \"name\": \"New Group\",\r\n \"description\": \"New Group Description\",\r\n \"activated\": true,\r\n \"config\": [\r\n {\r\n \"key\": \"NEW_SWITCHER_FROM_GROUP\",\r\n \"description\": \"New Switcher\",\r\n \"activated\": false,\r\n \"components\": [\r\n \"switcher-playground\"\r\n ]\r\n }\r\n ]\r\n }\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - New Switcher", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"NEW\",\r\n \"diff\": \"CONFIG\",\r\n \"path\": [\r\n \"New Group\"\r\n ],\r\n \"content\": {\r\n \"key\": \"NEW_SWITCHER_2\",\r\n \"description\": \"New Switcher #2\",\r\n \"activated\": true,\r\n \"components\": [\r\n \"switcher-playground\"\r\n ]\r\n }\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - New Switcher / Strategy", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"NEW\",\r\n \"diff\": \"CONFIG\",\r\n \"path\": [\r\n \"New Group\"\r\n ],\r\n \"content\": {\r\n \"key\": \"NEW_SWITCHER_STRATEGY\",\r\n \"description\": \"New Switcher\",\r\n \"activated\": true,\r\n \"strategies\": [\r\n {\r\n \"strategy\": \"VALUE_VALIDATION\",\r\n \"description\": \"Test Strategy\",\r\n \"operation\": \"EXIST\",\r\n \"activated\": true,\r\n \"values\": [\r\n \"A\",\r\n \"B\"\r\n ]\r\n }\r\n ],\r\n \"components\": [\r\n \"switcher-playground\"\r\n ]\r\n }\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - New Strategy", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"NEW\",\r\n \"diff\": \"STRATEGY\",\r\n \"path\": [\r\n \"New Group\",\r\n \"NEW_SWITCHER_STRATEGY\"\r\n ],\r\n \"content\": {\r\n \"strategy\": \"NUMERIC_VALIDATION\",\r\n \"description\": \"Test Strategy\",\r\n \"operation\": \"EQUAL\",\r\n \"activated\": true,\r\n \"values\": [\r\n \"100\"\r\n ]\r\n }\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - Delete Group", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"DELETED\",\r\n \"diff\": \"GROUP\",\r\n \"path\": [\r\n \"New Group\"\r\n ]\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - Delete Component", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"DELETED\",\r\n \"diff\": \"COMPONENT\",\r\n \"path\": [\r\n \"New Group\",\r\n \"NEW_SWITCHER_STRATEGY\"\r\n ],\r\n \"content\": [\r\n \"switcher-playground\"\r\n ]\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - Delete Strategy", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"DELETED\",\r\n \"diff\": \"STRATEGY\",\r\n \"path\": [\r\n \"New Group\",\r\n \"NEW_SWITCHER_STRATEGY\",\r\n \"VALUE_VALIDATION\"\r\n ]\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + }, + { + "name": "GitOps Push - Delete Switcher", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{gitopsToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"environment\": \"default\",\r\n \"changes\": [\r\n {\r\n \"action\": \"DELETED\",\r\n \"diff\": \"CONFIG\",\r\n \"path\": [\r\n \"New Group\",\r\n \"NEW_SWITCHER_STRATEGY\"\r\n ]\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/push", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "push" + ] + } + }, + "response": [] + } + ], + "description": "Push API will apply changes to a Domain using payload generated by GitOps Service" + }, + { + "name": "Account V1", + "item": [ + { + "name": "GitOps Account - Subscribe", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{authToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n\t\"repository\": \"{{gitops_github_url}}\",\r\n\t\"token\": \"{{gitops_github_pat}}\",\r\n\t\"branch\": \"staging\",\r\n \"environment\": \"default\",\r\n\t\"domain\": {\r\n\t\t\"id\": \"{{gitops_domain_id}}\",\r\n\t\t\"name\": \"GitOps\"\r\n\t},\r\n\t\"settings\": {\r\n\t\t\"active\": true,\r\n\t\t\"window\": \"30s\",\r\n\t\t\"forceprune\": true\r\n\t}\t\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/gitops/v1/account/subscribe", + "host": [ + "{{url}}" + ], + "path": [ + "gitops", + "v1", + "account", + "subscribe" + ] + } + }, + "response": [] + } + ] + } + ] + }, { "name": "GraphQL", "item": [ diff --git a/src/api-docs/paths/path-gitops.js b/src/api-docs/paths/path-gitops.js new file mode 100644 index 0000000..c0d2322 --- /dev/null +++ b/src/api-docs/paths/path-gitops.js @@ -0,0 +1,76 @@ +import { commonSchemaContent } from './common.js'; + +export default { + '/gitops/v1/push': { + post: { + tags: ['Switcher GitOps'], + description: 'Push changes to the gitops repository', + security: [{ gitopsAuth: [] }], + requestBody: { + content: commonSchemaContent('GitOpsPushRequest') + }, + responses: { + 200: { + description: 'Changes applied successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Changes applied successfully' + }, + version: { + type: 'number', + description: 'Domain version (lastUpdate)', + example: 123456789 + } + } + } + } + } + }, + 500: { + description: 'Something went wrong while applying the changes', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + example: 'One or more changes could not be applied' + } + } + } + } + } + } + } + } + }, + '/gitops/v1/account/subscribe': { + post: { + tags: ['Switcher GitOps'], + description: 'Subscribe an account to receive gitops changes', + requestBody: { + content: commonSchemaContent('GitOpsAccountRequest') + }, + responses: { + 201: { + description: 'Account subscription successful', + content: commonSchemaContent('GitOpsAccountResponse') + }, + 400: { + description: 'Invalid request body', + content: commonSchemaContent('ErrorResponse') + }, + 500: { + description: 'Something went wrong while subscribing the account', + content: commonSchemaContent('ErrorResponse') + } + } + } + } +}; \ No newline at end of file diff --git a/src/api-docs/schemas/gitops.js b/src/api-docs/schemas/gitops.js new file mode 100644 index 0000000..d1ea787 --- /dev/null +++ b/src/api-docs/schemas/gitops.js @@ -0,0 +1,166 @@ +const change = { + action: { + type: 'string', + description: 'The type of action', + enum: ['NEW', 'CHANGED', 'DELETED'] + }, + diff: { + type: 'string', + description: 'The type of diff', + enum: ['GROUP', 'CONFIG', 'STRATEGY', 'COMPONENT'] + }, + path: { + type: 'array', + items: { + type: 'string' + } + }, + content: { + type: 'object', + description: 'The content of the change (COMPONENT is an array of strings)' + } +}; + +const domain = { + id: { + type: 'string', + description: 'The domain ID' + }, + name: { + type: 'string', + description: 'The domain name' + } +}; + +const settings = { + active: { + type: 'boolean', + description: 'Sync handler status' + }, + window: { + type: 'string', + description: 'Sync window time (s, m, h)' + }, + forceprune: { + type: 'boolean', + description: 'Force delete elements from the API when true' + } +}; + +const accountRequest = { + token: { + type: 'string', + description: 'The Git token' + }, + repository: { + type: 'string', + description: 'The repository URL' + }, + branch: { + type: 'string', + description: 'The branch name' + }, + environment: { + type: 'string', + description: 'The environment name' + }, + domain: { + type: 'object', + properties: domain + }, + settings: { + type: 'object', + properties: settings + } +}; + +const accountResponse = { + _id: { + type: 'string', + description: 'The account ID' + }, + token: { + type: 'string', + description: 'The Git encrypted token (opaque)', + example: '...a24f' + }, + repository: { + type: 'string', + description: 'The repository URL' + }, + branch: { + type: 'string', + description: 'The branch name' + }, + environment: { + type: 'string', + description: 'The environment name' + }, + domain: { + type: 'object', + properties: { ...domain, + version: { + type: 'number', + description: 'Domain version (lastUpdate)', + example: 123456789 + }, + lastcommit: { + type: 'string', + description: 'Last respository commit hash' + }, + lastupdate: { + type: 'string', + description: 'Last respository commit date' + }, + status: { + type: 'string', + description: 'Sync status', + enum: ['Pending', 'Synced', 'OutSync', 'Error'] + }, + message: { + type: 'string', + description: 'Sync last message' + } + } + }, + settings: { + type: 'object', + properties: settings + } +}; + +export default { + GitOpsPushRequest: { + type: 'object', + properties: { + environment: { + type: 'string', + description: 'The environment where the change will be applied' + }, + changes: { + type: 'array', + items: { + type: 'object', + properties: change + } + } + } + }, + GitOpsAccountRequest: { + type: 'object', + properties: accountRequest + }, + GitOpsAccountResponse: { + type: 'object', + properties: accountResponse + }, + ErrorResponse: { + type: 'object', + properties: { + error: { + type: 'string', + description: 'The error message' + } + } + } +}; \ No newline at end of file diff --git a/src/api-docs/swagger-document.js b/src/api-docs/swagger-document.js index 5a2821d..5b44577 100644 --- a/src/api-docs/swagger-document.js +++ b/src/api-docs/swagger-document.js @@ -9,6 +9,7 @@ import pathTeam from './paths/path-team.js'; import pathPermission from './paths/path-permission.js'; import pathMetric from './paths/path-metric.js'; import pathSlack from './paths/path-slack.js'; +import pathGitOps from './paths/path-gitops.js'; import { commonSchema } from './schemas/common.js'; import adminSchema from './schemas/admin.js'; @@ -22,6 +23,7 @@ import teamSchema from './schemas/team.js'; import permissionSchema from './schemas/permission.js'; import metricSchema from './schemas/metric.js'; import slackSchema from './schemas/slack.js'; +import gitOpsSchema from './schemas/gitops.js'; import info from './swagger-info.js'; export default { @@ -48,6 +50,11 @@ export default { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' + }, + gitopsAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT' } }, schemas: { @@ -62,7 +69,8 @@ export default { ...teamSchema, ...permissionSchema, ...metricSchema, - ...slackSchema + ...slackSchema, + ...gitOpsSchema } }, paths: { @@ -76,6 +84,7 @@ export default { ...pathTeam, ...pathPermission, ...pathMetric, - ...pathSlack + ...pathSlack, + ...pathGitOps } }; \ No newline at end of file diff --git a/src/external/gitops.js b/src/external/gitops.js index f3d3083..508d975 100644 --- a/src/external/gitops.js +++ b/src/external/gitops.js @@ -1,14 +1,23 @@ import axios from 'axios'; import https from 'https'; +import jwt from 'jsonwebtoken'; const agent = (url) => { const usesHttps = url.startsWith('https://'); return new https.Agent({ rejectUnauthorized: !usesHttps }); }; +const headers = (subject) => ({ + Authorization: `Bearer ${generateToken(subject)}`, + 'Content-Type': 'application/json' +}); + export async function createAccount(account) { const url = `${process.env.SWITCHER_GITOPS_URL}/account`; - const response = await axios.post(url, account, { httpsAgent: agent(url) }); + const response = await axios.post(url, account, { + httpsAgent: agent(url), + headers: headers(account.domain.id) + }); if (response.status !== 201) { throw new GitOpsError(`Failed to create account [${response.status}] ${JSON.stringify(response.data)}`); @@ -17,6 +26,17 @@ export async function createAccount(account) { return response.data; } +function generateToken(subject) { + const options = { + expiresIn: '1m' + }; + + return jwt.sign(({ + iss: 'Switcher API', + subject + }), process.env.SWITCHER_GITOPS_JWT_SECRET, options); +} + export class GitOpsError extends Error { constructor(message) { super(message); diff --git a/tests/gitops-account.test.js b/tests/gitops-account.test.js index 560aff9..7a43dc9 100644 --- a/tests/gitops-account.test.js +++ b/tests/gitops-account.test.js @@ -100,6 +100,27 @@ describe('GitOps Account - Subscribe', () => { postStub.restore(); }); + test('GITOPS_ACCOUNT_SUITE - Should return error - unauthorized', async () => { + // given + const postStub = sinon.stub(axios, 'post').resolves({ + status: 401, + data: { + error: 'Invalid token' + } + }); + + // test + const req = await request(app) + .post('/gitops/v1/account/subscribe') + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send(VALID_SUBSCRIPTION_REQUEST) + .expect(500); + + // assert + expect(req.body.error).toBe('Account subscription failed'); + postStub.restore(); + }); + test('GITOPS_ACCOUNT_SUITE - Should return error - missing domain.id', async () => { const payload = JSON.parse(JSON.stringify(VALID_SUBSCRIPTION_REQUEST)); delete payload.domain.id;