Skip to content

Commit

Permalink
Merge pull request #47 from kontent-ai/decouple_status
Browse files Browse the repository at this point in the history
Implement importing the function from the statusImpl if it exists,
  • Loading branch information
winklertomas committed May 4, 2023
2 parents ff5ee97 + a99b07b commit 0288dcc
Show file tree
Hide file tree
Showing 11 changed files with 177 additions and 42 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,31 @@ The supported commands are divided into groups according to their target, at thi
* `backup --action [backup|restore|clean]` - This command enables you to use [Kontent.ai backup manager](https://github.com/kontent-ai/backup-manager-js)
* The purpose of this tool is to backup & restore [Kontent.ai projects](https://kontent.ai/). This project uses CM API to both get & restore data.

### Custom implementation of reading/saving status of migrations

You might want to implement your way to store information about migrations status. For instance, you would like to save it into DB such as MongoDB, Firebase, etc,... and not use the default JSON file. Therefore, we provide you with an option to implement functions `readStatus` and `saveStatus`. To do so, create a new file called `plugins.js` at the root of your migrations project, and implement mentioned functions there. To fit into the required declarations, you can use the template below:

```js
//plugins.js
exports.saveStatus = async (data) => {}

exports.readStatus = async () => {}
```
> Note: Both functions must be implemented.
It is also possible to use Typescript. We have prepared types `SaveStatusType` and `ReadStatusType` to typesafe your functions. To create plugins in Typescript, create a file `plugins.ts` and implement your functions there. We suggest using and implementing the template below:

```ts
//plugins.ts
import type { ReadStatusType, SaveStatusType } from "@kontent-ai/cli";

export const saveStatus: SaveStatusType = async (data: string) => {}

export const readStatus: ReadStatusType = async () => {}
```

> Note: Don't forget to transpile `plugins.ts` into `plugins.js` otherwise your plugins will not work.
### Debugging

By default, we do not provide any additional logs from the HttpService. If you require these logs, you can change this behavior by using (option `--log-http-service-errors-to-console`).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kontent-ai/cli",
"version": "0.7.0",
"version": "0.7.1",
"description": "Command line interface tool that can be used for generating and runningKontent.ai migration scripts",
"main": "./lib/index.js",
"types": "./lib/types/index.d.ts",
Expand Down
11 changes: 7 additions & 4 deletions src/cmds/migration/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { getDuplicates, getSuccessfullyExecutedMigrations, getMigrationFilepath,
import { fileExists, getFileWithExtension, isAllowedExtension } from '../../utils/fileUtils';
import { environmentConfigExists, getEnvironmentsConfig } from '../../utils/environmentUtils';
import { createManagementClient } from '../../managementClientFactory';
import { loadMigrationsExecutionStatus } from '../../utils/statusManager';
import { getPluginsFilePath, loadMigrationsExecutionStatus } from '../../utils/statusManager';
import { IMigration } from '../../models/migration';
import { IRange } from '../../models/range';
import { loadStatusPlugin } from '../../utils/status/statusPlugin';

const runMigrationCommand: yargs.CommandModule = {
command: 'run',
Expand Down Expand Up @@ -124,13 +125,15 @@ const runMigrationCommand: yargs.CommandModule = {
apiKey = environments[argv.environment].apiKey || argv.apiKey;
}

const plugin = fileExists(getPluginsFilePath()) ? await loadStatusPlugin(getPluginsFilePath().slice(0, -3) + '.js') : undefined;

const apiClient = createManagementClient({
projectId,
apiKey,
logHttpServiceErrorsToConsole,
});

loadMigrationsExecutionStatus();
await loadMigrationsExecutionStatus(plugin?.readStatus ?? null);

if (runAll || runRange) {
let migrationsToRun = await loadMigrationFiles();
Expand All @@ -155,7 +158,7 @@ const runMigrationCommand: yargs.CommandModule = {
const sortedMigrationsToRun = migrationsToRun.sort(orderComparator);
let executedMigrationsCount = 0;
for (const migration of sortedMigrationsToRun) {
const migrationResult = await runMigration(migration, apiClient, projectId);
const migrationResult = await runMigration(migration, apiClient, projectId, plugin?.saveStatus ?? null);

if (migrationResult > 0) {
if (!continueOnError) {
Expand All @@ -176,7 +179,7 @@ const runMigrationCommand: yargs.CommandModule = {
module: migrationModule,
};

migrationsResults = await runMigration(migration, apiClient, projectId);
migrationsResults = await runMigration(migration, apiClient, projectId, plugin?.saveStatus ?? null);
}

process.exit(migrationsResults);
Expand Down
4 changes: 2 additions & 2 deletions src/tests/cmds/run/runMigration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ const migrations: IMigration[] = [
},
];

jest.spyOn(statusManager, 'markAsCompleted').mockImplementation(() => {});
jest.spyOn(statusManager, 'loadMigrationsExecutionStatus').mockImplementation(() => {});
jest.spyOn(statusManager, 'markAsCompleted').mockImplementation(async () => {});
jest.spyOn(statusManager, 'loadMigrationsExecutionStatus').mockImplementation(async () => {});
jest.spyOn(migrationUtils, 'loadMigrationFiles').mockReturnValue(
new Promise((resolve) => {
resolve(migrations);
Expand Down
43 changes: 32 additions & 11 deletions src/tests/statusManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { markAsCompleted, wasSuccessfullyExecuted } from '../utils/statusManager';
import { readFileSync } from 'fs';
import * as statusManager from '../utils/statusManager';
import * as fileUtils from '../utils/fileUtils';
import * as fs from 'fs';
import * as path from 'path';
import { IStatus } from '../models/status';

const readStatusFile = (): IStatus => {
const statusFilepath = path.join(process.cwd(), 'status.json');

const fileContent = readFileSync(statusFilepath).toString();
const fileContent = fs.readFileSync(statusFilepath).toString();
return JSON.parse(fileContent);
};

Expand All @@ -15,35 +16,55 @@ describe('Status manager', () => {
jest.spyOn(Date, 'now').mockImplementation(() => 1575939660000);
});

it('Project has success status in status manager file', () => {
it('Project has success status in status manager file', async () => {
const projectId = 'project1';
const migrationName = 'migration1';
markAsCompleted(projectId, migrationName, 1);
await statusManager.markAsCompleted(projectId, migrationName, 1, null);

const statusFile = readStatusFile();
const status = statusFile[projectId][0];

expect(status).toMatchSnapshot();
});

it('Not executed migration is not present in status file', () => {
it('Not executed migration is not present in status file', async () => {
const project1Id = 'project1';
const project2Id = 'project2';
const migration1Name = 'migration1';
markAsCompleted(project1Id, migration1Name, 1);
await statusManager.markAsCompleted(project1Id, migration1Name, 1, null);

const projectMigrationStatus = wasSuccessfullyExecuted(migration1Name, project2Id);
const projectMigrationStatus = statusManager.wasSuccessfullyExecuted(migration1Name, project2Id);

expect(projectMigrationStatus).toBe(false);
});

it('Executed migration is present in status file', () => {
it('Executed migration is present in status file', async () => {
const project2Id = 'project2';
const migration2Name = 'migration2';
markAsCompleted(project2Id, migration2Name, 1);
await statusManager.markAsCompleted(project2Id, migration2Name, 1, null);

const projectMigrationStatus = wasSuccessfullyExecuted(project2Id, migration2Name);
const projectMigrationStatus = statusManager.wasSuccessfullyExecuted(project2Id, migration2Name);

expect(projectMigrationStatus).toBe(false);
});

it('loadMigrationsExecutionStatus to be called with plugins', async () => {
jest.spyOn(fileUtils, 'fileExists').mockReturnValue(true);

const readStatusMocked = jest.fn().mockResolvedValue({});

await statusManager.loadMigrationsExecutionStatus(readStatusMocked);

expect(readStatusMocked).toHaveBeenCalled();
});

it('MarkAsCompleted to be called with plugins', async () => {
jest.spyOn(fileUtils, 'fileExists').mockReturnValue(true);

const saveStatusMocked = jest.fn().mockImplementation(() => Promise.resolve());

await statusManager.markAsCompleted('', 'testMigration', 1, saveStatusMocked);

expect(saveStatusMocked).toHaveBeenCalled();
});
});
35 changes: 35 additions & 0 deletions src/tests/statusPlugin/statusPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { loadStatusPlugin } from '../../utils/status/statusPlugin';

describe('status plugin tests', () => {
const saveStatus = () => {};
const readStatus = () => ({});

jest.mock(
'plugin',
() => ({
saveStatus,
readStatus,
}),
{ virtual: true }
);

jest.mock(
'malformedPlugin',
() => ({
save: saveStatus,
read: readStatus,
}),
{ virtual: true }
);

it('test correct plugin', async () => {
const functions = await loadStatusPlugin('plugin');

expect(functions.saveStatus).toEqual(saveStatus);
expect(functions.readStatus).toEqual(readStatus);
});

it('test malformed plugin', async () => {
expect(loadStatusPlugin('malformedPlugin')).rejects.toThrow();
});
});
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { ManagementClient } from '@kontent-ai/management-sdk';
import { IStatus } from '../models/status';

export declare interface MigrationModule {
readonly order: number | Date;
run(apiClient: ManagementClient): Promise<void>;
}

export type SaveStatusType = (data: string) => Promise<void>;
export type ReadStatusType = () => Promise<IStatus>;
10 changes: 1 addition & 9 deletions src/utils/fileUtils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import fs, { Dirent, PathLike } from 'fs';
import { getMigrationDirectory } from './migrationUtils';
import fs, { PathLike } from 'fs';
import * as path from 'path';

export const listFiles = (fileExtension: string): Dirent[] => {
return fs
.readdirSync(getMigrationDirectory(), { withFileTypes: true })
.filter((f) => f.isFile())
.filter((f) => f.name.endsWith(fileExtension));
};

export const fileExists = (filePath: PathLike): boolean => {
return fs.existsSync(filePath);
};
Expand Down
19 changes: 13 additions & 6 deletions src/utils/migrationUtils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { ManagementClient, SharedModels } from '@kontent-ai/management-sdk';
import chalk from 'chalk';
import path from 'path';
import fs from 'fs';
import { listFiles } from './fileUtils';
import fs, { Dirent } from 'fs';
import { TemplateType } from '../models/templateType';
import { MigrationModule } from '../types';
import { IMigration } from '../models/migration';
import { markAsCompleted, wasSuccessfullyExecuted } from './statusManager';
import { formatDateForFileName } from './dateUtils';
import { StatusPlugin } from './status/statusPlugin';

const listMigrationFiles = (fileExtension: string): Dirent[] => {
return fs
.readdirSync(getMigrationDirectory(), { withFileTypes: true })
.filter((f) => f.isFile())
.filter((f) => f.name.endsWith(fileExtension));
};

export const getMigrationDirectory = (): string => {
const migrationDirectory = 'Migrations';
Expand Down Expand Up @@ -41,14 +48,14 @@ export const saveMigrationFile = (migrationName: string, migrationData: string,
return migrationFilepath;
};

export const runMigration = async (migration: IMigration, client: ManagementClient, projectId: string): Promise<number> => {
export const runMigration = async (migration: IMigration, client: ManagementClient, projectId: string, saveStatusFromPlugin: StatusPlugin['saveStatus'] | null): Promise<number> => {
console.log(`Running the ${migration.name} migration.`);

let isSuccess = true;

try {
await migration.module.run(client).then(() => {
markAsCompleted(projectId, migration.name, migration.module.order);
await migration.module.run(client).then(async () => {
await markAsCompleted(projectId, migration.name, migration.module.order, saveStatusFromPlugin);
});
} catch (e) {
console.error(chalk.redBright('An error occurred while running migration:'), chalk.yellowBright(migration.name), chalk.redBright('see the output from running the script.'));
Expand Down Expand Up @@ -182,7 +189,7 @@ export const loadModule = async (migrationFile: string): Promise<MigrationModule
export const loadMigrationFiles = async (): Promise<IMigration[]> => {
const migrations: IMigration[] = [];

const files = listFiles('.js');
const files = listMigrationFiles('.js');

for (const file of files) {
migrations.push({ name: file.name, module: await loadModule(file.name) });
Expand Down
19 changes: 19 additions & 0 deletions src/utils/status/statusPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ReadStatusType, SaveStatusType } from '../../types';

export interface StatusPlugin {
saveStatus: SaveStatusType;
readStatus: ReadStatusType;
}

export const loadStatusPlugin = async (path: string): Promise<StatusPlugin> => {
const pluginModule = await import(path);

if (!('saveStatus' in pluginModule && typeof pluginModule.saveStatus === 'function') || !('readStatus' in pluginModule && typeof pluginModule.readStatus === 'function')) {
throw new Error('Invalid plugin: does not implement saveStatus or readStatus functions');
}

return {
saveStatus: pluginModule.saveStatus,
readStatus: pluginModule.readStatus,
};
};

0 comments on commit 0288dcc

Please sign in to comment.