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
This commit is contained in:
198
spec/JobSchedule.spec.js
Normal file
198
spec/JobSchedule.spec.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -667,7 +667,7 @@ export class PostgresStorageAdapter {
|
|||||||
const joins = results.reduce((list, schema) => {
|
const joins = results.reduce((list, schema) => {
|
||||||
return list.concat(joinTablesForSchema(schema.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 }))));
|
return this._client.tx(t=>t.batch(classes.map(className=>t.none('DROP TABLE IF EXISTS $<className:name>', { className }))));
|
||||||
}, error => {
|
}, error => {
|
||||||
if (error.code === PostgresRelationDoesNotExistError) {
|
if (error.code === PostgresRelationDoesNotExistError) {
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ const defaultColumns = Object.freeze({
|
|||||||
"params": {type: 'Object'}, // params received when calling the job
|
"params": {type: 'Object'}, // params received when calling the job
|
||||||
"finishedAt": {type: 'Date'}
|
"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: {
|
_Hooks: {
|
||||||
"functionName": {type:'String'},
|
"functionName": {type:'String'},
|
||||||
"className": {type:'String'},
|
"className": {type:'String'},
|
||||||
@@ -112,9 +122,9 @@ const requiredColumns = Object.freeze({
|
|||||||
_Role: ["name", "ACL"]
|
_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
|
// 10 alpha numberic chars + uppercase
|
||||||
const userIdRegex = /^[a-zA-Z0-9]{10}$/;
|
const userIdRegex = /^[a-zA-Z0-9]{10}$/;
|
||||||
@@ -291,7 +301,12 @@ const _JobStatusSchema = convertSchemaToAdapterSchema(injectDefaultSchema({
|
|||||||
fields: {},
|
fields: {},
|
||||||
classLevelPermissions: {}
|
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) => {
|
const dbTypeMatchesObjectType = (dbType, objectType) => {
|
||||||
if (dbType.type !== objectType.type) return false;
|
if (dbType.type !== objectType.type) return false;
|
||||||
|
|||||||
@@ -1,20 +1,76 @@
|
|||||||
import PromiseRouter from '../PromiseRouter';
|
import PromiseRouter from '../PromiseRouter';
|
||||||
|
import Parse from 'parse/node';
|
||||||
|
import rest from '../rest';
|
||||||
const triggers = require('../triggers');
|
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 {
|
export class CloudCodeRouter extends PromiseRouter {
|
||||||
mountRoutes() {
|
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) {
|
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 config = req.config;
|
||||||
const jobs = triggers.getJobs(config.applicationId) || {};
|
const jobs = triggers.getJobs(config.applicationId) || {};
|
||||||
return Promise.resolve({
|
return rest.find(req.config, req.auth, '_JobSchedule', {}, {}).then((scheduledJobs) => {
|
||||||
response: Object.keys(jobs).map((jobName) => {
|
|
||||||
return {
|
return {
|
||||||
jobName,
|
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
|
||||||
}
|
}
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
function enforceRoleSecurity(method, className, auth) {
|
||||||
if (className === '_Installation' && !auth.isMaster) {
|
if (className === '_Installation' && !auth.isMaster) {
|
||||||
if (method === 'delete' || method === 'find') {
|
if (method === 'delete' || method === 'find') {
|
||||||
@@ -144,8 +145,7 @@ function enforceRoleSecurity(method, className, auth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//all volatileClasses are masterKey only
|
//all volatileClasses are masterKey only
|
||||||
const volatileClasses = ['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig'];
|
if(classesWithMasterOnlyAccess.includes(className) && !auth.isMaster){
|
||||||
if(volatileClasses.includes(className) && !auth.isMaster){
|
|
||||||
const error = `Clients aren't allowed to perform the ${method} operation on the ${className} collection.`
|
const error = `Clients aren't allowed to perform the ${method} operation on the ${className} collection.`
|
||||||
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error);
|
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user