Skip to content

Commit

Permalink
Add backup and rebuild feature, suiting interactive and CI processes
Browse files Browse the repository at this point in the history
  • Loading branch information
rhyslbw committed May 29, 2020
1 parent 6e6ccb3 commit 5a1144c
Show file tree
Hide file tree
Showing 12 changed files with 337 additions and 11 deletions.
3 changes: 3 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
"clear": "^0.1.0",
"commander": "^5.0.0",
"cross-fetch": "^3.0.4",
"docker-compose": "^0.23.4",
"dockerode": "^3.2.0",
"figlet": "^1.3.0",
"fs-extra": "^9.0.0",
"inquirer": "^7.1.0",
Expand All @@ -47,6 +49,7 @@
},
"devDependencies": {
"@types/clear": "^0.1.0",
"@types/dockerode": "^2.5.29",
"@types/figlet": "^1.2.0",
"@types/fs-extra": "^8.1.0",
"@types/inquirer": "^6.5.0",
Expand Down
2 changes: 1 addition & 1 deletion cli/scripts/count_running_docker_containers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ docker ps \
-f name="$PROJECT_NAME_cardano-db-sync-extended" \
-f name="$PROJECT_NAME_hasura" \
-f name="$PROJECT_NAME_cardano-graphql" \
--format '{{.Names}}' | wc -l
--format '{{.Names}}' | wc -l
14 changes: 9 additions & 5 deletions cli/src/__test__/__snapshots__/smoke.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ exports[`Smoke Test Shows the program help if no arguments are passed 1`] = `
Usage: cgql [options] [command]
Options:
-V, --version output the version number
-h, --help display help for command
-V, --version output the version number
-h, --help display help for command
Commands:
init Initialize a Docker stack with secrets using a boilerplate
compose file.
help [command] display help for command
init Initialize a Docker stack with secrets using a
boilerplate compose file.
backup Make and restore backups of the stateful
services
rebuild-service [options] Drop and rebuild data volumes for the specified
service
help [command] display help for command
"
`;
80 changes: 80 additions & 0 deletions cli/src/commands/backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createCommand } from 'commander'
import * as inquirer from 'inquirer'
import * as path from 'path'
import * as fs from 'fs-extra'
import { DockerComposeStack } from '../docker'
import { serviceChoices, serviceFromName, servicesFromNames } from './util'

export function backup (stack: DockerComposeStack) {

const backupCmd = createCommand('backup')
.description('Make and restore backups of the stateful services')

backupCmd
.command('make')
.description('Backup a service. Omit options to be prompted')
.option('--services [services]', 'Comma separated list of service names', (names) => servicesFromNames(stack, names))
.action(async (cmd) => {
if (cmd.services === undefined) {
return inquirer
.prompt([{
name: 'services',
message: 'Select services to backup',
choices: serviceChoices(stack),
type: 'checkbox',
validate (input) {
if (input.length === 0) {
throw new Error('At least one service must be selected')
}
return true
}
}])
.then(({ services }) => stack.makeBackup(services))
} else {
await stack.makeBackup(cmd.services)
process.exit(0)
}
})

backupCmd
.command('restore')
.description('Restore a backup. Omit options to be prompted')
.option('--backup-file [backupFile]', 'Filename of the backup')
.option('--service [service]', 'Service name', (name) => serviceFromName(stack, name))
.action(async (cmd) => {
const { backupFile, service } = cmd
if (service === undefined && backupFile === undefined) {
return inquirer
.prompt([{
name: 'service',
message: 'Select service',
choices: serviceChoices(stack),
type: 'list',
validate (input) {
if (input.length === 0) {
throw new Error('At least one service must be selected')
}
return true
}
}, {
name: 'backupFile',
message: 'Select backup file to restore',
choices: ({ service }) => fs.readdir(path.join(stack.backupDir, service.name)),
type: 'list',
validate (input) {
if (input.length === 0) {
throw new Error('At least one backup must be selected')
}
return true
}
}])
.then(({ service, backupFile }) => stack.restoreBackup(service, backupFile))
} else if (service !== undefined && backupFile !== undefined) {
await stack.restoreBackup(service, backupFile)
process.exit(0)
} else {
cmd.help()
}
})
return backupCmd
}
2 changes: 2 additions & 0 deletions cli/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './backup'
export * from './init'
export * from './rebuildService'
6 changes: 4 additions & 2 deletions cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import * as path from 'path'
import { ensureValue } from '../util'
import fetch from 'cross-fetch'

const ui = new inquirer.ui.BottomBar()

export const init = createCommand('init')
.description('Initialize a Docker stack with secrets using a boilerplate compose file.')
.action(async () => {
Expand All @@ -14,7 +16,7 @@ export const init = createCommand('init')
'https://raw.githubusercontent.com/input-output-hk/cardano-graphql/master/docker-compose.yml'
)
await dockerComposeFile.write(await response.text())
console.log(`docker-compose.yml created`)
ui.log.write('docker-compose.yml created')
return inquirer
.prompt([{
name: 'user',
Expand Down Expand Up @@ -44,6 +46,6 @@ export const init = createCommand('init')
writeFile(path.join(secretsDirPath, 'postgres_password'), password),
writeFile(path.join(secretsDirPath, 'postgres_user'), user)
])
console.log('PostgreSQL credentials created')
ui.log.write('PostgreSQL credentials created')
})
})
41 changes: 41 additions & 0 deletions cli/src/commands/rebuildService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createCommand } from 'commander'
import * as inquirer from 'inquirer'
import { DockerComposeStack } from '../docker'
import { serviceChoices, servicesFromNames } from './util'

export function rebuildService (stack: DockerComposeStack) {
return createCommand('rebuild-service')
.description('Drop and rebuild data volumes for the specified service')
.option('--services [services]', 'Service name', (names) => servicesFromNames(stack, names))
.option('--no-backup', 'Rebuild without making a backup')
.action(async (cmd) => {
const { backup, service } = cmd
if (backup === false) {
await stack.rebuildService(service, backup)
process.exit(0)
} else {
return inquirer
.prompt([{
name: 'services',
message: 'Select services',
choices: serviceChoices(stack),
type: 'checkbox',
validate (input) {
if (input.length === 0) {
throw new Error('At least one service must be selected')
}
return true
}
}, {
name: 'makeBackup',
default: true,
message: 'Make a backup first?',
type: 'confirm'
}])
.then(async (answers) => {
const { makeBackup, services } = answers
await stack.rebuildService(services, makeBackup)
})
}
})
}
18 changes: 18 additions & 0 deletions cli/src/commands/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { DockerComposeStack } from '../docker'

export function serviceChoices (stack: DockerComposeStack) {
return [...stack.statefulServices.values()].map(service => {
return { name: service.name, value: service }
})
}

export function serviceFromName (stack: DockerComposeStack, name: string) {
if (!stack.statefulServices.has(name)) {
throw new Error(`Unknown service ${name}`)
}
return stack.statefulServices.get(name)
}

export function servicesFromNames (stack: DockerComposeStack, names: string) {
return names.split(',').map((name: string) => serviceFromName(stack, name))
}
145 changes: 145 additions & 0 deletions cli/src/docker/DockerComposeStack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import * as fs from 'fs-extra'
import * as path from 'path'
import * as dockerCompose from 'docker-compose'
import * as Docker from 'dockerode'
import { cardanoNode, postgres, StatefulService } from './'

type logFunc = (message: string) => void

export class DockerComposeStack {
readonly docker: Docker

readonly log: logFunc

readonly backupDir: string

readonly projectName: string

public statefulServices: Map<string, StatefulService>

constructor ({
log = console.log as logFunc,
projectName = 'cardano-graphql',
backupDir = path.join(process.cwd(), 'backups'),
statefulServices = new Map([['postgres', postgres], ['cardano-node', cardanoNode]])
}) {
this.log = log
this.statefulServices = statefulServices
this.projectName = projectName
this.backupDir = backupDir
this.docker = new Docker()
}

// ************* LIFECYCLE ************************

public up () {
this.log('docker-compose up')
return dockerCompose.upAll({
composeOptions: ['-p', this.projectName]
})
}

public down (commandOptions?: string[]) {
this.log('docker-compose down')
return dockerCompose.down({
commandOptions
})
}

public stop () {
this.log('docker-compose stop')
return dockerCompose.stop({
composeOptions: ['-p', this.projectName]
})
}

// *************************************************

public async makeBackup (statefulServices: StatefulService[]): Promise<void> {
await this.stop()
for (const service of statefulServices) {
const hostBackupDir = this.serviceBackupDir(service)
await fs.ensureDir(hostBackupDir)
this.log(`Backing up service ${service.name}`)
await this.docker.run(
'ubuntu',
['bash', '-c', `cd ${service.dataPath} && tar cvf /backup/${service.volumeName}_${Date.now()}.tar .`],
process.stdout,
{
HostConfig: {
AutoRemove: true,
Binds: [
`${hostBackupDir}:/backup`,
`${this.volumeName(service)}:${service.dataPath}`
]
}
}
)
}
await this.up()
}

public async restoreBackup (service: StatefulService, backupFile: string): Promise<void> {
await this.validateBackup(service, backupFile)
await this.down()
await this.removeServiceVolumes([service])
await this.createServiceVolumes([service])
await this.docker.run(
'ubuntu',
['bash', '-c', `cd /restore && tar xvf /backup.tar`],
process.stdout,
{
HostConfig: {
AutoRemove: true,
Binds: [
`${this.volumeName(service)}:/restore`,
`${path.join(this.serviceBackupDir(service), backupFile)}:/backup.tar`
]
}
}
)
await this.up()
}

public async rebuildService (services: StatefulService[], backup = true): Promise<void> {
try {
if (backup === true) {
await this.makeBackup(services)
}
await this.down()
await this.removeServiceVolumes(services)
await this.up()
} catch (error) {
console.error(error)
}
}

private async createServiceVolumes (services: StatefulService[]): Promise<void> {
for (const service of services) {
this.log(`Creating volume ${service.volumeName}...`)
await this.docker.createVolume({
Name: this.volumeName(service)
})
}
}

private async removeServiceVolumes (services: StatefulService[]): Promise<void> {
for (const service of services) {
this.log(`Removing volume ${service.volumeName}...`)
await this.docker.getVolume(this.volumeName(service)).remove()
}
}

public async validateBackup (service: StatefulService, backupFile: string): Promise<void> {
const fileStat = await fs.stat(path.join(this.serviceBackupDir(service), backupFile))
if (!fileStat.isFile()) throw new Error('Invalid backup')
}

private serviceBackupDir (service: StatefulService) {
return path.join(this.backupDir, service.name)
}

private volumeName (service: StatefulService) {
return `${this.projectName}_${service.volumeName}`
}
}
2 changes: 2 additions & 0 deletions cli/src/docker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './DockerComposeStack'
export * from './services'
17 changes: 17 additions & 0 deletions cli/src/docker/services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface StatefulService {
name: string,
dataPath: string
volumeName: string
}

export const cardanoNode: StatefulService = {
name: 'cardano-node',
volumeName: 'node-db',
dataPath: '/data/db'
}

export const postgres: StatefulService = {
name: 'postgres',
dataPath: '/var/lib/postgresql/data',
volumeName: 'postgres-data'
}
Loading

0 comments on commit 5a1144c

Please sign in to comment.