Skip to content

Job scheduling #3927

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 14, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions spec/JobSchedule.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 1 addition & 1 deletion src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:name>', { className }))));
}, error => {
if (error.code === PostgresRelationDoesNotExistError) {
Expand Down
21 changes: 18 additions & 3 deletions src/Controllers/SchemaController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand All @@ -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}$/;
Expand Down Expand Up @@ -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;
Expand Down
72 changes: 64 additions & 8 deletions src/Routers/CloudCodeRouter.js
Original file line number Diff line number Diff line change
@@ -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
}
});
}
}
6 changes: 3 additions & 3 deletions src/rest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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);
}
Expand Down