Skip to content

Commit

Permalink
fix(pg): Restoring behavior for backup scheduling (#2981)
Browse files Browse the repository at this point in the history
* Restoring behavior for backup scheduling

* Adding unit tests
  • Loading branch information
sbosio authored Aug 19, 2024
1 parent d11c18f commit bfac653
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 83 deletions.
Empty file modified .husky/pre-commit
100644 → 100755
Empty file.
64 changes: 30 additions & 34 deletions packages/cli/src/commands/pg/backups/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,25 @@ import {Args, ux} from '@oclif/core'
import pgHost from '../../../lib/pg/host'
import {getAttachment} from '../../../lib/pg/fetcher'
import {PgDatabase} from '../../../lib/pg/types'
import {HTTPError} from 'http-call'

type Timezone = {
PST: string;
PDT: string;
MST: string;
MDT: string;
CST: string;
CDT: string;
EST: string;
EDT: string;
Z: string;
GMT: string;
BST: string;
CET: string;
CEST: string;
PST: string
PDT: string
MST: string
MDT: string
CST: string
CDT: string
EST: string
EDT: string
Z: string
GMT: string
BST: string
CET: string
CEST: string
}

const TZ:Timezone = {
const TZ: Timezone = {
PST: 'America/Los_Angeles',
PDT: 'America/Los_Angeles',
MST: 'America/Boise',
Expand All @@ -38,39 +39,34 @@ const TZ:Timezone = {
}

type BackupSchedule = {
hour: string;
timezone: string;
schedule_name?: string;
hour: string
timezone: string
schedule_name?: string
}

export default class Schedule extends Command {
static topic = 'pg';
static description = 'schedule daily backups for given database';
static topic = 'pg'
static description = 'schedule daily backups for given database'
static flags = {
at: flags.string({required: true, description: "at a specific (24h) hour in the given timezone. Defaults to UTC. --at '[HOUR]:00 [TIMEZONE]'"}),
app: flags.app({required: true}),
remote: flags.remote(),
};
}

static args = {
database: Args.string(),
};
}

parseDate = function (at: string): BackupSchedule {
const m = at.match(/^([0-2]?[0-9]):00 ?(\S*)$/)
if (!m)
throw new Error("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'")
const [, hour, timezone] = m
let scheduledTZ = TZ[timezone.toUpperCase() as keyof Timezone]
if (!scheduledTZ) {
scheduledTZ = 'UTC'
if (timezone) {
ux.warn(`Unknown timezone ${color.yellow(timezone)}. Defaulting to UTC.`)
}
const m = at.match(/^(0?\d|1\d|2[0-3]):00 ?(\S*)$/)

if (m) {
const [, hour, timezone] = m
return {hour, timezone: TZ[timezone.toUpperCase() as keyof Timezone] || timezone || 'UTC'}
}

return {hour, timezone: scheduledTZ}
};
return ux.error("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'", {exit: 1})
}

public async run(): Promise<void> {
const {flags, args} = await this.parse(Schedule)
Expand All @@ -83,7 +79,7 @@ export default class Schedule extends Command {
const at = color.cyan(`${schedule.hour}:00 ${schedule.timezone}`)

const pgResponse = await this.heroku.get<PgDatabase>(`/client/v11/databases/${db.id}`, {hostname: pgHost()})
.catch(error => {
.catch((error: HTTPError) => {
if (error.statusCode !== 404)
throw error
ux.error(`${color.yellow(db.name)} is not yet provisioned.\nRun ${color.cyan.bold('heroku addons:wait')} to wait until the db is provisioned.`, {exit: 1})
Expand Down
182 changes: 133 additions & 49 deletions packages/cli/test/unit/commands/pg/backups/schedule.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,127 @@ import * as nock from 'nock'
import heredoc from 'tsheredoc'
import {expect} from 'chai'
import stripAnsi = require('strip-ansi')
import {CLIError} from '@oclif/core/lib/errors'
import {HTTPError} from 'http-call'

const shouldSchedule = function (cmdRun: (args: string[]) => Promise<any>) {
const continuousProtectionWarning = heredoc('Logical backups of large databases are likely to fail.')
describe('pg:backups:schedule', function () {
let api: nock.Scope
let data: nock.Scope

context('with correct arguments', function () {
const continuousProtectionWarning = 'Logical backups of large databases are likely to fail.'

beforeEach(function () {
api = nock('https://api.heroku.com')
.post('/actions/addon-attachments/resolve', {
app: 'myapp',
addon_attachment: 'DATABASE_URL',
addon_service: 'heroku-postgresql',
})
.reply(200, [
{
addon: {
id: 1,
name: 'postgres-1',
plan: {name: 'heroku-postgresql:standard-0'},
},
config_vars: [
'DATABASE_URL',
],
name: 'DATABASE',
},
])
data = nock('https://api.data.heroku.com')
.post('/client/v11/databases/1/transfer-schedules', {
hour: '06',
timezone: 'America/New_York',
schedule_name: 'DATABASE_URL',
})
.reply(201)
})

afterEach(function () {
nock.cleanAll()
api.done()
data.done()
})

it('schedules a backup', async function () {
const dbA = {info: [
{name: 'Continuous Protection', values: ['On']},
]}
nock('https://api.data.heroku.com')
.get('/client/v11/databases/1')
.reply(200, dbA)

await runCommand(Cmd, ['--at', '06:00 EDT', '--app', 'myapp'])

expect(stdout.output).to.equal('')
expect(stderr.output).to.include(heredoc(`
Scheduling automatic daily backups of postgres-1 at 06:00 America/New_York...
Scheduling automatic daily backups of postgres-1 at 06:00 America/New_York... done
`))
})

it('warns user that logical backups are error prone if continuous protection is on', async function () {
const dbA = {info: [
{name: 'Continuous Protection', values: ['On']},
]}
nock('https://api.data.heroku.com')
.get('/client/v11/databases/1')
.reply(200, dbA)

await runCommand(Cmd, ['--at', '06:00 EDT', '--app', 'myapp'])

expect(stripAnsi(stderr.output)).to.include(continuousProtectionWarning)
})

it('does not warn user that logical backups are error prone if continuous protection is off', async function () {
const dbA = {info: [
{name: 'Continuous Protection', values: ['Off']},
]}
nock('https://api.data.heroku.com')
.get('/client/v11/databases/1')
.reply(200, dbA)

await runCommand(Cmd, ['--at', '06:00 EDT', '--app', 'myapp'])

expect(stripAnsi(stderr.output)).not.to.include(continuousProtectionWarning)
})
})

it('errors when the scheduled time has an invalid hour value', async function () {
try {
await runCommand(Cmd, ['--at', '24:00', '--app', 'myapp'])
} catch (error: unknown) {
const {message, oclif} = error as CLIError
expect(message).to.eq("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'")
expect(oclif.exit).to.equal(1)
}
})

beforeEach(function () {
nock('https://api.heroku.com')
it('errors when the scheduled time has an invalid time zone value', async function () {
try {
await runCommand(Cmd, ['--at', '01:00 New York', '--app', 'myapp'])
} catch (error: unknown) {
const {message, oclif} = error as CLIError
expect(message).to.eq("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'")
expect(oclif.exit).to.equal(1)
}
})

it('errors when the scheduled time specifies minutes', async function () {
try {
await runCommand(Cmd, ['--at', '06:15 EDT', '--app', 'myapp'])
} catch (error: unknown) {
const {message, oclif} = error as CLIError
expect(message).to.eq("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'")
expect(oclif.exit).to.equal(1)
}
})

it('accepts a correctly formatted time string even if the time zone might not be correct', async function () {
api = nock('https://api.heroku.com')
.post('/actions/addon-attachments/resolve', {
app: 'myapp',
addon_attachment: 'DATABASE_URL',
Expand All @@ -29,56 +144,25 @@ const shouldSchedule = function (cmdRun: (args: string[]) => Promise<any>) {
name: 'DATABASE',
},
])
nock('https://api.data.heroku.com')
data = nock('https://api.data.heroku.com')
.get('/client/v11/databases/1')
.reply(200, {info: []})
.post('/client/v11/databases/1/transfer-schedules', {
hour: '06',
timezone: 'America/New_York',
timezone: 'New_York',
schedule_name: 'DATABASE_URL',
})
.reply(201)
})
afterEach(function () {
nock.cleanAll()
})
.reply(400, {id: 'bad_request', message: 'Bad request.'})

it('schedules a backup', async function () {
const dbA = {info: [
{name: 'Continuous Protection', values: ['On']},
]}
nock('https://api.data.heroku.com')
.get('/client/v11/databases/1')
.reply(200, dbA)
await cmdRun(['--at', '06:00 EDT', '--app', 'myapp'])
expect(stdout.output).to.equal('')
expect(stderr.output).to.include(heredoc(`
Scheduling automatic daily backups of postgres-1 at 06:00 America/New_York...
Scheduling automatic daily backups of postgres-1 at 06:00 America/New_York... done
`))
})

it('warns user that logical backups are error prone if continuous protection is on', async function () {
const dbA = {info: [
{name: 'Continuous Protection', values: ['On']},
]}
nock('https://api.data.heroku.com')
.get('/client/v11/databases/1')
.reply(200, dbA)
await cmdRun(['--at', '06:00 EDT', '--app', 'myapp'])
expect(stripAnsi(stderr.output)).to.include(continuousProtectionWarning)
})
try {
await runCommand(Cmd, ['--at', '06:00 New_York', '--app', 'myapp'])
} catch (error: unknown) {
const {message} = error as CLIError
expect(message).to.contain('Bad request.')
}

it('does not warn user that logical backups are error prone if continuous protection is off', async function () {
const dbA = {info: [
{name: 'Continuous Protection', values: ['Off']},
]}
nock('https://api.data.heroku.com')
.get('/client/v11/databases/1')
.reply(200, dbA)
await cmdRun(['--at', '06:00 EDT', '--app', 'myapp'])
expect(stripAnsi(stderr.output)).not.to.include(continuousProtectionWarning)
api.done()
data.done()
nock.cleanAll()
})
}

describe('pg:backups:schedule', function () {
shouldSchedule((args: string[]) => runCommand(Cmd, args))
})

0 comments on commit bfac653

Please sign in to comment.