-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(isobot): add locking/cloning functionalities #1392
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import path from "path" | ||
|
||
import { fromPromise, ResultAsync } from "neverthrow" | ||
|
||
import { lock, unlock } from "@utils/mutex-utils" | ||
|
||
import { EFS_VOL_PATH_STAGING_LITE } from "@root/constants" | ||
import GitFileSystemError from "@root/errors/GitFileSystemError" | ||
import LockedError from "@root/errors/LockedError" | ||
import ReposService from "@root/services/identity/ReposService" | ||
|
||
import GitFileSystemService from "../db/GitFileSystemService" | ||
|
||
const LOCK_TIME_SECONDS = 15 * 60 // 15 minutes | ||
|
||
interface RepairServiceProps { | ||
gitFileSystemService: GitFileSystemService | ||
reposService: ReposService | ||
} | ||
|
||
export class RepairService { | ||
gitFileSystemService: GitFileSystemService | ||
|
||
reposService: ReposService | ||
|
||
constructor({ gitFileSystemService, reposService }: RepairServiceProps) { | ||
this.reposService = reposService | ||
this.gitFileSystemService = gitFileSystemService | ||
} | ||
|
||
lockRepo(repoName: string, lockDurationSeconds: number = LOCK_TIME_SECONDS) { | ||
return ResultAsync.fromPromise( | ||
lock(repoName, lockDurationSeconds), | ||
(err) => new LockedError(`Unable to lock repo ${repoName}, ${err}`) | ||
).map(() => repoName) | ||
} | ||
|
||
cloneRepo(repoName: string) { | ||
const repoUrl = `[email protected]:isomerpages/${repoName}.git` | ||
|
||
return ( | ||
this.gitFileSystemService | ||
.cloneBranch(repoName, true) | ||
// Repo does not exist in EFS, clone it | ||
.andThen(() => | ||
// repo exists in efs, but we need to pull for staging and reset staging lite | ||
this.gitFileSystemService | ||
.pull(repoName, "staging") | ||
.andThen(() => | ||
fromPromise( | ||
this.reposService.setUpStagingLite( | ||
path.join(EFS_VOL_PATH_STAGING_LITE, repoName), | ||
repoUrl | ||
), | ||
(error) => | ||
new GitFileSystemError( | ||
`Error setting up staging lite for repo ${repoName}: ${error}` | ||
) | ||
) | ||
) | ||
) | ||
) | ||
} | ||
|
||
unlockRepo(repoName: string) { | ||
return ResultAsync.fromPromise( | ||
unlock(repoName), | ||
(err) => `Failed to unlock repo with error: ${err}` | ||
) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,12 @@ | ||
import { App, ExpressReceiver } from "@slack/bolt" | ||
import { | ||
App, | ||
ExpressReceiver, | ||
Middleware, | ||
SlackCommandMiddlewareArgs, | ||
} from "@slack/bolt" | ||
import { okAsync } from "neverthrow" | ||
|
||
import { repairService } from "@common/index" | ||
import { Whitelist } from "@database/models" | ||
import config from "@root/config/config" | ||
import logger from "@root/logger/logger" | ||
|
@@ -16,11 +23,37 @@ const token = config.get("slackbot.token") | |
const botReceiver = new ExpressReceiver({ signingSecret, endpoints: "/" }) | ||
export const isobotRouter = botReceiver.router | ||
|
||
// TODO: add slack ids of isomer user | ||
const ISOMER_USERS_ID = ["U01HTSFC0RY"] | ||
const BOT_AUDIT_CHANNEL_ID = "C075Z617GCQ" | ||
const bot = new App({ | ||
token, | ||
receiver: botReceiver, | ||
}) | ||
|
||
const validateIsomerUser: Middleware<SlackCommandMiddlewareArgs> = async ({ | ||
payload, | ||
client, | ||
next, | ||
}) => { | ||
// NOTE: Not calling `client.get` again - repeated work and also | ||
// we only have a 3s window to ACK slack (haven't ack yet) | ||
if (!ISOMER_USERS_ID.some((userId) => userId === payload.user_id)) { | ||
await client.chat.postEphemeral({ | ||
channel: payload.channel, | ||
user: payload.user_id, | ||
text: `Sorry @${payload.user_id}, only Isomer members are allowed to use this command!`, | ||
}) | ||
await client.chat.postMessage({ | ||
channel: BOT_AUDIT_CHANNEL_ID, | ||
text: `Attempted access by @${payload.user_id}`, | ||
}) | ||
throw new Error("Non-isomer member") | ||
} | ||
|
||
next() | ||
} | ||
|
||
// TODO: add in validation for user once downstream is merged | ||
bot.command("/whitelist", async ({ payload, respond, ack }) => { | ||
await ack() | ||
|
@@ -45,3 +78,112 @@ bot.command("/siteup", async ({ payload, respond, ack }) => { | |
|
||
return botService.dnsChecker(payload).map((response) => respond(response)) | ||
}) | ||
|
||
bot.command( | ||
"/clone", | ||
validateIsomerUser, | ||
async ({ command, ack, respond, payload, client }) => { | ||
await ack() | ||
|
||
const HELP_TEXT = | ||
"Usage: `/clone <github_repo_name>`. Take note that this locks the repo for 15 minutes by default. To bypass this behaviour, add `-n` at the end of the command" | ||
const tokens = command.text.split(" ") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. us clone the right term? we usually refer to it as a repair? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the repair does a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. correct, hence the 3-step process is called |
||
|
||
const hasUnrecognisedLastToken = tokens.length === 2 && tokens[1] !== "-n" | ||
if (tokens.length < 1 || hasUnrecognisedLastToken) { | ||
return respond(HELP_TEXT) | ||
} | ||
|
||
// NOTE: Invariant maintained: | ||
// 1. we always need 0 < tokens.length < 3 | ||
// 2. token at index 0 is always the github repository | ||
// 3. token at index 1 is always the "-n" option | ||
const isHelp = tokens[0].toLowerCase() === "help" | ||
if (isHelp) { | ||
return respond(HELP_TEXT) | ||
} | ||
|
||
const repo = tokens[0] | ||
const shouldLock = tokens.length === 2 && tokens[1] === "-n" | ||
await client.chat.postMessage({ | ||
channel: BOT_AUDIT_CHANNEL_ID, | ||
text: `${payload.user_id} attempting to clone repo: ${repo} to EFS. Should lock: ${shouldLock}`, | ||
}) | ||
|
||
const base = shouldLock ? repairService.lockRepo(tokens[0]) : okAsync("") | ||
return base | ||
.andThen(repairService.cloneRepo) | ||
.map(() => respond(`${repo} was successfully cloned to efs!`)) | ||
.mapErr((e) => respond(`${e} occurred while cloning repo to efs`)) | ||
} | ||
) | ||
|
||
bot.command( | ||
"/lock", | ||
validateIsomerUser, | ||
async ({ command, ack, respond, payload, client }) => { | ||
await ack() | ||
|
||
const HELP_TEXT = | ||
"Usage: `/lock <github_repo_name> -d <duration_in_minutes>`. Take note that this locks the repo for 15 minutes by default if `-d` is not specified" | ||
const tokens = command.text.split(" ") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should d also have a max time? what if someone puts in an accidental 10000000 mins or something? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. makes sense, will make this change |
||
// NOTE: Invariant maintained: | ||
// 1. tokens.length === 1 || tokens.length === 3 | ||
// 2. token at index 0 is always the github repository | ||
// 3. if tokens.length === 3, then the last element must not be `NaN` | ||
const isShortCommand = tokens.length === 1 | ||
const isLongCommand = | ||
tokens.length === 3 && | ||
!Number.isNaN(parseInt(tokens[2], 10)) && | ||
tokens[1] === "-d" | ||
if (!isShortCommand || !isLongCommand) { | ||
return respond(HELP_TEXT) | ||
} | ||
|
||
const repo = tokens[0] | ||
const lockTimeMinutes = isLongCommand ? parseInt(tokens[2], 10) : 15 | ||
const lockTimeSeconds = lockTimeMinutes * 60 | ||
await client.chat.postMessage({ | ||
channel: BOT_AUDIT_CHANNEL_ID, | ||
text: `${payload.user_id} attempting to lock repo: ${repo} for ${lockTimeMinutes}`, | ||
}) | ||
|
||
return repairService | ||
.lockRepo(repo, lockTimeSeconds) | ||
.map((lockedRepo) => | ||
respond( | ||
`${lockedRepo} was successfully locked for ${lockTimeMinutes} minutes!` | ||
) | ||
) | ||
.mapErr((e) => respond(`${e} occurred while attempting to lock repo`)) | ||
} | ||
) | ||
|
||
bot.command( | ||
"/unlock", | ||
validateIsomerUser, | ||
async ({ command, ack, respond, payload, client }) => { | ||
await ack() | ||
|
||
const HELP_TEXT = | ||
"Usage: `/unlock <github_repo_name>`. This unlocks a previously locked repo. Has no effect if the repo is already unlocked" | ||
const tokens = command.text.split(" ") | ||
// NOTE: Invariant maintained: | ||
// 1. token at index 0 is always the github repository | ||
const repo = tokens[0] | ||
|
||
if (repo === "help" || repo === "h" || repo === "-help") { | ||
return respond(HELP_TEXT) | ||
} | ||
|
||
await client.chat.postMessage({ | ||
channel: BOT_AUDIT_CHANNEL_ID, | ||
text: `${payload.user_id} attempting to unlock repo: ${repo}`, | ||
}) | ||
|
||
return repairService | ||
.unlockRepo(repo) | ||
.map(() => respond(`repo: ${repo} was successfully unlocked!`)) | ||
.mapErr(respond) | ||
} | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we be hardcoding this? Or fetch it dynamically? Else every member movement in team will require a change
An alternative I can think of is to fetch members of the
isomer-team
and then check if the user is within this team. I believe the team will be more permanent than a user idThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok this sounds good to me, i'll make this change!