Skip to content

Commit

Permalink
Merge pull request #2782 from owid/deploy-admin-code
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelgerber authored Oct 17, 2023
2 parents 2cd0521 + 87ddea9 commit 7eca78a
Show file tree
Hide file tree
Showing 4 changed files with 419 additions and 0 deletions.
34 changes: 34 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ help:
@echo ' make refresh.wp download a new wordpress snapshot and update MySQL'
@echo ' make refresh.full do a full MySQL update of both wordpress and grapher'
@echo
@echo ' OPS (staff-only)'
@echo ' make deploy Deploy your local site to production'
@echo ' make stage Deploy your local site to staging'
@echo

up: export DEBUG = 'knex:query'

Expand Down Expand Up @@ -215,6 +219,36 @@ wordpress/web/app/uploads/2022:
@echo '==> Downloading wordpress uploads'
./devTools/docker/download-wordpress-uploads.sh

deploy:
@echo '==> Starting from a clean slate...'
rm -rf itsJustJavascript

@echo '==> Building...'
yarn
yarn lerna run build
yarn run tsc -b

@echo '==> Deploying...'
yarn buildAndDeploySite live

stage:
@if [[ ! "$(STAGING)" ]]; then \
echo 'ERROR: must set the staging environment'; \
echo ' e.g. STAGING=halley make stage'; \
exit 1; \
fi
@echo '==> Preparing to deploy to $(STAGING)'
@echo '==> Starting from a clean slate...'
rm -rf itsJustJavascript

@echo '==> Building...'
yarn
yarn lerna run build
yarn run tsc -b

@echo '==> Deploying to $(STAGING)...'
yarn buildAndDeploySite $(STAGING)

test:
@echo '==> Linting'
yarn
Expand Down
314 changes: 314 additions & 0 deletions baker/Deployer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
import fs from "fs-extra"
import { prompt } from "prompts"
import ProgressBar from "progress"
import { execWrapper } from "../db/execWrapper.js"
import { spawn } from "child_process"
import { simpleGit, SimpleGit } from "simple-git"
import { WriteStream } from "tty"
import { ProgressStream } from "./ProgressStream.js"
import { DeployTarget, ProdTarget } from "./DeployTarget.js"

const TEMP_DEPLOY_SCRIPT_PREFIX = `tempDeployScript.`

interface DeployerOptions {
owidGrapherRootDir: string
userRunningTheDeploy: string
target: DeployTarget
skipChecks?: boolean
runChecksRemotely?: boolean
}

const OWID_STAGING_DROPLET_IP = "165.22.127.239"
const OWID_LIVE_DROPLET_IP = "209.97.185.49"

export class Deployer {
private options: DeployerOptions
private progressBar: ProgressBar
private stream: ProgressStream
constructor(options: DeployerOptions) {
this.options = options
const { target, skipChecks, runChecksRemotely } = this.options

this.stream = new ProgressStream(process.stderr)
// todo: a smarter way to precompute out the number of steps?
const testSteps = !skipChecks && !runChecksRemotely ? 1 : 0
this.progressBar = new ProgressBar(
`Baking and deploying to ${target} [:bar] :current/:total :elapseds :name\n`,
{
total: 21 + testSteps,
renderThrottle: 0, // print on every tick
stream: this.stream as unknown as WriteStream,
}
)
}

private async runAndTick(command: string) {
await execWrapper(command)
this.progressBar.tick({ name: `✅ finished ${command}` })
}

private get isValidTarget() {
return new Set(Object.values(DeployTarget)).has(this.options.target)
}

get targetIsProd() {
return this.options.target === ProdTarget
}

private get targetIpAddress() {
return this.targetIsProd
? OWID_LIVE_DROPLET_IP
: OWID_STAGING_DROPLET_IP
}

// todo: I have not tested this yet, and would be surprised if it worked on the first attempt.
private async runPreDeployChecksRemotely() {
const { owidGrapherRootDir } = this.options
const { rsyncTargetDirForTests } = this.pathsOnTarget
const RSYNC_TESTS = `rsync -havz --no-perms --progress --delete --include=/test --include=*.test.ts --include=*.test.tsx --exclude-from=${owidGrapherRootDir}/.rsync-ignore`
await execWrapper(
`${RSYNC_TESTS} ${owidGrapherRootDir} ${this.sshHost}:${rsyncTargetDirForTests}`
)

const script = `cd ${rsyncTargetDirForTests}
yarn install --immutable
yarn testPrettierAll`
await execWrapper(`ssh -t ${this.sshHost} 'bash -e -s' ${script}`)

this.progressBar.tick({
name: "✅📡 finished running predeploy checks remotely",
})
}

private async runLiveSafetyChecks() {
const { simpleGit } = this
const branches = await simpleGit.branchLocal()
const branch = await branches.current
if (branch !== "master")
this.printAndExit(
"To deploy to live please run from the master branch."
)

// Making sure we have the latest changes from the upstream
// Also, will fail if working copy is not clean
try {
const gitStatus = await simpleGit.status()
// gitStatus.isClean() checks for staged, unstaged, and untracked files
if (!gitStatus.isClean()) throw "Git working directory is not clean"

await simpleGit.pull("origin", undefined, { "--rebase": "true" })
} catch (err) {
this.printAndExit(JSON.stringify(err))
}

const response = await prompt({
type: "confirm",
name: "confirmed",
message: "Are you sure you want to deploy to live?",
})
if (!response?.confirmed) this.printAndExit("Cancelled")
}

private _simpleGit?: SimpleGit
private get simpleGit() {
if (!this._simpleGit)
this._simpleGit = simpleGit({
baseDir: this.options.owidGrapherRootDir,
binary: "git",
maxConcurrentProcesses: 1,
})
return this._simpleGit
}

private get pathsOnTarget() {
const { target, userRunningTheDeploy } = this.options
const owidUserHomeDir = "/home/owid"
const owidUserHomeTmpDir = `${owidUserHomeDir}/tmp`

return {
owidUserHomeDir,
owidUserHomeTmpDir,
rsyncTargetDir: `${owidUserHomeTmpDir}/${target}-${userRunningTheDeploy}`,
rsyncTargetDirTmp: `${owidUserHomeTmpDir}/${target}-${userRunningTheDeploy}-tmp`,
rsyncTargetDirForTests: `${owidUserHomeTmpDir}/${target}-tests`,
finalTargetDir: `${owidUserHomeDir}/${target}`,
oldRepoBackupDir: `${owidUserHomeTmpDir}/${target}-old`,
finalDataDir: `${owidUserHomeDir}/${target}-data`,
}
}

private get sshHost() {
return `owid@${this.targetIpAddress}`
}

private async writeHeadDotText() {
const { simpleGit } = this
const { owidGrapherRootDir } = this.options
const gitCommitSHA = await simpleGit.revparse(["HEAD"])

// Write the current commit SHA to public/head.txt so we always know which commit is deployed
fs.writeFileSync(
owidGrapherRootDir + "/public/head.txt",
gitCommitSHA,
"utf8"
)
this.progressBar.tick({ name: "✅ finished writing head.txt" })
}

// 📡 indicates that a task is running/ran on the remote server
async buildAndDeploy() {
const { skipChecks, runChecksRemotely } = this.options

if (this.targetIsProd) await this.runLiveSafetyChecks()
else if (!this.isValidTarget)
this.printAndExit(
"Please select either live or a valid test target."
)

this.progressBar.tick({
name: "✅ finished validating deploy arguments",
})

// make sure that no old assets are left over from an old deploy
await this.runAndTick(`yarn cleanTsc`)
await this.runAndTick(`yarn buildTsc`)

if (runChecksRemotely) await this.runPreDeployChecksRemotely()
else if (skipChecks) {
if (this.targetIsProd)
this.printAndExit(`Cannot skip checks when deploying to live`)
this.progressBar.tick({
name: "✅ finished checks because we skipped them",
})
} else {
await this.runAndTick(`yarn testJest`)
}

await this.writeHeadDotText()
await this.ensureTmpDirExistsOnServer()

await this.generateShellScriptsAndRunThemOnServer()

this.progressBar.tick({
name: `✅ 📡 finished everything`,
})
this.stream.replay()
}

// todo: the old deploy script would generete BASH on the fly and run it on the server. we should clean that up and remove these shell scripts.
private async generateShellScriptsAndRunThemOnServer(): Promise<void> {
const { target, owidGrapherRootDir } = this.options

const {
rsyncTargetDirTmp,
finalTargetDir,
rsyncTargetDir,
oldRepoBackupDir,
finalDataDir,
} = this.pathsOnTarget

const scripts: any = {
clearOldTemporaryRepo: `rm -rf ${rsyncTargetDirTmp}`,
copySyncedRepo: `cp -r ${rsyncTargetDir} ${rsyncTargetDirTmp}`, // Copy the synced repo-- this is because we're about to move it, and we want the original target to stay around to make future syncs faster
createDataSoftlinks: `mkdir -p ${finalDataDir}/bakedSite && ln -sf ${finalDataDir}/bakedSite ${rsyncTargetDirTmp}/bakedSite`,
createDatasetSoftlinks: `mkdir -p ${finalDataDir}/datasetsExport && ln -sf ${finalDataDir}/datasetsExport ${rsyncTargetDirTmp}/datasetsExport`,
createSettingsSoftlinks: `ln -sf ${finalDataDir}/.env ${rsyncTargetDirTmp}/.env`,
yarn: `cd ${rsyncTargetDirTmp} && yarn install --immutable`,
lernaBuild: `cd ${rsyncTargetDirTmp} && yarn lerna run build`,
vite: `cd ${rsyncTargetDirTmp} && yarn buildVite`,
migrateDb: `cd ${rsyncTargetDirTmp} && yarn runDbMigrations`,
algolia: `cd ${rsyncTargetDirTmp} && node --enable-source-maps --unhandled-rejections=strict itsJustJavascript/baker/algolia/configureAlgolia.js`,
createQueueFile: `cd ${rsyncTargetDirTmp} && touch .queue && chmod 0666 .queue`,
swapFolders: `rm -rf ${oldRepoBackupDir} && mv ${finalTargetDir} ${oldRepoBackupDir} || true && mv ${rsyncTargetDirTmp} ${finalTargetDir}`,
restartAdminServer: `pm2 restart ${target}`,
restartDeployQueueServer: `pm2 restart ${target}-deploy-queue`,
}

Object.keys(scripts).forEach((name) => {
const localPath = `${owidGrapherRootDir}/${TEMP_DEPLOY_SCRIPT_PREFIX}${name}.sh`
fs.writeFileSync(localPath, scripts[name], "utf8")
fs.chmodSync(localPath, "755")
})

await this.copyLocalRepoToServerTmpDirectory()

for await (const name of Object.keys(scripts)) {
await this.runAndStreamScriptOnRemoteServerViaSSH(
`${rsyncTargetDir}/${TEMP_DEPLOY_SCRIPT_PREFIX}${name}.sh`
)
const localPath = `${owidGrapherRootDir}/${TEMP_DEPLOY_SCRIPT_PREFIX}${name}.sh`
fs.removeSync(localPath)
}
}

printAndExit(message: string) {
// eslint-disable-next-line no-console
console.log(message)
process.exit()
}

private async ensureTmpDirExistsOnServer() {
const { sshHost } = this
const { owidUserHomeTmpDir } = this.pathsOnTarget
await execWrapper(`ssh ${sshHost} mkdir -p ${owidUserHomeTmpDir}`)
this.progressBar.tick({
name: `✅ 📡 finished ensuring ${owidUserHomeTmpDir} exists on ${sshHost}`,
})
}

private async copyLocalRepoToServerTmpDirectory() {
const { owidGrapherRootDir } = this.options
const { rsyncTargetDir } = this.pathsOnTarget
const RSYNC = `rsync -havz --no-perms --progress --delete --delete-excluded --prune-empty-dirs --exclude-from=${owidGrapherRootDir}/.rsync-ignore`
await execWrapper(
`${RSYNC} ${owidGrapherRootDir}/ ${this.sshHost}:${rsyncTargetDir}`
)
this.progressBar.tick({
name: `✅ 📡 finished rsync of ${owidGrapherRootDir} to ${this.sshHost} ${rsyncTargetDir}`,
})
}

private async runAndStreamScriptOnRemoteServerViaSSH(
path: string
): Promise<void> {
// eslint-disable-next-line no-console
console.log(`📡 Running ${path} on ${this.sshHost}`)
const bashTerminateIfAnyNonZero = "bash -e" // https://stackoverflow.com/questions/9952177/whats-the-meaning-of-the-parameter-e-for-bash-shell-command-line/9952249
const pseudoTty = "-tt" // https://stackoverflow.com/questions/7114990/pseudo-terminal-will-not-be-allocated-because-stdin-is-not-a-terminal
const params = [
pseudoTty,
this.sshHost,
bashTerminateIfAnyNonZero,
path,
]
const child = spawn(`ssh`, params)

child.stdout.on("data", (data) => {
const trimmed = data.toString().trim()
if (!trimmed) return
// eslint-disable-next-line no-console
console.log(trimmed)
})

child.stderr.on("data", (data) => {
const trimmed = data.toString().trim()
if (!trimmed) return
// eslint-disable-next-line no-console
console.error(trimmed)
})

const exitCode: number = await new Promise((resolve) => {
child.on("close", resolve)
})

if (exitCode !== 0) {
throw new Error(
`📡⛔️ failed running ${path} [exit code ${exitCode}]`
)
}

this.progressBar.tick({
name: `📡✅ finished running ${path}`,
})
}
}
36 changes: 36 additions & 0 deletions baker/ProgressStream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { WriteStream } from "tty"

// Wrap stderr before passing it to ProgressBar so we can save all writes
// and replay them at the end of the bake. Without this the progress bar class
// works fine, but there is no way to show the summary once the job is complete.
export class ProgressStream implements Partial<WriteStream> {
private wrappedStream: WriteStream
constructor(wrap: WriteStream) {
this.wrappedStream = wrap
}

isTTY = true

private allWrites: string[] = []

replay() {
console.log(this.allWrites.join(""))
}

write(buffer: string) {
this.allWrites.push(buffer)
return this.wrappedStream.write(buffer)
}

cursorTo(index: number) {
return this.wrappedStream.cursorTo(index)
}

clearLine(direction: 1) {
return this.wrappedStream.clearLine(direction)
}

get columns() {
return this.wrappedStream.columns
}
}
Loading

0 comments on commit 7eca78a

Please sign in to comment.