Skip to content
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

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
ReviewRequestView,
} from "@database/models"
import AuditLogsService from "@root/services/admin/AuditLogsService"
import { RepairService } from "@root/services/admin/RepairService"
import RepoManagementService from "@root/services/admin/RepoManagementService"
import GitFileCommitService from "@root/services/db/GitFileCommitService"
import GitFileSystemService from "@root/services/db/GitFileSystemService"
Expand Down Expand Up @@ -248,3 +249,8 @@ export const auditLogsService = new AuditLogsService({
sitesService,
usersService,
})

export const repairService = new RepairService({
reposService,
gitFileSystemService,
})
71 changes: 71 additions & 0 deletions src/services/admin/RepairService.ts
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}`
)
}
}
144 changes: 143 additions & 1 deletion support/routes/v2/isobot/index.ts
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"
Expand All @@ -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"
Copy link
Contributor

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 id

Copy link
Contributor Author

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!

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()
Expand All @@ -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(" ")
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the repair does a lock -> clone -> unlock under the hood!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct, hence the 3-step process is called repair


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(" ")
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
}
)
Loading