From f0949a13109f9c7f698c329726b10de0ba467a43 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Wed, 14 Jun 2017 13:07:00 -0400 Subject: [PATCH] feat: Job Scheduling (#3927) * Adds back _JobSchedule as volatile class * wip * Restores jobs endpoints for creation, update and deletion * Adds tests * Fixes postgres tests * Enforce jobName exists before creating a schedule --- spec/JobSchedule.spec.js | 198 ++++++++++++++++++ .../Postgres/PostgresStorageAdapter.js | 2 +- src/Controllers/SchemaController.js | 21 +- src/Routers/CloudCodeRouter.js | 72 ++++++- src/rest.js | 6 +- 5 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 spec/JobSchedule.spec.js diff --git a/spec/JobSchedule.spec.js b/spec/JobSchedule.spec.js new file mode 100644 index 00000000..3b5a087b --- /dev/null +++ b/spec/JobSchedule.spec.js @@ -0,0 +1,198 @@ +const rp = require('request-promise'); +const defaultHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest' +} +const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Master-Key': 'test' +} +const defaultOptions = { + headers: defaultHeaders, + json: true +} +const masterKeyOptions = { + headers: masterKeyHeaders, + json: true +} + +describe('JobSchedule', () => { + it('should create _JobSchedule with masterKey', (done) => { + const jobSchedule = new Parse.Object('_JobSchedule'); + jobSchedule.set({ + 'jobName': 'MY Cool Job' + }); + jobSchedule.save(null, {useMasterKey: true}).then(() => { + done(); + }) + .catch(done.fail); + }); + + it('should fail creating _JobSchedule without masterKey', (done) => { + const jobSchedule = new Parse.Object('_JobSchedule'); + jobSchedule.set({ + 'jobName': 'SomeJob' + }); + jobSchedule.save(null).then(done.fail) + .catch(done); + }); + + it('should reject access when not using masterKey (/jobs)', (done) => { + rp.get(Parse.serverURL + '/cloud_code/jobs', defaultOptions).then(done.fail, done); + }); + + it('should reject access when not using masterKey (/jobs/data)', (done) => { + rp.get(Parse.serverURL + '/cloud_code/jobs/data', defaultOptions).then(done.fail, done); + }); + + it('should reject access when not using masterKey (PUT /jobs/id)', (done) => { + rp.put(Parse.serverURL + '/cloud_code/jobs/jobId', defaultOptions).then(done.fail, done); + }); + + it('should reject access when not using masterKey (PUT /jobs/id)', (done) => { + rp.del(Parse.serverURL + '/cloud_code/jobs/jobId', defaultOptions).then(done.fail, done); + }); + + it('should allow access when using masterKey (/jobs)', (done) => { + rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions).then(done, done.fail); + }); + + it('should create a job schedule', (done) => { + Parse.Cloud.job('job', () => {}); + const options = Object.assign({}, masterKeyOptions, { + body: { + job_schedule: { + jobName: 'job' + } + } + }); + rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => { + expect(res.objectId).not.toBeUndefined(); + }) + .then(() => { + return rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions); + }) + .then((res) => { + expect(res.length).toBe(1); + }) + .then(done) + .catch(done.fail); + }); + + it('should fail creating a job with an invalid name', (done) => { + const options = Object.assign({}, masterKeyOptions, { + body: { + job_schedule: { + jobName: 'job' + } + } + }); + rp.post(Parse.serverURL + '/cloud_code/jobs', options) + .then(done.fail) + .catch(done); + }); + + it('should update a job', (done) => { + Parse.Cloud.job('job1', () => {}); + Parse.Cloud.job('job2', () => {}); + const options = Object.assign({}, masterKeyOptions, { + body: { + job_schedule: { + jobName: 'job1' + } + } + }); + rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => { + expect(res.objectId).not.toBeUndefined(); + return rp.put(Parse.serverURL + '/cloud_code/jobs/' + res.objectId, Object.assign(options, { + body: { + job_schedule: { + jobName: 'job2' + } + } + })); + }) + .then(() => { + return rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions); + }) + .then((res) => { + expect(res.length).toBe(1); + expect(res[0].jobName).toBe('job2'); + }) + .then(done) + .catch(done.fail); + }); + + it('should fail updating a job with an invalid name', (done) => { + Parse.Cloud.job('job1', () => {}); + const options = Object.assign({}, masterKeyOptions, { + body: { + job_schedule: { + jobName: 'job1' + } + } + }); + rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => { + expect(res.objectId).not.toBeUndefined(); + return rp.put(Parse.serverURL + '/cloud_code/jobs/' + res.objectId, Object.assign(options, { + body: { + job_schedule: { + jobName: 'job2' + } + } + })); + }) + .then(done.fail) + .catch(done); + }); + + it('should destroy a job', (done) => { + Parse.Cloud.job('job', () => {}); + const options = Object.assign({}, masterKeyOptions, { + body: { + job_schedule: { + jobName: 'job' + } + } + }); + rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => { + expect(res.objectId).not.toBeUndefined(); + return rp.del(Parse.serverURL + '/cloud_code/jobs/' + res.objectId, masterKeyOptions); + }) + .then(() => { + return rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions); + }) + .then((res) => { + expect(res.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('should properly return job data', (done) => { + Parse.Cloud.job('job1', () => {}); + Parse.Cloud.job('job2', () => {}); + const options = Object.assign({}, masterKeyOptions, { + body: { + job_schedule: { + jobName: 'job1' + } + } + }); + rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => { + expect(res.objectId).not.toBeUndefined(); + }) + .then(() => { + return rp.get(Parse.serverURL + '/cloud_code/jobs/data', masterKeyOptions); + }) + .then((res) => { + expect(res.in_use).toEqual(['job1']); + expect(res.jobs).toContain('job1'); + expect(res.jobs).toContain('job2'); + expect(res.jobs.length).toBe(2); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 2cfb766e..b6dd11ec 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -667,7 +667,7 @@ export class PostgresStorageAdapter { const joins = results.reduce((list, schema) => { return list.concat(joinTablesForSchema(schema.schema)); }, []); - const classes = ['_SCHEMA','_PushStatus','_JobStatus','_Hooks','_GlobalConfig', ...results.map(result => result.className), ...joins]; + const classes = ['_SCHEMA','_PushStatus','_JobStatus','_JobSchedule','_Hooks','_GlobalConfig', ...results.map(result => result.className), ...joins]; return this._client.tx(t=>t.batch(classes.map(className=>t.none('DROP TABLE IF EXISTS $', { className })))); }, error => { if (error.code === PostgresRelationDoesNotExistError) { diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index a29298b4..159482f3 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -95,6 +95,16 @@ const defaultColumns = Object.freeze({ "params": {type: 'Object'}, // params received when calling the job "finishedAt": {type: 'Date'} }, + _JobSchedule: { + "jobName": {type:'String'}, + "description": {type:'String'}, + "params": {type:'String'}, + "startAfter": {type:'String'}, + "daysOfWeek": {type:'Array'}, + "timeOfDay": {type:'String'}, + "lastRun": {type:'Number'}, + "repeatMinutes":{type:'Number'} + }, _Hooks: { "functionName": {type:'String'}, "className": {type:'String'}, @@ -112,9 +122,9 @@ const requiredColumns = Object.freeze({ _Role: ["name", "ACL"] }); -const systemClasses = Object.freeze(['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus', '_JobStatus']); +const systemClasses = Object.freeze(['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus', '_JobStatus', '_JobSchedule']); -const volatileClasses = Object.freeze(['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig']); +const volatileClasses = Object.freeze(['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig', '_JobSchedule']); // 10 alpha numberic chars + uppercase const userIdRegex = /^[a-zA-Z0-9]{10}$/; @@ -291,7 +301,12 @@ const _JobStatusSchema = convertSchemaToAdapterSchema(injectDefaultSchema({ fields: {}, classLevelPermissions: {} })); -const VolatileClassesSchemas = [_HooksSchema, _JobStatusSchema, _PushStatusSchema, _GlobalConfigSchema]; +const _JobScheduleSchema = convertSchemaToAdapterSchema(injectDefaultSchema({ + className: "_JobSchedule", + fields: {}, + classLevelPermissions: {} +})); +const VolatileClassesSchemas = [_HooksSchema, _JobStatusSchema, _JobScheduleSchema, _PushStatusSchema, _GlobalConfigSchema]; const dbTypeMatchesObjectType = (dbType, objectType) => { if (dbType.type !== objectType.type) return false; diff --git a/src/Routers/CloudCodeRouter.js b/src/Routers/CloudCodeRouter.js index 0cf45b47..d1ca9009 100644 --- a/src/Routers/CloudCodeRouter.js +++ b/src/Routers/CloudCodeRouter.js @@ -1,20 +1,76 @@ -import PromiseRouter from '../PromiseRouter'; -const triggers = require('../triggers'); +import PromiseRouter from '../PromiseRouter'; +import Parse from 'parse/node'; +import rest from '../rest'; +const triggers = require('../triggers'); +const middleware = require('../middlewares'); + +function formatJobSchedule(job_schedule) { + if (typeof job_schedule.startAfter === 'undefined') { + job_schedule.startAfter = new Date().toISOString(); + } + return job_schedule; +} + +function validateJobSchedule(config, job_schedule) { + const jobs = triggers.getJobs(config.applicationId) || {}; + if (job_schedule.jobName && !jobs[job_schedule.jobName]) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Cannot Schedule a job that is not deployed'); + } +} export class CloudCodeRouter extends PromiseRouter { mountRoutes() { - this.route('GET',`/cloud_code/jobs`, CloudCodeRouter.getJobs); + this.route('GET', '/cloud_code/jobs', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.getJobs); + this.route('GET', '/cloud_code/jobs/data', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.getJobsData); + this.route('POST', '/cloud_code/jobs', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.createJob); + this.route('PUT', '/cloud_code/jobs/:objectId', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.editJob); + this.route('DELETE', '/cloud_code/jobs/:objectId', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.deleteJob); } static getJobs(req) { + return rest.find(req.config, req.auth, '_JobSchedule', {}, {}).then((scheduledJobs) => { + return { + response: scheduledJobs.results + } + }); + } + + static getJobsData(req) { const config = req.config; const jobs = triggers.getJobs(config.applicationId) || {}; - return Promise.resolve({ - response: Object.keys(jobs).map((jobName) => { - return { - jobName, + return rest.find(req.config, req.auth, '_JobSchedule', {}, {}).then((scheduledJobs) => { + return { + response: { + in_use: scheduledJobs.results.map((job) => job.jobName), + jobs: Object.keys(jobs), } - }) + }; + }); + } + + static createJob(req) { + const { job_schedule } = req.body; + validateJobSchedule(req.config, job_schedule); + return rest.create(req.config, req.auth, '_JobSchedule', formatJobSchedule(job_schedule), req.client); + } + + static editJob(req) { + const { objectId } = req.params; + const { job_schedule } = req.body; + validateJobSchedule(req.config, job_schedule); + return rest.update(req.config, req.auth, '_JobSchedule', { objectId }, formatJobSchedule(job_schedule)).then((response) => { + return { + response + } + }); + } + + static deleteJob(req) { + const { objectId } = req.params; + return rest.del(req.config, req.auth, '_JobSchedule', objectId).then((response) => { + return { + response + } }); } } diff --git a/src/rest.js b/src/rest.js index d6341b14..a1482aef 100644 --- a/src/rest.js +++ b/src/rest.js @@ -134,7 +134,8 @@ function update(config, auth, className, restWhere, restObject, clientSDK) { }); } -// Disallowing access to the _Role collection except by master key +const classesWithMasterOnlyAccess = ['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig', '_JobSchedule']; + // Disallowing access to the _Role collection except by master key function enforceRoleSecurity(method, className, auth) { if (className === '_Installation' && !auth.isMaster) { if (method === 'delete' || method === 'find') { @@ -144,8 +145,7 @@ function enforceRoleSecurity(method, className, auth) { } //all volatileClasses are masterKey only - const volatileClasses = ['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig']; - if(volatileClasses.includes(className) && !auth.isMaster){ + if(classesWithMasterOnlyAccess.includes(className) && !auth.isMaster){ const error = `Clients aren't allowed to perform the ${method} operation on the ${className} collection.` throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); }