Skip to content

Commit

Permalink
Merge pull request #27 from France-ioi/opencv
Browse files Browse the repository at this point in the history
Add OpenCV support as remote lib
  • Loading branch information
SebastienTainon authored Nov 15, 2024
2 parents 43de101 + a4d28fc commit 25980df
Show file tree
Hide file tree
Showing 9 changed files with 486 additions and 8 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.env.local
.env.test
node_modules
dist
dist
cache
Empty file added cache/.gitkeep
Empty file.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"license": "MIT",
"dependencies": {
"@hapi/hapi": "^20.2.2",
"@hapi/inert": "^7.1.0",
"dockerode": "^4.0.0",
"fp-ts": "^2.12.3",
"got": "^11",
"hapi-plugin-websocket": "^2.4.8",
Expand All @@ -24,6 +26,7 @@
"devDependencies": {
"@cucumber/cucumber": "^9.0.1",
"@types/chai-subset": "^1.3.3",
"@types/dockerode": "^3.3.23",
"@types/hapi__hapi": "^20.0.12",
"@types/mysql": "^2.15.21",
"@types/node": "^20.9.0",
Expand Down
4 changes: 2 additions & 2 deletions src/error_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ export class InvalidInputError extends Error {
export class PlatformInteractionError extends Error {
}

export function isResponseBoom(response: any): response is Boom {
export function isResponseBoom(response: unknown): response is Boom {
return (response as Boom).isBoom && (response as Boom).isServer;
}

export class ErrorHandler {
public handleError(e: any, h: Hapi.ResponseToolkit): ResponseValue {
public handleError(e: unknown, h: Hapi.ResponseToolkit): ResponseValue {
if (e instanceof InvalidInputError) {
return h
.response({error: 'Incorrect input arguments.', message: String(e)})
Expand Down
172 changes: 172 additions & 0 deletions src/lib/opencv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import {RemoteLib} from './remote_lib';
import Docker from 'dockerode';
import fs, {createWriteStream} from 'fs';
import {randomUUID} from 'crypto';
import got from 'got';
import stream from 'stream';
import path from 'path';

const OPENCV_IMAGE = 'hdgigante/python-opencv:4.9.0-alpine';
const CACHE_FOLDER = 'cache';
const ALLOWED_CALLS = ['imread', 'cvtColor', 'flip', 'rotate', 'blur', 'resize', 'Canny', 'imwrite'];

export class ImageCache {
generateIdentifier(): string {
return randomUUID() + '.jpg';
}

getCachePath(key: string): string {
return `${CACHE_FOLDER}/${key}`;
}
}

interface FileArg {
fileType: string,
fileUrl: string,
}

function isFileArg(arg: unknown): arg is FileArg {
return 'object' === typeof arg && null !== arg && 'fileType' in arg;
}

const imageCache = new ImageCache();

class EchoStream extends stream.Writable {
private content = '';
_write(chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
// eslint-disable-next-line
this.content += chunk.toString();
callback();
}

getContent(): string {
return this.content;
}
}

export class OpenCvLib extends RemoteLib {
private dockerInstance: Docker|null = null;

async executeRemoteCall(callName: string, args: unknown[]): Promise<{fileType: 'image', fileUrl: string}> {
if (-1 === ALLOWED_CALLS.indexOf(callName)) {
throw new TypeError(`Unauthorized OpenCV call name: ${callName}`);
}

if (!this.dockerInstance) {
this.initDockerInstance();
}

const dockerInstance = this.dockerInstance as Docker;

const images = await dockerInstance.listImages();
if (!images.find(image => image.RepoTags && image.RepoTags.includes(OPENCV_IMAGE))) {
await new Promise(resolve => {
void dockerInstance.pull(OPENCV_IMAGE, (err: unknown, stream: NodeJS.ReadableStream) => {
dockerInstance.modem.followProgress(stream, resolve);
});
});
}

const resultImageName = imageCache.generateIdentifier();

const program = `import cv2
result = cv2.${callName}(${await this.formatArguments(args)})
cv2.imwrite('${resultImageName}', result)`;

fs.writeFileSync(`${CACHE_FOLDER}/app.py`, program);

const outputStream = new EchoStream();
const cacheFolder = path.join(__dirname, '../../', CACHE_FOLDER);

const data = await dockerInstance.run(OPENCV_IMAGE, ['python3', 'app.py'], outputStream, {
HostConfig: {
Binds: [`${cacheFolder}:/opt/build`],
AutoRemove: true,
},
}) as {StatusCode: number}[];

if (0 !== data[0].StatusCode) {
throw new Error(outputStream.getContent().trim());
}

return {
fileType: 'image',
fileUrl: '/image-cache/' + resultImageName,
};
}

async formatArguments(args: unknown[]): Promise<string> {
const argsString = [];
for (const arg of args) {
argsString.push(await this.convertArgument(arg));
}

return `${argsString.join(', ')}`;
}

async convertArgument(arg: unknown): Promise<string> {
if ('object' === typeof arg && isFileArg(arg)) {
const fileName = arg.fileUrl.split('/').pop() ?? '';
if (!fileName.match(/^[a-zA-Z0-9-]+\.[a-z]+$/)) {
throw new TypeError(`File name "${fileName}" does not match the required pattern`);
}

return `cv2.imread("${fileName ?? ''}")`;
}
if ('string' === typeof arg) {
if (this.isImageUrl(arg)) {
const imagePath = imageCache.generateIdentifier();
await this.downloadImage(arg, imageCache.getCachePath(imagePath));

return `"${imagePath}"`;
}

// Escape quotes to prevent Python custom code injection
// eslint-disable-next-line
arg = arg.replace(/"/g, '\\\"');

return `"${String(arg)}"`;
}

if (Array.isArray(arg)) {
const convertedArgs = await Promise.all(arg.map(element => this.convertArgument(element)));

return `(${convertedArgs.join(', ')})`;
}
if ('number' === typeof arg) {
return `${Number(arg)}`;
}

throw new TypeError(`Unaccepted argument type: ${String(arg)}`);
}

isImageUrl(arg: unknown): boolean {
return 'string' === typeof arg && !!arg.match(/^https?:\/\/.+\.(jpg|png|gif|webp|avi)$/);
}

initDockerInstance(): void {
this.dockerInstance = new Docker();
}

downloadImage(url: string, filePath: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const downloadStream = got.stream(url);
const fileWriterStream = createWriteStream(filePath);

downloadStream
.on('error', error => {
reject(`Download failed: ${error.message}`);
});

fileWriterStream
.on('error', error => {
reject(`Could not write file to system: ${error.message}`);
})
.on('finish', () => {
resolve();
});

downloadStream.pipe(fileWriterStream);
});
}
}
4 changes: 4 additions & 0 deletions src/lib/remote_lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

export abstract class RemoteLib {
public abstract executeRemoteCall(callName: string, args: unknown[]): Promise<unknown>;
}
47 changes: 47 additions & 0 deletions src/lib/remote_lib_executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {OpenCvLib} from './opencv';
import {RemoteLib} from './remote_lib';
import {decode} from '../util';
import {pipe} from 'fp-ts/function';
import * as D from 'io-ts/Decoder';

const availableLibraries: {[libraryName: string]: RemoteLib} = {
opencv: new OpenCvLib(),
};

export const remoteCallPayloadDecoder = pipe(
D.struct({
libraryName: D.string,
callName: D.string,
args: D.UnknownArray,
}),
);

export async function executeRemoteCall(libraryName: string, callName: string, args: unknown[]): Promise<unknown> {
if (!(libraryName in availableLibraries)) {
throw new Error(`Unknown remote lib name: ${libraryName}`);
}

return await availableLibraries[libraryName].executeRemoteCall(callName, args);
}

export async function decodeAndExecuteRemoteCall(remoteCallPayload: unknown): Promise<{success: boolean, result?: unknown, error?: string}> {
const taskGraderWebhookParams = decode(remoteCallPayloadDecoder)(remoteCallPayload);

try {
const result = await executeRemoteCall(
taskGraderWebhookParams.libraryName,
taskGraderWebhookParams.callName,
taskGraderWebhookParams.args
);

return {
success: true,
result,
};
} catch (e) {
return {
success: false,
error: String(e),
};
}
}
28 changes: 28 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import {receiveSubmissionResultsFromTaskGrader} from './grader_webhook';
import {longPollingHandler} from './long_polling';
import log from 'loglevel';
import HAPIWebSocket from 'hapi-plugin-websocket';
import Inert from '@hapi/inert';
import {remoteExecutionProxyHandler} from './remote_execution_proxy';
import appConfig from './config';
import {decode} from './util';
import {decodeAndExecuteRemoteCall} from './lib/remote_lib_executor';

export async function init(): Promise<Server> {
const server = Hapi.server({
Expand All @@ -30,6 +32,7 @@ export async function init(): Promise<Server> {
});

await server.register(HAPIWebSocket);
await server.register(Inert);

server.route({
method: 'GET',
Expand Down Expand Up @@ -136,6 +139,31 @@ export async function init(): Promise<Server> {
},
});

server.route({
method: 'POST',
path: '/remote-lib-call',
options: {
handler: async (request, h) => {
const {success, result, error} = await decodeAndExecuteRemoteCall(request.payload);

return h.response({
success,
...(success ? {result} : {error}),
});
}
}
});

server.route({
method: 'GET',
path: '/image-cache/{param*}',
handler: {
directory: {
path: 'cache'
}
}
});

const errorHandler = new ErrorHandler();

server.ext('onPreResponse', (request, h): ReturnValue => {
Expand Down
Loading

0 comments on commit 25980df

Please sign in to comment.