updating tasks and tech-insights to work together

Signed-off-by: Phil Gore <pgore@ea.com>
This commit is contained in:
Phil Gore
2022-02-28 23:16:42 -06:00
committed by Fredrik Adelöw
parent 6bc4253672
commit 231fee736b
18 changed files with 358 additions and 134 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-tech-insights-node': patch
---
Adds an optional timeout to fact retriever registrations to stop a task if it runs too long.
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-tech-insights-backend': minor
---
Updates tech-insights to use backend-tasks as the Fact Retriever scheduler.
```
```
+1
View File
@@ -15,6 +15,7 @@ export interface PluginTaskScheduler {
scheduleTask(
task: TaskScheduleDefinition & TaskInvocationDefinition,
): Promise<void>;
triggerTask(id: string): Promise<void>;
}
// @public
@@ -20,6 +20,9 @@ import { Duration } from 'luxon';
import waitForExpect from 'wait-for-expect';
import { migrateBackendTasks } from '../database/migrateBackendTasks';
import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl';
import { ConflictError, NotFoundError } from '@backstage/errors';
jest.useFakeTimers();
describe('PluginTaskManagerImpl', () => {
const databases = TestDatabases.create({
@@ -80,6 +83,72 @@ describe('PluginTaskManagerImpl', () => {
);
});
describe('triggerTask', () => {
it.each(databases.eachSupportedId())(
'can manually trigger a task, %p',
async databaseId => {
const { manager } = await init(databaseId);
const fn = jest.fn();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ years: 1 }),
fn,
});
await manager.triggerTask('task1');
jest.advanceTimersByTime(5000);
await waitForExpect(() => {
expect(fn).toBeCalled();
});
},
60_000,
);
it.each(databases.eachSupportedId())(
'cant trigger a non-existent task, %p',
async databaseId => {
const { manager } = await init(databaseId);
const fn = jest.fn();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn,
});
await expect(() => manager.triggerTask('task2')).rejects.toThrow(
NotFoundError,
);
},
60_000,
);
it.each(databases.eachSupportedId())(
'cant trigger a running task, %p',
async databaseId => {
const { manager } = await init(databaseId);
const fn = jest.fn();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn,
});
await expect(() => manager.triggerTask('task1')).rejects.toThrow(
ConflictError,
);
},
60_000,
);
});
// This is just to test the wrapper code; most of the actual tests are in
// TaskWorker.test.ts
describe('createScheduledTaskRunner', () => {
@@ -24,6 +24,8 @@ import {
TaskScheduleDefinition,
} from './types';
import { validateId } from './util';
import { DB_TASKS_TABLE, DbTasksRow } from '../database/tables';
import { ConflictError, NotFoundError } from '@backstage/errors';
/**
* Implements the actual task management.
@@ -34,6 +36,37 @@ export class PluginTaskSchedulerImpl implements PluginTaskScheduler {
private readonly logger: Logger,
) {}
async triggerTask(id: string): Promise<void> {
const knex = await this.databaseFactory();
// get the task definition
const rows = await knex<DbTasksRow>(DB_TASKS_TABLE)
.select({
currentRun: 'current_run_ticket',
id: 'id',
})
.where('id', '=', id);
// validate the task exists
if (rows.length <= 0 || rows[0].id !== id) {
throw new NotFoundError(`Task ${id} does not exist`);
}
if (rows[0].currentRun) {
throw new ConflictError(`task ${id} is currently running`);
}
const updatedRows = await knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', id)
.whereNull('current_run_ticket')
.update({
next_run_start_at: knex.fn.now(),
});
if (updatedRows < 1) {
throw new ConflictError(`task ${id} is currently running`);
}
}
async scheduleTask(
task: TaskScheduleDefinition & TaskInvocationDefinition,
): Promise<void> {
+11
View File
@@ -134,6 +134,17 @@ export interface TaskRunner {
* @public
*/
export interface PluginTaskScheduler {
/**
* Manually triggers a task by ID.
*
* If the task doesn't exist, a NotFoundError is thrown.
* If the task is currently running, a ConflictError is thrown.
*
* @param id - The task ID
*
*/
triggerTask(id: string): Promise<void>;
/**
* Schedules a task function for coordinated exclusive invocation across
* workers. This convenience method performs both the scheduling and
@@ -36,6 +36,7 @@ export default async function createPlugin(
logger: env.logger,
config: env.config,
database: env.database,
scheduler: env.scheduler,
discovery: env.discovery,
factRetrievers: [
createFactRetrieverRegistration({
@@ -46,8 +46,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.16.1-next.0",
"@types/node-cron": "^3.0.1"
"@backstage/cli": "^0.16.1-next.0"
},
"files": [
"dist"
@@ -14,6 +14,7 @@ import { FactRetrieverRegistration } from '@backstage/plugin-tech-insights-node'
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { TechInsightCheck } from '@backstage/plugin-tech-insights-node';
import { TechInsightsStore } from '@backstage/plugin-tech-insights-node';
@@ -94,6 +95,8 @@ export interface TechInsightsOptions<
factRetrievers: FactRetrieverRegistration[];
// (undocumented)
logger: Logger;
// (undocumented)
scheduler: PluginTaskScheduler;
}
// (No @packageDocumentation comment for this package)
+3 -3
View File
@@ -35,6 +35,7 @@
},
"dependencies": {
"@backstage/backend-common": "^0.13.2-next.0",
"@backstage/backend-tasks": "^0.2.2-next.0",
"@backstage/catalog-client": "^1.0.1-next.0",
"@backstage/catalog-model": "^1.0.1-next.0",
"@backstage/config": "^1.0.0",
@@ -47,7 +48,6 @@
"knex": "^1.0.2",
"lodash": "^4.17.21",
"luxon": "^2.0.2",
"node-cron": "^3.0.0",
"semver": "^7.3.5",
"uuid": "^8.3.2",
"winston": "^3.2.1",
@@ -57,9 +57,9 @@
"@backstage/backend-test-utils": "^0.1.23-next.0",
"@backstage/cli": "^0.16.1-next.0",
"@types/supertest": "^2.0.8",
"@types/node-cron": "^3.0.0",
"@types/semver": "^7.3.8",
"supertest": "^6.1.3"
"supertest": "^6.1.3",
"wait-for-expect": "^3.0.2"
},
"files": [
"dist",
@@ -22,20 +22,16 @@ import {
} from '@backstage/plugin-tech-insights-node';
import { FactRetrieverRegistry } from './FactRetrieverRegistry';
import { FactRetrieverEngine } from './FactRetrieverEngine';
import { getVoidLogger } from '@backstage/backend-common';
import { DatabaseManager, getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { schedule } from 'node-cron';
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { TaskScheduler } from '@backstage/backend-tasks';
import waitForExpect from 'wait-for-expect';
jest.mock('node-cron', () => {
const original = jest.requireActual('node-cron');
return {
...original,
schedule: jest.fn(),
};
});
jest.useFakeTimers();
const testFactRetriever: FactRetriever = {
id: 'test-factretriever',
id: 'test_factretriever',
version: '0.0.1',
entityFilter: [{ kind: 'component' }],
schema: {
@@ -59,94 +55,162 @@ const testFactRetriever: FactRetriever = {
];
}),
};
const cadence = '1 * * * *';
const defaultCadence = '1 * * * *';
describe('FactRetrieverEngine', () => {
let engine: FactRetrieverEngine;
let factSchemaAssertionCallback: (
type FactSchemaAssertionCallback = (
factSchemaDefinition: FactSchemaDefinition,
) => void;
let factInsertionAssertionCallback: (facts: TechInsightFact[]) => void;
const mockRepository: TechInsightsStore = {
insertFacts: (facts: TechInsightFact[]) => {
factInsertionAssertionCallback(facts);
return Promise.resolve();
},
insertFactSchema: (def: FactSchemaDefinition) => {
factSchemaAssertionCallback(def);
return Promise.resolve();
},
} as unknown as TechInsightsStore;
jest.setTimeout(15000);
const mockFactRetrieverRegistry: FactRetrieverRegistry = {
listRetrievers(): FactRetriever[] {
return [testFactRetriever];
},
listRegistrations(): FactRetrieverRegistration[] {
return [{ factRetriever: testFactRetriever, cadence }];
},
} as unknown as FactRetrieverRegistry;
type FactInsertionAssertionCallback = ({
facts,
id,
}: {
id: string;
facts: TechInsightFact[];
}) => void;
const defaultEngineConfig = {
factRetrieverContext: {
logger: getVoidLogger(),
config: ConfigReader.fromConfigs([]),
discovery: {
getBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
getExternalBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
function createMockRepository(
insertCallback: FactInsertionAssertionCallback,
assertionCallback: FactSchemaAssertionCallback,
): TechInsightsStore {
return {
async insertFacts(f: { facts: TechInsightFact[]; id: string }) {
insertCallback(f);
},
},
factRetrieverRegistry: mockFactRetrieverRegistry,
repository: mockRepository,
};
it('Should update fact retriever schemas on initialization', async () => {
factSchemaAssertionCallback = ({ id, schema, version, entityFilter }) => {
expect(id).toEqual('test-factretriever');
expect(version).toEqual('0.0.1');
expect(entityFilter).toEqual([{ kind: 'component' }]);
expect(schema).toEqual({
testnumberfact: {
type: 'integer',
description: '',
},
});
};
engine = await FactRetrieverEngine.create(defaultEngineConfig);
});
it('Should insert facts when scheduled step is run', async () => {
(schedule as jest.Mock).mockImplementation(
(cronCadence: string, retrieverAction: Function) => {
return {
cadence: cronCadence,
triggerScheduledJobNow: retrieverAction,
};
async insertFactSchema(def: FactSchemaDefinition) {
assertionCallback(def);
},
);
} as unknown as TechInsightsStore;
}
factSchemaAssertionCallback = () => {};
factInsertionAssertionCallback = facts => {
expect(facts).toHaveLength(1);
expect(facts[0]).toEqual({
ref: 'test-factretriever',
entity: {
namespace: 'a',
kind: 'a',
name: 'a',
},
facts: {
testnumberfact: 1,
},
});
};
engine = await FactRetrieverEngine.create(defaultEngineConfig);
engine.schedule();
const job: any = engine.getJob('test-factretriever');
job.triggerScheduledJobNow();
expect(job.cadence!).toEqual(cadence);
expect(testFactRetriever.handler).toHaveBeenCalledWith(
expect.objectContaining({ entityFilter: testFactRetriever.entityFilter }),
);
function createMockFactRetrieverRegistry(
cadence?: string,
factRetriever?: FactRetriever,
): FactRetrieverRegistry {
const cron = cadence ?? defaultCadence;
const retriever: FactRetriever = factRetriever
? factRetriever
: testFactRetriever;
return {
listRetrievers(): FactRetriever[] {
return [retriever];
},
listRegistrations(): FactRetrieverRegistration[] {
return [{ factRetriever: retriever, cadence: cron }];
},
get: (_: string): FactRetrieverRegistration => {
return { factRetriever: retriever, cadence: cron };
},
} as unknown as FactRetrieverRegistry;
}
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
});
async function createEngine(
databaseId: TestDatabaseId,
insert: FactInsertionAssertionCallback,
schema: FactSchemaAssertionCallback,
cadence?: string,
factRetriever?: FactRetriever,
): Promise<FactRetrieverEngine> {
const knex = await databases.init(databaseId);
const databaseManager: Partial<DatabaseManager> = {
forPlugin: (_: string) => ({
getClient: async () => knex,
}),
};
const manager = databaseManager as DatabaseManager;
const scheduler = new TaskScheduler(manager, getVoidLogger());
return await FactRetrieverEngine.create({
factRetrieverContext: {
logger: getVoidLogger(),
config: ConfigReader.fromConfigs([]),
discovery: {
getBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
getExternalBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
},
},
factRetrieverRegistry: createMockFactRetrieverRegistry(
cadence,
factRetriever,
),
repository: createMockRepository(insert, schema),
scheduler: scheduler.forPlugin('tech-insights'),
});
}
it.each(databases.eachSupportedId())(
'Should update fact retriever schemas on initialization',
async databaseId => {
const schemaAssertionCallback = jest.fn((def: FactSchemaDefinition) => {
expect(def.id).toEqual('test_factretriever');
expect(def.version).toEqual('0.0.1');
expect(def.entityFilter).toEqual([{ kind: 'component' }]);
expect(def.schema).toEqual({
testnumberfact: {
type: 'integer',
description: '',
},
});
});
engine = await createEngine(
databaseId,
() => {},
schemaAssertionCallback,
);
expect(schemaAssertionCallback).toBeCalled();
},
60_000,
);
it.each(databases.eachSupportedId())(
'Should insert facts when scheduled step is run',
async databaseId => {
function insertCallback({
facts,
id,
}: {
id: string;
facts: TechInsightFact[];
}) {
expect(facts).toHaveLength(1);
expect(id).toEqual('test_factretriever');
expect(facts[0]).toEqual({
entity: {
namespace: 'a',
kind: 'a',
name: 'a',
},
facts: {
testnumberfact: 1,
},
});
}
engine = await createEngine(databaseId, insertCallback, () => {});
engine.schedule();
const job: FactRetrieverRegistration = engine.getJobRegistration(
testFactRetriever.id,
);
expect(job.cadence!!).toEqual(defaultCadence);
await engine.triggerJob(job.factRetriever.id);
jest.advanceTimersByTime(5000);
await waitForExpect(() => {
expect(testFactRetriever.handler).toHaveBeenCalledWith(
expect.objectContaining({
entityFilter: testFactRetriever.entityFilter,
}),
);
});
},
60_000,
);
});
@@ -17,12 +17,14 @@ import {
FactLifecycle,
FactRetriever,
FactRetrieverContext,
FactRetrieverRegistration,
TechInsightFact,
TechInsightsStore,
} from '@backstage/plugin-tech-insights-node';
import { FactRetrieverRegistry } from './FactRetrieverRegistry';
import { schedule, validate, ScheduledTask } from 'node-cron';
import { Logger } from 'winston';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { Duration } from 'luxon';
function randomDailyCron() {
const rand = (min: number, max: number) =>
@@ -37,26 +39,30 @@ function duration(startTimestamp: [number, number]): string {
}
export class FactRetrieverEngine {
private scheduledJobs = new Map<string, ScheduledTask>();
constructor(
private readonly repository: TechInsightsStore,
private readonly factRetrieverRegistry: FactRetrieverRegistry,
private readonly factRetrieverContext: FactRetrieverContext,
private readonly logger: Logger,
private readonly scheduler: PluginTaskScheduler,
private readonly defaultCadence?: string,
private readonly defaultTimeout?: Duration,
) {}
static async create({
repository,
factRetrieverRegistry,
factRetrieverContext,
scheduler,
defaultCadence,
defaultTimeout,
}: {
repository: TechInsightsStore;
factRetrieverRegistry: FactRetrieverRegistry;
factRetrieverContext: FactRetrieverContext;
scheduler: PluginTaskScheduler;
defaultCadence?: string;
defaultTimeout?: Duration;
}) {
await Promise.all(
factRetrieverRegistry
@@ -69,39 +75,46 @@ export class FactRetrieverEngine {
factRetrieverRegistry,
factRetrieverContext,
factRetrieverContext.logger,
scheduler,
defaultCadence,
defaultTimeout,
);
}
schedule() {
const registrations = this.factRetrieverRegistry.listRegistrations();
const newRegs: string[] = [];
registrations.forEach(registration => {
const { factRetriever, cadence, lifecycle } = registration;
if (!this.scheduledJobs.has(factRetriever.id)) {
const cronExpression =
cadence || this.defaultCadence || randomDailyCron();
if (!validate(cronExpression)) {
this.logger.warn(
`Validation failed for cron expression ${cronExpression} when trying to schedule fact retriever ${factRetriever.id}`,
);
return;
}
const job = schedule(
cronExpression,
this.createFactRetrieverHandler(factRetriever, lifecycle),
registrations.forEach(async registration => {
const { factRetriever, cadence, lifecycle, timeout } = registration;
const cronExpression =
cadence || this.defaultCadence || randomDailyCron();
const timeLimit =
timeout || this.defaultTimeout || Duration.fromObject({ minutes: 5 });
try {
await this.scheduler.scheduleTask({
id: factRetriever.id,
frequency: { cron: cronExpression },
fn: this.createFactRetrieverHandler(factRetriever, lifecycle),
timeout: timeLimit,
});
} catch (e) {
throw new Error(
`Failed to schedule fact retriever ${factRetriever.id}, ${e}`,
);
this.scheduledJobs.set(factRetriever.id, job);
newRegs.push(factRetriever.id);
}
newRegs.push(factRetriever.id);
});
this.logger.info(
`Scheduled ${newRegs.length} fact retrievers to Fact Retriever Engine.`,
`Scheduled ${newRegs.length} fact retrievers to Fact Retriever Engine through Backend Tasks`,
);
}
getJob(ref: string) {
return this.scheduledJobs.get(ref);
getJobRegistration(ref: string): FactRetrieverRegistration {
return this.factRetrieverRegistry.get(ref);
}
async triggerJob(ref: string): Promise<void> {
return this.scheduler.triggerTask(ref);
}
private createFactRetrieverHandler(
@@ -39,14 +39,14 @@ export class FactRetrieverRegistry {
this.retrievers.set(registration.factRetriever.id, registration);
}
get(retrieverReference: string): FactRetriever {
get(retrieverReference: string): FactRetrieverRegistration {
const registration = this.retrievers.get(retrieverReference);
if (!registration) {
throw new NotFoundError(
`Tech insight fact retriever with identifier '${retrieverReference}' is not registered.`,
);
}
return registration.factRetriever;
return registration;
}
listRetrievers(): FactRetriever[] {
@@ -15,7 +15,7 @@
*/
import { buildTechInsightsContext } from './techInsightsContextBuilder';
import { createRouter } from './router';
import { getVoidLogger } from '@backstage/backend-common';
import { DatabaseManager, getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import request from 'supertest';
import express from 'express';
@@ -23,6 +23,7 @@ import { PersistenceContext } from './persistence/persistenceContext';
import { TechInsightsStore } from '@backstage/plugin-tech-insights-node';
import { DateTime } from 'luxon';
import { Knex } from 'knex';
import { TaskScheduler } from '@backstage/backend-tasks';
describe('Tech Insights router tests', () => {
let app: express.Express;
@@ -44,6 +45,18 @@ describe('Tech Insights router tests', () => {
});
beforeAll(async () => {
const databaseManager: Partial<DatabaseManager> = {
forPlugin: () => ({
getClient: () => {
return Promise.resolve({
migrate: {
latest: () => {},
},
}) as unknown as Promise<Knex>;
},
}),
};
const manager = databaseManager as DatabaseManager;
const techInsightsContext = await buildTechInsightsContext({
database: {
getClient: () => {
@@ -56,6 +69,9 @@ describe('Tech Insights router tests', () => {
},
logger: getVoidLogger(),
factRetrievers: [],
scheduler: new TaskScheduler(manager, getVoidLogger()).forPlugin(
'tech-insights',
),
config: ConfigReader.fromConfigs([]),
discovery: {
getBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
@@ -33,6 +33,7 @@ import {
PersistenceContext,
} from './persistence/persistenceContext';
import { CheckResult } from '@backstage/plugin-tech-insights-common';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
/**
* @public
@@ -61,6 +62,7 @@ export interface TechInsightsOptions<
config: Config;
discovery: PluginEndpointDiscovery;
database: PluginDatabaseManager;
scheduler: PluginTaskScheduler;
}
/**
@@ -101,6 +103,7 @@ export const buildTechInsightsContext = async <
discovery,
database,
logger,
scheduler,
} = options;
const factRetrieverRegistry = new FactRetrieverRegistry(factRetrievers);
@@ -111,6 +114,7 @@ export const buildTechInsightsContext = async <
);
const factRetrieverEngine = await FactRetrieverEngine.create({
scheduler,
repository: persistenceContext.techInsightsStore,
factRetrieverRegistry,
factRetrieverContext: {
+2
View File
@@ -6,6 +6,7 @@
import { CheckResult } from '@backstage/plugin-tech-insights-common';
import { Config } from '@backstage/config';
import { DateTime } from 'luxon';
import { Duration } from 'luxon';
import { DurationLike } from 'luxon';
import { Logger } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
@@ -66,6 +67,7 @@ export type FactRetrieverContext = {
export type FactRetrieverRegistration = {
factRetriever: FactRetriever;
cadence?: string;
timeout?: Duration;
lifecycle?: FactLifecycle;
};
+8 -2
View File
@@ -13,8 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DateTime, DurationLike } from 'luxon';
import { DateTime, Duration, DurationLike } from 'luxon';
import { Config } from '@backstage/config';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Logger } from 'winston';
@@ -243,6 +242,13 @@ export type FactRetrieverRegistration = {
*/
cadence?: string;
/**
* A duration to determine how long the fact retriever should be allowed to run,
* defaults to 5 minutes.
*
*/
timeout?: Duration;
/**
* Fact lifecycle definition
*
+1 -13
View File
@@ -6259,11 +6259,6 @@
resolved "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
"@types/node-cron@^3.0.0", "@types/node-cron@^3.0.1":
version "3.0.1"
resolved "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.1.tgz#e01a874d4c2aa1a02ebc64cfd1cd8ebdbad7a996"
integrity sha512-BkMHHonDT8NJUE/pQ3kr5v2GLDKm5or9btLBoBx4F2MB2cuqYC748LYMDC55VlrLI5qZZv+Qgc3m4P3dBPcmeg==
"@types/node-fetch@^2.5.0", "@types/node-fetch@^2.5.12", "@types/node-fetch@^2.5.7":
version "2.5.12"
resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.12.tgz#8a6f779b1d4e60b7a57fb6fd48d84fb545b9cc66"
@@ -17997,7 +17992,7 @@ modify-values@^1.0.0:
resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022"
integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==
moment-timezone@^0.5.31, moment-timezone@^0.5.x:
moment-timezone@^0.5.x:
version "0.5.34"
resolved "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c"
integrity sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg==
@@ -18272,13 +18267,6 @@ node-cache@^5.1.2:
dependencies:
clone "2.x"
node-cron@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/node-cron/-/node-cron-3.0.0.tgz#b33252803e430f9cd8590cf85738efa1497a9522"
integrity sha512-DDwIvvuCwrNiaU7HEivFDULcaQualDv7KoNlB/UU1wPW0n1tDEmBJKhEIE6DlF2FuoOHcNbLJ8ITL2Iv/3AWmA==
dependencies:
moment-timezone "^0.5.31"
node-dir@^0.1.17:
version "0.1.17"
resolved "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5"