Skip to content

Commit

Permalink
Feat/ggs rollback handler (#895)
Browse files Browse the repository at this point in the history
* IS-401: Write extend repo service to support delete files folders (#894)

* feat: base code for delete file

* feat: add base delete directory steps

* feat: refactor into one delete function

* feat: split RepoService delete into two

* fix: remove unused imports

* feat: update return types

* fix: BDS tests

* feat: tests for delete in RepoService

* fix: remove update tree function for ggs flow

* feat: add tests for GFS

* feat: add tests for delete

* feat: add check for latest commit

* fix: uncomment push

* fix: add log

* feat: add tests

* Feat/is 399 ggs create file (#892)

* refactor: safeExistsSync

* chore: upgrade js-base64

* feat: add create methods

* test: add tests

* refactor: remove safeExistsSync and use getFilePathStats instead

* test: add tests for rollback

* feat: push on creation

* chore: update missing import

* chore: change to pass arguments directly

* chore: replace return type with GitCommitResult

* fix: remove unnecessary maps

* fix: swap returned error on creating file

* chore: remove unused import

* fix: handle stats from retrieving directory info

* nit: swap return from empty string to boolean

* chore: improve log statement

* fix: update assertion of error

* feat: add optional force param to push

* feat: add rollback functionality for whitelisted ggs sites

* fix: rebase errors

* fix: use false as default for isForce

---------

Co-authored-by: Harish <harish@open.gov.sg>
  • Loading branch information
alexanderleegs and harishv7 authored Aug 15, 2023
1 parent c4a9ab6 commit 115a98a
Show file tree
Hide file tree
Showing 8 changed files with 654 additions and 67 deletions.
85 changes: 70 additions & 15 deletions src/middleware/routeHandler.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
const { backOff } = require("exponential-backoff")
const SimpleGit = require("simple-git")

const { config } = require("@config/config")

const { default: GithubSessionData } = require("@classes/GithubSessionData")

const { lock, unlock } = require("@utils/mutex-utils")
const { getCommitAndTreeSha, revertCommit } = require("@utils/utils.js")

const GitFileSystemError = require("@root/errors/GitFileSystemError")
const {
default: GitFileSystemService,
} = require("@services/db/GitFileSystemService")

const WHITELISTED_GIT_SERVICE_REPOS = config.get(
"featureFlags.ggsWhitelistedRepos"
)
const BRANCH_REF = config.get("github.branchRef")

// Used when there are no write API calls to the repo on GitHub
const attachReadRouteHandlerWrapper = (routeHandler) => async (
req,
Expand All @@ -31,6 +44,8 @@ const attachWriteRouteHandlerWrapper = (routeHandler) => async (
await unlock(siteName)
}

const gitFileSystemService = new GitFileSystemService(new SimpleGit())

const attachRollbackRouteHandlerWrapper = (routeHandler) => async (
req,
res,
Expand All @@ -40,32 +55,72 @@ const attachRollbackRouteHandlerWrapper = (routeHandler) => async (
const { siteName } = req.params

const { accessToken } = userSessionData
const isRepoWhitelisted = WHITELISTED_GIT_SERVICE_REPOS.split(",").includes(
siteName
)

await lock(siteName)

let originalCommitSha
try {
const { currentCommitSha, treeSha } = await getCommitAndTreeSha(
if (isRepoWhitelisted) {
const result = await gitFileSystemService.getLatestCommitOfBranch(
siteName,
accessToken
BRANCH_REF
)
if (result.isErr()) {
await unlock(siteName)
next(result.err)
} else {
originalCommitSha = result.value.sha
if (!originalCommitSha) {
await unlock(siteName)
next(result.err)
}
// Unused for git file system, but to maintain existing structure
res.locals.githubSessionData = new GithubSessionData({
currentCommitSha: "",
treeSha: "",
})
}
} else {
try {
const { currentCommitSha, treeSha } = await getCommitAndTreeSha(
siteName,
accessToken
)

const githubSessionData = new GithubSessionData({
currentCommitSha,
treeSha,
})
res.locals.githubSessionData = githubSessionData
const githubSessionData = new GithubSessionData({
currentCommitSha,
treeSha,
})
res.locals.githubSessionData = githubSessionData

originalCommitSha = currentCommitSha
} catch (err) {
await unlock(siteName)
next(err)
originalCommitSha = currentCommitSha
} catch (err) {
await unlock(siteName)
next(err)
}
}
routeHandler(req, res, next).catch(async (err) => {
try {
await backOff(() =>
revertCommit(originalCommitSha, siteName, accessToken)
)
if (isRepoWhitelisted) {
await backOff(() => {
const rollbackRes = gitFileSystemService
.rollback(siteName, originalCommitSha)
.unwrapOr(false)
if (!rollbackRes) throw new GitFileSystemError("Rollback failure")
})
await backOff(() => {
const pushRes = gitFileSystemService
.push(siteName, true)
.unwrapOr(false)
if (!pushRes) throw new GitFileSystemError("Push failure")
})
} else {
await backOff(() => {
revertCommit(originalCommitSha, siteName, accessToken)
})
}
} catch (retryErr) {
await unlock(siteName)
next(retryErr)
Expand Down
108 changes: 104 additions & 4 deletions src/services/db/GitFileSystemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,10 @@ export default class GitFileSystemService {
}

// Push the latest changes to upstream Git hosting provider
push(repoName: string): ResultAsync<string, GitFileSystemError> {
push(
repoName: string,
isForce = false
): ResultAsync<string, GitFileSystemError> {
return this.isValidGitRepo(repoName).andThen((isValid) => {
if (!isValid) {
return errAsync(
Expand All @@ -354,7 +357,9 @@ export default class GitFileSystemService {

return this.ensureCorrectBranch(repoName).andThen(() =>
ResultAsync.fromPromise(
this.git.cwd(`${EFS_VOL_PATH}/${repoName}`).push(),
isForce
? this.git.cwd(`${EFS_VOL_PATH}/${repoName}`).push(["--force"])
: this.git.cwd(`${EFS_VOL_PATH}/${repoName}`).push(),
(error) => {
logger.error(`Error when pushing ${repoName}: ${error}`)

Expand Down Expand Up @@ -767,8 +772,103 @@ export default class GitFileSystemService {
})
}

// TODO: Delete a file
async delete() {}
// Delete a file or directory
delete(
repoName: string,
path: string,
oldSha: string,
userId: SessionDataProps["isomerUserId"],
isDir: boolean
): ResultAsync<string, GitFileSystemError | NotFoundError> {
let oldStateSha = ""

return this.getLatestCommitOfBranch(repoName, BRANCH_REF)
.andThen((latestCommit) => {
if (!latestCommit.sha) {
return errAsync(
new GitFileSystemError(
`Unable to find latest commit of repo: ${repoName} on branch "${BRANCH_REF}"`
)
)
}
oldStateSha = latestCommit.sha as string
return okAsync(true)
})
.andThen(() => this.getFilePathStats(repoName, path))
.andThen((stats) => {
if (isDir && !stats.isDirectory()) {
return errAsync(
new GitFileSystemError(
`Path "${path}" is not a valid directory in repo "${repoName}"`
)
)
}
if (!isDir && !stats.isFile()) {
return errAsync(
new GitFileSystemError(
`Path "${path}" is not a valid file in repo "${repoName}"`
)
)
}
return okAsync(true)
})
.andThen(() => {
if (isDir) {
return okAsync(true) // If it's a directory, skip the blob hash verification
}
return this.getGitBlobHash(repoName, path).andThen((sha) => {
if (sha !== oldSha) {
return errAsync(
new ConflictError(
"File has been changed recently, please try again"
)
)
}
return okAsync(sha)
})
})
.andThen(() => {
const deletePromise = isDir
? fs.promises.rm(`${EFS_VOL_PATH}/${repoName}/${path}`, {
recursive: true,
force: true,
})
: fs.promises.rm(`${EFS_VOL_PATH}/${repoName}/${path}`)

return ResultAsync.fromPromise(deletePromise, (error) => {
logger.error(
`Error when deleting ${path} from Git file system: ${error}`
)
if (error instanceof Error) {
return new GitFileSystemNeedsRollbackError(
`Unable to delete ${isDir ? "directory" : "file"} on disk`
)
}
return new GitFileSystemNeedsRollbackError(
"An unknown error occurred"
)
})
})
.andThen(() =>
this.commit(
repoName,
[path],
userId,
`Delete ${
isDir ? `directory: ${path}` : `file: ${path.split("/").pop()}`
}`
)
)
.orElse((error) => {
if (error instanceof GitFileSystemNeedsRollbackError) {
return this.rollback(repoName, oldStateSha).andThen(() =>
errAsync(new GitFileSystemError(error.message))
)
}

return errAsync(error)
})
}

isDefaultLogFields(logFields: unknown): logFields is DefaultLogFields {
const c = logFields as DefaultLogFields
Expand Down
101 changes: 97 additions & 4 deletions src/services/db/RepoService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import config from "@config/config"

import logger from "@logger/logger"

import { GithubSessionDataProps } from "@root/classes"
import UserWithSiteSessionData from "@root/classes/UserWithSiteSessionData"
import { GitHubCommitData } from "@root/types/commitData"
import type {
Expand All @@ -13,6 +14,7 @@ import type {
} from "@root/types/gitfilesystem"
import { MediaDirOutput, MediaFileOutput, MediaType } from "@root/types/media"
import { getMediaFileInfo } from "@root/utils/media-utils"
import { RawGitTreeEntry } from "@root/types/github"

import GitFileSystemService from "./GitFileSystemService"
import { GitHubService } from "./GitHubService"
Expand Down Expand Up @@ -344,10 +346,101 @@ export default class RepoService extends GitHubService {
})
}

async deleteDirectory(
sessionData: UserWithSiteSessionData,
{
directoryName,
message,
githubSessionData,
}: {
directoryName: string
message: string
githubSessionData: GithubSessionDataProps
}
): Promise<void> {
if (this.isRepoWhitelisted(sessionData.siteName)) {
logger.info(
`Deleting directory in local Git file system for repo: ${sessionData.siteName}, directory name: ${directoryName}`
)
const result = await this.gitFileSystemService.delete(
sessionData.siteName,
directoryName,
"",
sessionData.isomerUserId,
true
)

if (result.isErr()) {
throw result.error
}

this.gitFileSystemService.push(sessionData.siteName)
return
}

// GitHub flow
const gitTree = await this.getTree(sessionData, githubSessionData, {
isRecursive: true,
})

// Retrieve removed items and set their sha to null
const newGitTree = gitTree
.filter(
(item) =>
item.path.startsWith(`${directoryName}/`) && item.type !== "tree"
)
.map((item) => ({
...item,
sha: null,
}))

const newCommitSha = this.updateTree(sessionData, githubSessionData, {
gitTree: newGitTree,
message,
})

return await this.updateRepoState(sessionData, {
commitSha: newCommitSha,
})
}

// deletes a file
async delete(
sessionData: any,
{ sha, fileName, directoryName }: any
): Promise<any> {
sessionData: UserWithSiteSessionData,
{
sha,
fileName,
directoryName,
}: {
sha: string
fileName: string
directoryName: string
}
): Promise<void> {
if (this.isRepoWhitelisted(sessionData.siteName)) {
logger.info(
`Deleting file in local Git file system for repo: ${sessionData.siteName}, directory name: ${directoryName}, file name: ${fileName}`
)

const filePath = directoryName ? `${directoryName}/${fileName}` : fileName

const result = await this.gitFileSystemService.delete(
sessionData.siteName,
filePath,
sha,
sessionData.isomerUserId,
false
)

if (result.isErr()) {
throw result.error
}

this.gitFileSystemService.push(sessionData.siteName)
return
}

// GitHub flow
return await super.delete(sessionData, {
sha,
fileName,
Expand Down Expand Up @@ -388,7 +481,7 @@ export default class RepoService extends GitHubService {
sessionData: any,
githubSessionData: any,
{ isRecursive }: any
): Promise<any> {
): Promise<RawGitTreeEntry[]> {
return await super.getTree(sessionData, githubSessionData, {
isRecursive,
})
Expand Down
Loading

0 comments on commit 115a98a

Please sign in to comment.