Skip to content

Commit

Permalink
chore: make some cli operations available through a maintenance endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
sneko committed Feb 19, 2024
1 parent a786e15 commit a106c0c
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 18 deletions.
4 changes: 4 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { program } from '@etabli/src/cli/program';

// This would break imports from Next.js so isolating it to be run only by CLI
program.parse();
4 changes: 1 addition & 3 deletions src/cli.ts → src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { cleanLlmSystem, ingestInitiativeListToLlmSystem, ingestToolListToLlmSys
import { enhanceRepositoriesIntoDatabase, formatRepositoriesIntoDatabase, saveRepositoryListFile } from '@etabli/src/features/repository';
import { enhanceToolsIntoDatabase, formatToolsIntoDatabase, saveToolCsvFile } from '@etabli/src/features/tool';

const program = new Command();
export const program = new Command();

program.name('etabli').description('CLI to some deal with Établi project').version('0.0.0');

Expand Down Expand Up @@ -179,5 +179,3 @@ cache
.action(async () => {
console.log('cache.clear');
});

program.parse();
4 changes: 4 additions & 0 deletions src/models/entities/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export const unexpectedErrorError = new UnexpectedError('unexpectedError', 'unex

// Validations
export const unexpectedDomainRedirectionError = new BusinessError('unexpectedDomainRedirection', 'unexpected domain redirection');
export const unexpectedCliMaintenanceCommandError = new BusinessError(
'unexpectedCliMaintenanceCommand',
'unexpected command passed for maintenance through cli'
);

// LLM
export const tokensReachTheLimitError = new BusinessError('tokensReachTheLimit', 'too many tokens according to the model limit');
71 changes: 71 additions & 0 deletions src/pages/api/maintenance/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Command } from '@commander-js/extra-typings';
import { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';

import { program } from '@etabli/src/cli/program';
import { unexpectedCliMaintenanceCommandError } from '@etabli/src/models/entities/errors';
import { apiHandlerWrapper } from '@etabli/src/utils/api';
import { assertMaintenanceOperationAuthenticated } from '@etabli/src/utils/maintenance';

export const HandlerBodySchema = z
.object({
command: z.string().min(1).max(1000),
})
.strict();
export type HandlerBodySchemaType = z.infer<typeof HandlerBodySchema>;

const commandsPrefixesWhitelist: string[] = [
'domain fetch',
'domain format',
'domain enhance',
'repository fetch',
'repository format',
'repository enhance',
'tool fetch',
'tool format',
'tool enhance',
'llm initialize',
'llm initiatives',
'llm tools',
'initiative infer',
'initiative feed',
];

// This endpoint allows running cron job commands (that are defined in the CLI) without connecting to the host (note also it would imply from us to make another build in addition to the Next.js build)
// (it avoids maintaining 2 implementations when changing parameters and so on)
export async function handler(req: NextApiRequest, res: NextApiResponse) {
assertMaintenanceOperationAuthenticated(req);

const body = HandlerBodySchema.parse(req.body);

// It could be risky to expose the whole CLI due to future implementations, so whitelisting commands we allow
// Note: `commander.js` was not made to reuse easily subcommands to create another program, so we hack a bit we filter based on raw strings
if (!commandsPrefixesWhitelist.some((commandPrefix) => body.command.startsWith(commandPrefix))) {
throw unexpectedCliMaintenanceCommandError;
}

// [WORKAROUND] We cannot check if it matches a command or not, in case none it terminates the running process
// We can add a catcher but we cannot custom the response since global to the instance... so using a clone to wait for it
let commandFound = true;

const cloneProgram = new Command();
for (const command of program.commands) {
cloneProgram.addCommand(command);
}

cloneProgram.on('command:*', () => {
commandFound = false;
});

await cloneProgram.parseAsync(body.command.split(' '), { from: 'user' });

if (!commandFound) {
throw unexpectedCliMaintenanceCommandError;
}

res.send(`the following command has been executed with success:\n${body.command}`);
}

export default apiHandlerWrapper(handler, {
restrictMethods: ['POST'],
});
20 changes: 5 additions & 15 deletions src/pages/api/maintenance/jobs/replay.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import createHttpError from 'http-errors';
import { NextApiRequest, NextApiResponse } from 'next';
import { JobWithMetadata } from 'pg-boss';
import { z } from 'zod';
Expand All @@ -7,16 +6,10 @@ import { BusinessError } from '@etabli/src/models/entities/errors';
import { MaintenanceDataSchemaType, MaintenanceWrapperDataSchema } from '@etabli/src/models/jobs/maintenance';
import { getBossClientInstance } from '@etabli/src/server/queueing/client';
import { apiHandlerWrapper } from '@etabli/src/utils/api';

const maintenanceApiKey = process.env.MAINTENANCE_API_KEY;
import { assertMaintenanceOperationAuthenticated } from '@etabli/src/utils/maintenance';

export const replayableJobStates: JobWithMetadata['state'][] = ['completed', 'expired', 'cancelled', 'failed'];

export function isAuthenticated(apiKeyHeader?: string): boolean {
// If the maintenance api key is not defined on the server we prevent executing operations
return !!maintenanceApiKey && maintenanceApiKey === apiKeyHeader;
}

export const HandlerBodySchema = z
.object({
jobId: z.string().uuid(),
Expand All @@ -25,12 +18,7 @@ export const HandlerBodySchema = z
export type HandlerBodySchemaType = z.infer<typeof HandlerBodySchema>;

export async function handler(req: NextApiRequest, res: NextApiResponse) {
// Check the originator has the maintenance secret
if (!isAuthenticated((req.headers as any)['x-api-key'])) {
console.log('someone is trying to trigger a job replay without being authenticated');

throw new createHttpError.Unauthorized(`invalid api key`);
}
assertMaintenanceOperationAuthenticated(req);

const body = HandlerBodySchema.parse(req.body);

Expand Down Expand Up @@ -85,4 +73,6 @@ export async function handler(req: NextApiRequest, res: NextApiResponse) {
res.send(`The job has been replayed with the id "${newJobId}"`);
}

export default apiHandlerWrapper(handler);
export default apiHandlerWrapper(handler, {
restrictMethods: ['POST'],
});
18 changes: 18 additions & 0 deletions src/utils/maintenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import createHttpError from 'http-errors';
import { NextApiRequest } from 'next';

const maintenanceApiKey = process.env.MAINTENANCE_API_KEY;

export function isAuthenticated(apiKeyHeader?: string): boolean {
// If the maintenance api key is not defined on the server we prevent executing operations
return !!maintenanceApiKey && maintenanceApiKey === apiKeyHeader;
}

// Check the originator has the maintenance secret
export function assertMaintenanceOperationAuthenticated(req: NextApiRequest) {
if (!isAuthenticated((req.headers as any)['x-api-key'])) {
console.log('someone is trying to trigger a maintenance operation without being authenticated');

throw new createHttpError.Unauthorized(`invalid api key`);
}
}

0 comments on commit a106c0c

Please sign in to comment.