From bf2644d18ef0cb05acd3882c0a7e851bc3f6872d Mon Sep 17 00:00:00 2001 From: Mohamed AZIKIOU <mazikiou@takima.fr> Date: Tue, 12 Jul 2022 14:46:33 +0000 Subject: [PATCH] feat: submit code to api --- .../src/core/api.service.ts | 17 ++- .../deadlock-extension/src/core/config.ts | 2 + .../deadlock-extension/src/core/controller.ts | 7 +- .../src/core/mission/missionDevContainer.ts | 3 +- .../src/core/mission/model/userMission.ts | 16 +- .../deadlock-extension/src/extension.ts | 2 +- .../deadlock-extension/src/model/attempt.ts | 68 +++++++++ .../deadlock-extension/src/model/mission.ts | 74 +++++++--- .../src/recorder/utils/gitea.ts | 35 +++-- .../src/view/CommandTree.ts | 138 ++++++++++++------ .../src/view/startedMissionsView.ts | 4 +- 11 files changed, 275 insertions(+), 91 deletions(-) create mode 100644 deadlock-plugins/deadlock-extension/src/model/attempt.ts diff --git a/deadlock-plugins/deadlock-extension/src/core/api.service.ts b/deadlock-plugins/deadlock-extension/src/core/api.service.ts index 73686796..9d029564 100644 --- a/deadlock-plugins/deadlock-extension/src/core/api.service.ts +++ b/deadlock-plugins/deadlock-extension/src/core/api.service.ts @@ -3,6 +3,7 @@ import { API_QUERY_REFERER, API_URL } from '../config'; import { GiteaPublicProperties } from '../model/giteaPublicProperties.model'; import { SshKeyPair } from '../model/sshKeyPair.model'; import { extensionError as error } from '../recorder/utils/log'; +import { Attempt } from '../model/attempt'; import Controller from './controller'; import ExtensionStore from './extensionStore'; import KeycloakOAuth2DeviceFlowConnection from './keycloakOAuth2DeviceFlowConnection'; @@ -168,13 +169,19 @@ export default class ApiService { .then((res) => res.data); } - async startUpdateWorkTime(): Promise<MissionUser> { + async startUpdateWorkTime() { + return this.axiosInstance + .post(`users/${(await this.getCurrentUser()).id}/missions/${UserMission.getInstance().missionId}/worktime`, { + action: 'START', + }) + .then((res) => res.data); + } + + async submitAttempt(attempt: Attempt): Promise<MissionUser> { return this.axiosInstance .post<MissionUser>( - `users/${(await this.getCurrentUser()).id}/missions/${UserMission.getInstance().missionId}/worktime`, - { - action: 'START', - }, + `/users/${(await this.getCurrentUser()).id}/missions/${UserMission.getInstance().missionId}/solve/desktop`, + attempt, ) .then((res) => res.data); } diff --git a/deadlock-plugins/deadlock-extension/src/core/config.ts b/deadlock-plugins/deadlock-extension/src/core/config.ts index 7c9e0f06..1fa0a2c7 100644 --- a/deadlock-plugins/deadlock-extension/src/core/config.ts +++ b/deadlock-plugins/deadlock-extension/src/core/config.ts @@ -17,6 +17,8 @@ export const GITEA_PATH_IC = '/project'; export const MISSION_PATH_IC = `${homedir()}/mission`; +export const MISSION_FILE_IC = join('/', 'deadlock', 'challenge.yaml'); + export const DEADLOCK_WORKDIR_PATH = onContainer ? '/deadlock' : deadlockConfigPath; export const USER_MISSION_PATH = join('/home/config/user-challenge.json'); diff --git a/deadlock-plugins/deadlock-extension/src/core/controller.ts b/deadlock-plugins/deadlock-extension/src/core/controller.ts index f61a10f0..0cd96e52 100644 --- a/deadlock-plugins/deadlock-extension/src/core/controller.ts +++ b/deadlock-plugins/deadlock-extension/src/core/controller.ts @@ -101,6 +101,7 @@ export default class Controller { const missionId = queryParams.get('missionId'); const missionVersion = queryParams.get('missionVersion'); const userId = queryParams.get('userId') ?? undefined; + const storyId = queryParams.get('storyId') ?? undefined; log('Opening link', uri); switch (action) { @@ -108,7 +109,7 @@ export default class Controller { if (!missionId || !missionVersion) { window.showErrorMessage('Identifiant ou version de la mission incorrect'); } else { - that.launchMission(missionId, missionVersion, userId); + that.launchMission(missionId, missionVersion, userId, storyId); } break; @@ -162,7 +163,7 @@ export default class Controller { commands.executeCommand(openUrlInBrowserCommand.command, Uri.parse(url)); } - public async launchMission(missionId: string, missionVersion: string, revieweeId?: string) { + public async launchMission(missionId: string, missionVersion: string, revieweeId?: string, storyId?: string) { window.showInformationMessage(`vous lancez la mission ${missionId}`); const hadBeenConnected = (await this._extensionStore.getAccessToken()) !== undefined; @@ -195,7 +196,7 @@ export default class Controller { } } - const missionDevcontainer = new MissionDevContainer(missionId, missionVersion, revieweeId); + const missionDevcontainer = new MissionDevContainer(missionId, missionVersion, revieweeId, storyId); try { await missionDevcontainer.open(); diff --git a/deadlock-plugins/deadlock-extension/src/core/mission/missionDevContainer.ts b/deadlock-plugins/deadlock-extension/src/core/mission/missionDevContainer.ts index a4abb9a0..13d3329e 100644 --- a/deadlock-plugins/deadlock-extension/src/core/mission/missionDevContainer.ts +++ b/deadlock-plugins/deadlock-extension/src/core/mission/missionDevContainer.ts @@ -40,6 +40,7 @@ export class MissionDevContainer { private readonly missionId: string, private readonly missionVersion: string, private readonly revieweeId?: string, + private readonly storyId?: string, ) { let prefix: string; if (revieweeId) { @@ -84,7 +85,7 @@ export class MissionDevContainer { private async createUserChallengeJsonFile() { try { - await UserMission.writeFile(this.missionId, this.missionVersion, this.revieweeId); + await UserMission.writeFile(this.missionId, this.missionVersion, this.revieweeId, this.storyId); } catch (e) { extensionError(e); window.showErrorMessage(`Une erreur est survenue lors de la création du fichier userChallenge.json`); diff --git a/deadlock-plugins/deadlock-extension/src/core/mission/model/userMission.ts b/deadlock-plugins/deadlock-extension/src/core/mission/model/userMission.ts index 95676b09..359af9cb 100644 --- a/deadlock-plugins/deadlock-extension/src/core/mission/model/userMission.ts +++ b/deadlock-plugins/deadlock-extension/src/core/mission/model/userMission.ts @@ -9,6 +9,7 @@ import { getReviewedStudentWorkdirPath } from '../../utils/mission.utils'; interface UserMissionModel { missionId: string; missionVersion: string; + storyId?: string; reviewee?: User; } @@ -19,6 +20,7 @@ export default class UserMission implements UserMissionModel { this.missionId = userMission.missionId; this.missionVersion = userMission.missionVersion; this.reviewee = userMission.reviewee; + this.storyId = userMission.storyId; UserMission._instance = this; } @@ -43,6 +45,7 @@ export default class UserMission implements UserMissionModel { } public readonly missionId: string; + public readonly storyId?: string; public readonly missionVersion: string; public readonly reviewee?: User; @@ -50,21 +53,22 @@ export default class UserMission implements UserMissionModel { return join(revieweeId ? getReviewedStudentWorkdirPath(revieweeId) : missionWorkdir, missionId, '.config'); } - public static async writeFile(missionId: string, missionVersion: string, revieweeId?: string): Promise<void> { + public static async writeFile( + missionId: string, + missionVersion: string, + revieweeId?: string, + storyId?: string, + ): Promise<void> { const workdir = this.getMissionUserFolder(missionId, revieweeId); await createDirectories(workdir); - const user = await ApiService.getInstance().getCurrentUser(); - const username = (revieweeId ?? user.id).split('-').join(''); const path = join(workdir, 'user-challenge.json'); await writeFile( path, JSON.stringify( { - username, - email: `${username}@deadlock.io`, missionId, + storyId, missionVersion, - remoteGitUsername: username, reviewee: revieweeId ? await ApiService.getInstance().getUser(revieweeId) : undefined, } as UserMissionModel, null, diff --git a/deadlock-plugins/deadlock-extension/src/extension.ts b/deadlock-plugins/deadlock-extension/src/extension.ts index 01ee2de5..ba3d660e 100644 --- a/deadlock-plugins/deadlock-extension/src/extension.ts +++ b/deadlock-plugins/deadlock-extension/src/extension.ts @@ -19,7 +19,7 @@ export async function activate(context: ExtensionContext) { if (isDocker()) { commands.executeCommand('setContext', 'deadlock.inContainer', true); - window.registerTreeDataProvider('commandTree', new CommandTreeProvider(context)); + window.registerTreeDataProvider('commandTree', new CommandTreeProvider()); try { Recorder.instance.run(); } catch (e) { diff --git a/deadlock-plugins/deadlock-extension/src/model/attempt.ts b/deadlock-plugins/deadlock-extension/src/model/attempt.ts new file mode 100644 index 00000000..1c964409 --- /dev/null +++ b/deadlock-plugins/deadlock-extension/src/model/attempt.ts @@ -0,0 +1,68 @@ +export default class AttemptBuilder { + public id?: string; + public date?: number; + public files?: Map<string, string | null>; + public score = -1; + public readonly treated: false; + private _consoleContent: string; + private _exitCode? = 408; // timeout + + constructor( + public readonly userId: string, + public readonly missionId: string, + public readonly isASubmit: boolean, + public readonly storyId?: string, + ) { + this.treated = false; + this._consoleContent = ''; + } + + public addLog(log: string): void { + this._consoleContent += `[${Date.now()}] ${log}\n`; + } + + public setExitCode(exitCode?: number): void { + this._exitCode = exitCode; + } + + public get isSolved(): boolean { + return this._exitCode === 0; + } + + public get consoleContent(): string { + return this._consoleContent; + } + + build(commit?: string): Attempt { + const files = new Map<string, string | null>(); + files['exitCode'] = this._exitCode ?? null; + files['commit'] = commit; + return { + id: this.id, + date: this.date, + files: files, + score: this.score, + treated: this.treated, + consoleContent: this.consoleContent, + asubmit: this.isASubmit, + storyId: this.storyId, + solved: this.isSolved, + userId: this.userId, + missionId: this.missionId, + }; + } +} + +export interface Attempt { + storyId?: string; + score: number; + solved: boolean; + userId: string; + missionId: string; + treated: false; + id?: string; + date?: number; + consoleContent: string; + files?: Map<string, string | null>; + asubmit: boolean; +} diff --git a/deadlock-plugins/deadlock-extension/src/model/mission.ts b/deadlock-plugins/deadlock-extension/src/model/mission.ts index 3595f571..21df72d6 100644 --- a/deadlock-plugins/deadlock-extension/src/model/mission.ts +++ b/deadlock-plugins/deadlock-extension/src/model/mission.ts @@ -1,28 +1,62 @@ +import { readFileSync } from 'fs'; +import { parse } from 'yaml'; +import { MISSION_FILE_IC } from '../core/config'; + +type level = + | 'Jajarbinks' // le plus facile + | 'Ewok' + | 'Padawan' + | 'Jedi' + | 'Master'; // le plus difficile + interface Mission { - version: string; - name: string; - label: string; - description: string; - level: - | 'Jajarbinks' // le plus facile - | 'Ewok' - | 'Padawan' - | 'Jedi' - | 'Master'; // le plus difficile - type: 'DESKTOP'; - xp: Map<string, number>; - desktop: { + readonly version: string; + readonly name: string; + readonly label: string; + readonly description: string; + readonly level: level; + readonly type: 'DESKTOP'; + readonly xp: Map<string, number>; + readonly desktop: { scripts: MissionCommand[]; }; } -export class MissionCommand { - constructor( - public readonly name: string, - public readonly command: string, - public readonly description?: string, - public readonly type: 'submit' | 'setup' | 'run' = 'run', - ) {} +export class MissionHandler implements Mission { + public readonly version: string; + public readonly name: string; + public readonly label: string; + public readonly description: string; + public readonly level: level; + public readonly type: 'DESKTOP'; + public readonly desktop: { scripts: MissionCommand[] }; + public readonly xp: Map<string, number>; + private static _instance: MissionHandler; + + public static get instance(): MissionHandler { + if (!MissionHandler._instance) { + MissionHandler._instance = new MissionHandler(parse(readFileSync(MISSION_FILE_IC, 'utf-8'))); + } + return MissionHandler._instance; + } + + private constructor(mission: Mission) { + this.version = mission.version; + this.name = mission.name; + this.label = mission.label; + this.description = mission.description; + this.level = mission.level; + this.type = mission.type; + this.desktop = mission.desktop; + this.xp = mission.xp; + } +} + +export interface MissionCommand { + readonly name: string; + readonly command: string; + readonly description?: string; + type?: 'submit' | 'setup' | 'run'; } export default Mission; diff --git a/deadlock-plugins/deadlock-extension/src/recorder/utils/gitea.ts b/deadlock-plugins/deadlock-extension/src/recorder/utils/gitea.ts index 196e081e..f307b2b5 100644 --- a/deadlock-plugins/deadlock-extension/src/recorder/utils/gitea.ts +++ b/deadlock-plugins/deadlock-extension/src/recorder/utils/gitea.ts @@ -4,7 +4,7 @@ import { format } from 'date-fns'; import { execSync } from 'child_process'; import { existsSync, promises } from 'fs'; import { join } from 'path'; -import { GitError } from 'simple-git'; +import { CommitResult, GitError } from 'simple-git'; import { queue } from 'async'; import { extensionWarn, recorderError as error, recorderLog as log } from './log'; import { clearFilesExceptGit, copyGitUserFiles } from './workdir'; @@ -17,25 +17,35 @@ const gitQueue = queue(async (task: CallableFunction) => { await task(); }, 1); -async function pushOnCommitQueue(gitMission: GitMission, from: 'Run' | 'Auto' | 'HttpServer') { +async function pushOnCommitQueue( + gitMission: GitMission, + from: 'Run' | 'Auto' | 'HttpServer', + callback?: (commitResult: CommitResult) => void, +) { if (from === 'Run') { gitQueue.push(async () => { - await mergeMaster(gitMission); + const commitResult = await mergeMaster(gitMission); + callback?.(commitResult); }); } else if (gitQueue.length() == 0) { gitQueue.push(async () => { - await commitAndPushCode(gitMission); + const commitResult = await commitAndPushCode(gitMission); + callback?.(commitResult); }); } } -export async function pushOnCommitQueueIfNotReviewing(gitMission: GitMission, from: 'Run' | 'Auto' | 'HttpServer') { +export async function pushOnCommitQueueIfNotReviewing( + gitMission: GitMission, + from: 'Run' | 'Auto' | 'HttpServer', + callback?: (commitResult: CommitResult) => void, +) { if (!UserMission.getInstance().isReviewing()) { - await pushOnCommitQueue(gitMission, from); + await pushOnCommitQueue(gitMission, from, callback); } } -async function commitAndPushCode(gitMission: GitMission) { +async function commitAndPushCode(gitMission: GitMission): Promise<CommitResult> { try { await gitMission.checkout('live'); await clearFilesExceptGit(GITEA_PATH_IC); @@ -48,9 +58,10 @@ async function commitAndPushCode(gitMission: GitMission) { const currentDate = format(new Date(), "HH'H'mm'_'dd/LL/y"); await gitMission.addAll(); - await gitMission.commit(currentDate); + const commitResult = await gitMission.commit(currentDate); await gitMission.push(); log('Commit & push done.'); + return commitResult; } catch (e) { if (e instanceof GitError) { error(`[${e.task?.commands}] cannot commitAndPush`); @@ -63,7 +74,7 @@ async function commitAndPushCode(gitMission: GitMission) { } } -async function mergeMaster(gitMission: GitMission) { +async function mergeMaster(gitMission: GitMission): Promise<CommitResult> { try { log('Merging live to master'); @@ -71,10 +82,11 @@ async function mergeMaster(gitMission: GitMission) { await gitMission.checkout(); await gitMission.merge(['--squash', '-X', 'theirs', 'live']); - await gitMission.commit(currentDate); + const commitResult = await gitMission.commit(currentDate); await gitMission.push(); log('Merge to master done'); await gitMission.checkout('live'); + return commitResult; } catch (e) { if (e instanceof GitError) { error(`[${e.task?.commands}] cannot commitAndPush`); @@ -98,5 +110,6 @@ export async function getIgnorePatternFromIgnoreFile(ignoreFile: string): Promis const filesPatterns = lines .filter((line) => line.trimLeft().startsWith('#') || !!line.trim()) .map((line_1) => `**/${line_1}`); - return [...filesPatterns, '.git']; + filesPatterns.push('.git'); + return filesPatterns; } diff --git a/deadlock-plugins/deadlock-extension/src/view/CommandTree.ts b/deadlock-plugins/deadlock-extension/src/view/CommandTree.ts index cdb024f0..e172e422 100644 --- a/deadlock-plugins/deadlock-extension/src/view/CommandTree.ts +++ b/deadlock-plugins/deadlock-extension/src/view/CommandTree.ts @@ -1,24 +1,14 @@ -import isDocker from '../core/utils/isdocker'; -import Mission, { MissionCommand } from '../model/mission'; -import { parse } from 'yaml'; -import { readFileSync } from 'fs'; -import { commands, ExtensionContext, ThemeColor, ThemeIcon, TreeDataProvider, TreeItem, window } from 'vscode'; -import { pushOnCommitQueueIfNotReviewing } from '../recorder/utils/gitea'; +import { MissionCommand, MissionHandler } from '../model/mission'; +import { commands, Event, EventEmitter, ThemeColor, ThemeIcon, TreeDataProvider, TreeItem, window } from 'vscode'; import Recorder from '../recorder/recorder'; +import { MISSION_PATH_IC } from '../core/config'; +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import AttemptBuilder from '../model/attempt'; +import ApiService from '../core/api.service'; +import UserMission from '../core/mission/model/userMission'; +import { pushOnCommitQueueIfNotReviewing } from '../recorder/utils/gitea'; export class CommandTreeProvider implements TreeDataProvider<CommandItem> { - challenge: Mission; - context: ExtensionContext; - constructor(context: ExtensionContext) { - this.context = context; - if (isDocker()) { - this.challenge = parse(readFileSync('/deadlock/challenge.yaml', 'utf8')); - } else { - window.showErrorMessage('No mission found'); - throw new Error('No mission found'); - } - } - getTreeItem(element: CommandItem): TreeItem { return element; } @@ -27,18 +17,58 @@ export class CommandTreeProvider implements TreeDataProvider<CommandItem> { if (element) { return Promise.resolve([]); } else { - return Promise.resolve(this.challenge.desktop?.scripts?.map((script) => new CommandItem(script)) ?? []); + return Promise.resolve( + MissionHandler.instance.desktop?.scripts?.map((script) => new CommandItem(script, this)) ?? [], + ); } } + + private _onDidChangeTreeData: EventEmitter<CommandItem> = new EventEmitter<CommandItem>(); + readonly onDidChangeTreeData: Event<CommandItem> = this._onDidChangeTreeData.event; + + refresh(commandItem: CommandItem): void { + this._onDidChangeTreeData.fire(commandItem); + } } -const terminalName = `Deadlock`; +const outputPrefix = `Deadlock`; class CommandItem extends TreeItem { - constructor(private readonly missionCommand: MissionCommand) { + public readonly parced: { + cmd: string; + args: string[]; + }; + + private _isRunning: boolean; + private spawnHandler?: ChildProcessWithoutNullStreams; + + public get isRunning(): boolean { + return this._isRunning; + } + + private set isRunning(value: boolean) { + this._isRunning = value; + if (this._isRunning) { + this.apply(); + } else { + if (this.spawnHandler?.exitCode === null) { + this.spawnHandler?.kill(); + } + this.spawnHandler = undefined; + } + this.iconPath = new ThemeIcon( + this._isRunning ? 'notebook-stop' : 'notebook-execute', + new ThemeColor(this.missionCommand.type === 'submit' ? 'debugIcon.stopForeground' : 'debugIcon.startForeground'), + ); + this.parent.refresh(this); + } + + constructor(private readonly missionCommand: MissionCommand, private readonly parent: CommandTreeProvider) { super(missionCommand.name); + missionCommand.type = missionCommand.type ?? 'run'; this.tooltip = missionCommand.description ?? missionCommand.name; this.description = missionCommand.description ?? missionCommand.name; + this._isRunning = false; this.iconPath = new ThemeIcon( 'notebook-execute', new ThemeColor(missionCommand.type === 'submit' ? 'debugIcon.stopForeground' : 'debugIcon.startForeground'), @@ -48,32 +78,56 @@ class CommandItem extends TreeItem { command: missionCommand.name, tooltip: this.tooltip, }; + const split = missionCommand.command.split(' ').map((s) => s.replace(/\$([^$/]+)/, (_, n) => process.env[n] ?? '')); + this.parced = { cmd: split[0], args: split.slice(1) }; commands.registerCommand(missionCommand.name, () => { - if (missionCommand.type === 'submit') { - window - .showInformationMessage('Êtes-vous sûr de soumettre votre solution ?', { modal: true }, 'Oui', 'Non') - .then((answer) => { - if (answer === 'Oui') { - this.apply(); - } - }); + if (this.isRunning) { + this.isRunning = false; } else { - this.apply(); + if (missionCommand.type === 'submit') { + window + .showInformationMessage('Êtes-vous sûr de soumettre votre solution ?', { modal: true }, 'Oui') + .then((answer) => { + if (answer === 'Oui') { + this.isRunning = true; + } + }); + } else { + this.isRunning = true; + } } }); } - apply() { - const terminal = - window.terminals.filter((terminal) => terminal.name === terminalName)[0] ?? - window.createTerminal({ - cwd: process.env.WORKDIR, - name: terminalName, - }); - terminal.sendText(this.missionCommand.command); - terminal.show(); - if (this.missionCommand.type === 'submit' || this.missionCommand.type === 'run') { - pushOnCommitQueueIfNotReviewing(Recorder.instance.gitMission, 'Run'); - } + async apply() { + const output = window.createOutputChannel(`${outputPrefix} - ${this.missionCommand.name}`); + output.show(); + const attempt = new AttemptBuilder( + (await ApiService.getInstance().getCurrentUser()).id, + UserMission.getInstance().missionId, + this.missionCommand.type === 'submit', + UserMission.getInstance().storyId, + ); + this.spawnHandler = spawn(this.parced.cmd, this.parced.args, { + cwd: MISSION_PATH_IC, + }); + this.spawnHandler.stdout.addListener('data', (data) => { + output.append(data.toString()); + attempt.addLog(data.toString()); + }); + this.spawnHandler.stderr.addListener('data', (data) => { + output.append(data.toString()); + attempt.addLog(data.toString()); + }); + this.spawnHandler.addListener('exit', async (code) => { + output.appendLine(`${outputPrefix} - ${this.missionCommand.name} - exited with code ${code}`); + attempt.setExitCode(code ?? undefined); + if (this.missionCommand.type === 'submit' || this.missionCommand.type === 'run') { + pushOnCommitQueueIfNotReviewing(Recorder.instance.gitMission, 'Run', async (commit) => { + await ApiService.getInstance().submitAttempt(attempt.build(commit?.commit)); + }); + } + this.isRunning = false; + }); } } diff --git a/deadlock-plugins/deadlock-extension/src/view/startedMissionsView.ts b/deadlock-plugins/deadlock-extension/src/view/startedMissionsView.ts index c0354e5a..54efd4b9 100644 --- a/deadlock-plugins/deadlock-extension/src/view/startedMissionsView.ts +++ b/deadlock-plugins/deadlock-extension/src/view/startedMissionsView.ts @@ -27,8 +27,8 @@ export default class StartedMissionsView implements WebviewViewProvider { switch (message.command) { case 'openMission': { const path = join(missionWorkdir, message.mission, '.config', 'user-challenge.json'); - const userChallenge: UserMission = JSON.parse(readFileSync(path, 'utf8')); - Controller.getInstance().launchMission(userChallenge.missionId, userChallenge.missionVersion); + const userMission: UserMission = JSON.parse(readFileSync(path, 'utf8')); + Controller.getInstance().launchMission(userMission.missionId, userMission.missionVersion, userMission.storyId); return; } } -- GitLab