-
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(GH<->GGs): Repair GGs #996
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
47e1a7a
chore(env var): include env vars for forms
kishore03109 5a3883a
chore(formsg): rename routers for clarity
kishore03109 4d5936b
chore(server.js): rename vars for claity
kishore03109 064a21b
refactor(reposService): move func out for reusability
kishore03109 a18a7df
feat(ggsRepair): add in repair functionality
kishore03109 680fb54
feat(formsg): allow submissions of form with attachments
kishore03109 30e53ca
feat(formsg): introduce utirls
kishore03109 3deb80a
chore(gfs): make error message more meaningful
kishore03109 6d924c1
feat(ggsRepair): ability to get attachments
kishore03109 8ed9bba
fix(formsg): change in API
kishore03109 98733ea
chore(reposservice): add warning
kishore03109 cc613ae
fix(formsProcessingService): fix tests
kishore03109 1a339f0
fix(.env.test): add env var
kishore03109 154fa70
feat(reposService): marginal improvement to script
kishore03109 a3ced38
fix(ggsRepair): use ssh instead
kishore03109 b7d850b
chore(ggrepair): rm console logs
kishore03109 b20ff53
fix(ssm): add ssm to env var
kishore03109 c8bafb3
chore(repairGGs): rm redundant comment
kishore03109 76b0c5c
chore(GGsRepair): rm redundant cast
kishore03109 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,7 @@ | |
|
||
convict.addFormat({ | ||
name: "required-string", | ||
validate: (val: any) => { | ||
if (!val) throw new Error("value cannot be empty, null or undefined") | ||
if (typeof val !== "string") throw new Error("value must be a string") | ||
}, | ||
|
@@ -10,14 +10,14 @@ | |
|
||
convict.addFormat({ | ||
name: "required-positive-number", | ||
validate: (val: any) => { | ||
if (val === null || val === undefined || val === "") | ||
throw new Error("value cannot be empty, null or undefined") | ||
if (typeof val !== "number") throw new Error("value must be a number") | ||
}, | ||
coerce: (val: string) => { | ||
const coercedVal = Number(val) | ||
if (isNaN(coercedVal)) { | ||
Check warning on line 20 in src/config/config.ts GitHub Actions / lint
|
||
throw new Error( | ||
"value provided is not a positive number. please provide a valid positive number" | ||
) | ||
|
@@ -31,7 +31,7 @@ | |
|
||
convict.addFormat({ | ||
name: "required-boolean", | ||
validate: (val: any) => { | ||
if (val === null || val === undefined) | ||
throw new Error("value cannot be empty, null or undefined") | ||
if (typeof val !== "boolean") throw new Error("value must be a boolean") | ||
|
@@ -283,6 +283,20 @@ | |
format: "required-string", | ||
default: "", | ||
}, | ||
siteLaunchFormKey: { | ||
doc: "FormSG API key for site launch form", | ||
env: "SITE_LAUNCH_FORM_KEY", | ||
sensitive: true, | ||
format: "required-string", | ||
default: "", | ||
}, | ||
ggsRepairFormKey: { | ||
doc: "FormSG API key for GGs repair form", | ||
env: "GGS_REPAIR_FORM_KEY", | ||
sensitive: true, | ||
format: "required-string", | ||
default: "", | ||
}, | ||
}, | ||
postman: { | ||
apiKey: { | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,264 @@ | ||
/* eslint-disable import/prefer-default-export */ | ||
import path from "path" | ||
|
||
import { | ||
DecryptedContentAndAttachments, | ||
DecryptedFile, | ||
} from "@opengovsg/formsg-sdk/dist/types" | ||
import express, { RequestHandler } from "express" | ||
import { | ||
ResultAsync, | ||
combineWithAllErrors, | ||
errAsync, | ||
fromPromise, | ||
okAsync, | ||
} from "neverthrow" | ||
|
||
import { config } from "@config/config" | ||
|
||
import { EFS_VOL_PATH_STAGING_LITE } from "@root/constants" | ||
import GitFileSystemError from "@root/errors/GitFileSystemError" | ||
import InitializationError from "@root/errors/InitializationError" | ||
import { consoleLogger } from "@root/logger/console.logger" | ||
import logger from "@root/logger/logger" | ||
import { attachFormSGHandler } from "@root/middleware" | ||
import GitFileSystemService from "@root/services/db/GitFileSystemService" | ||
import ReposService from "@root/services/identity/ReposService" | ||
import { mailer } from "@root/services/utilServices/MailClient" | ||
import { getField, getFieldsFromTable, getId } from "@root/utils/formsg-utils" | ||
|
||
const GGS_REPAIR_FORM_KEY = config.get("formSg.ggsRepairFormKey") | ||
|
||
interface FormsgGGsRepairRouterProps { | ||
gitFileSystemService: GitFileSystemService | ||
reposService: ReposService | ||
} | ||
|
||
interface RepairEmailProps { | ||
clonedRepos: string[] | ||
syncedRepos: string[] | ||
errors: GitFileSystemError[] | ||
requesterEmail: string | ||
} | ||
|
||
const REQUESTER_EMAIL_FIELD = "Email" | ||
const OPTION_TO_SUBMIT_CSV = "Do you want to upload list of sites as a CSV?" | ||
const ATTACHMENT = "Attachment" | ||
const REPO_NAME_FIELD = "Table (Repo Name (in GitHub))" | ||
|
||
export class FormsgGGsRepairRouter { | ||
private readonly gitFileSystemService: FormsgGGsRepairRouterProps["gitFileSystemService"] | ||
|
||
private readonly reposService: FormsgGGsRepairRouterProps["reposService"] | ||
|
||
constructor({ | ||
gitFileSystemService, | ||
reposService, | ||
}: FormsgGGsRepairRouterProps) { | ||
this.gitFileSystemService = gitFileSystemService | ||
this.reposService = reposService | ||
} | ||
|
||
getGGsRepairFormSubmission: RequestHandler< | ||
never, | ||
Record<string, never>, | ||
{ data: { submissionId: string } }, | ||
never, | ||
{ submission: DecryptedContentAndAttachments } | ||
> = async (req, res) => { | ||
const { responses } = res.locals.submission.content | ||
|
||
const requesterEmail = getField(responses, REQUESTER_EMAIL_FIELD) | ||
const optionToSubmitCsv = getField(responses, OPTION_TO_SUBMIT_CSV) | ||
const repoNames: string[] = [] | ||
if (optionToSubmitCsv === "Yes") { | ||
const attachmentId = getId(responses, ATTACHMENT) | ||
if (!attachmentId) { | ||
throw new Error("No attachment id") | ||
} | ||
const decryptedFile: DecryptedFile = | ||
res.locals.submission.attachments?.[attachmentId] | ||
const reposCsv = Buffer.from(decryptedFile.content).toString() | ||
if (!reposCsv.startsWith("repo_name")) { | ||
logger.error("Invalid csv format") | ||
return | ||
} | ||
const repos = reposCsv.split("\n").slice(1) | ||
repos.forEach((repo) => { | ||
repoNames.push(repo) | ||
}) | ||
if (repoNames.length === 0) { | ||
logger.error("No repo name provided") | ||
return | ||
} | ||
} else { | ||
const repoNamesFromTable = getFieldsFromTable(responses, REPO_NAME_FIELD) | ||
|
||
if (!repoNamesFromTable) { | ||
logger.error("No repo name provided") | ||
return | ||
} | ||
repoNamesFromTable.forEach((repoName) => { | ||
if (typeof repoName === "string") { | ||
// actually wont happen based on our formsg form, but this code | ||
// is added defensively | ||
repoNames.push(repoName) | ||
} else { | ||
repoNames.push(repoName[0]) | ||
} | ||
}) | ||
} | ||
|
||
if (!requesterEmail?.endsWith("@open.gov.sg")) { | ||
logger.error("Requester email is not from @open.gov.sg") | ||
return | ||
} | ||
this.handleGGsFormSubmission(repoNames, requesterEmail) | ||
} | ||
|
||
handleGGsFormSubmission = (repoNames: string[], requesterEmail: string) => { | ||
const repairs: ResultAsync<string, GitFileSystemError>[] = [] | ||
|
||
const clonedStagingRepos: string[] = [] | ||
const syncedStagingAndStagingLiteRepos: string[] = [] | ||
repoNames.forEach((repoName) => { | ||
const repoUrl = `[email protected]:isomerpages/${repoName}` | ||
|
||
repairs.push( | ||
this.doesRepoNeedClone(repoName) | ||
.andThen(() => { | ||
const isStaging = true | ||
return ( | ||
this.gitFileSystemService | ||
// Repo does not exist in EFS, clone it | ||
.cloneBranch(repoName, isStaging) | ||
.andThen(() => { | ||
// take note of repos that cloned successfully | ||
clonedStagingRepos.push(repoName) | ||
return okAsync(true) | ||
}) | ||
) | ||
}) | ||
.orElse((error) => { | ||
if (error === false) { | ||
// Repo exists in EFS, no need to clone, but continue with syncing | ||
return okAsync(false) | ||
} | ||
return errAsync(error) | ||
}) | ||
.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}` | ||
) | ||
) | ||
) | ||
.andThen((result) => { | ||
// take note of repos that synced successfully | ||
syncedStagingAndStagingLiteRepos.push(repoName) | ||
return okAsync(result) | ||
}) | ||
) | ||
) | ||
}) | ||
|
||
let errors: GitFileSystemError[] = [] | ||
combineWithAllErrors(repairs) | ||
.orElse((error) => { | ||
errors = error | ||
// send one final email about success and failures | ||
return okAsync([]) | ||
}) | ||
.andThen(() => | ||
fromPromise( | ||
this.sendEmail({ | ||
requesterEmail, | ||
clonedRepos: clonedStagingRepos, | ||
syncedRepos: syncedStagingAndStagingLiteRepos, | ||
errors, | ||
}), | ||
(error) => { | ||
const emailContent = `Sites have been repaired ${ | ||
errors ? `with errors ${errors}` : `without errors` | ||
} and clonedRepos ${clonedStagingRepos} and syncedRepos ${syncedStagingAndStagingLiteRepos}` | ||
// Logging information in case email sending fails | ||
consoleLogger.error( | ||
`There was an error sending email to ${requesterEmail}: ${error}\n The content of the email is: ${emailContent}` | ||
) | ||
} | ||
) | ||
) | ||
} | ||
|
||
async sendEmail({ | ||
requesterEmail, | ||
clonedRepos, | ||
syncedRepos, | ||
errors, | ||
}: RepairEmailProps) { | ||
const subject = `[Isomer] Site Repair Report` | ||
const errorReport = | ||
errors.length > 0 | ||
? `The following errors were observed while repairing the sites: | ||
<ul> | ||
${errors.map((error) => `<li>${error.message}</li>`)} | ||
</ul>` | ||
: "" | ||
|
||
const clonedReposReport = | ||
clonedRepos.length > 0 | ||
? `The following sites were cloned to EFS: | ||
<ul> | ||
${clonedRepos.map((repo) => `<li>${repo}</li>`)} | ||
</ul>` | ||
: "" | ||
|
||
const syncedReposReport = | ||
syncedRepos.length > 0 | ||
? `The following sites were synced to EFS: | ||
<ul> | ||
${syncedRepos.map((repo) => `<li>${repo}</li>`)} | ||
</ul>` | ||
: "" | ||
|
||
const html = errorReport + clonedReposReport + syncedReposReport | ||
await mailer.sendMail(requesterEmail, subject, html) | ||
} | ||
|
||
doesRepoNeedClone(repoName: string): ResultAsync<true, false> { | ||
return this.gitFileSystemService | ||
.isGitInitialized(repoName) | ||
.andThen((isRepoInEfs) => { | ||
if (isRepoInEfs) { | ||
return errAsync<true, false>(false) | ||
} | ||
return okAsync<true, false>(true) | ||
}) | ||
.orElse(() => okAsync<true, false>(true)) | ||
} | ||
|
||
getRouter() { | ||
const router = express.Router({ mergeParams: true }) | ||
if (!GGS_REPAIR_FORM_KEY) { | ||
throw new InitializationError( | ||
"Required GGS_REPAIR_FORM_KEY environment variable is empty." | ||
) | ||
} | ||
router.post( | ||
"/repair-ggs", | ||
attachFormSGHandler(GGS_REPAIR_FORM_KEY), | ||
this.getGGsRepairFormSubmission | ||
) | ||
|
||
return router | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Hmm actually does this assume that a staging lite doesn't exist? it seems to attempt a clone + pull, but we also want this to be able to repair error right, is this the desired behaviour?
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.
actually fair this not v clear, updated pr desc
We always assume staging lite doesnt exist. the reason why we dont git pull is because once we git pull, .git reconciles and the folder bloats, and we lose the benefits of a small bundle size. so we always reset, and this is not too long (< 1min for nlb iirc.
We also always clone and pull. the reason for this is because cloning takes time, with repos like nlb it can take a while, so we do a pull right before we reset staging lite.
It is notable that during this period of time, any changes to the repo will screw this repair up, but actually this is ok because: