Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions V12-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Many commands now resolve add-ons, add-on attachments, and Postgres databases th

- `pg:backups:unschedule` now names the `DATABASE` argument, or the auto-selected schedule name, in the “no daily backups found” error instead of the add-on resource name.
- `pg:backups:schedule` now names the `DATABASE` argument, or `DATABASE_URL` when that argument is omitted, in the "not yet provisioned" error instead of the add-on resource name. The scheduling spinner still shows that add-on name when `database.describe` returns `name`; if `name` is absent it falls back to the `DATABASE` argument, then `DATABASE_URL`.
- `pg:backups:capture` now names the `DATABASE` argument, or `DATABASE_URL` when that argument is omitted, in the "not yet provisioned" error instead of the add-on resource name. The backup spinner still shows the resolved add-on name, since `captureAndWait` resolves it independently.

## Run commands

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"@heroku/heroku-fetch": "^0.1.1-beta",
"@heroku/http-call": "^5.5.1",
"@heroku/mcp-server": "^1.2.9",
"@heroku/sdk": "^0.8.1",
"@heroku/sdk": "^0.9.0",
"@heroku/types": "^5.2.0",
"@inquirer/prompts": "^8.7",
"@oclif/core": "^4.8.4",
Expand Down
70 changes: 41 additions & 29 deletions src/commands/pg/backups/capture.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {Command, flags} from '@heroku-cli/command'
import {color, utils} from '@heroku/heroku-cli-util'
import {HTTPError} from '@heroku/http-call'
import {color} from '@heroku/heroku-cli-util'
import {NotFoundError} from '@heroku/heroku-fetch'
import {HerokuSDK} from '@heroku/sdk'
import {backupExtensions, databaseExtensions, TransferFailedError} from '@heroku/sdk/extensions/data'
import {Args, ux} from '@oclif/core'
import tsheredoc from 'tsheredoc'

import backupsApi from '../../../lib/pg/backups.js'
import {BackupTransfer, PgDatabase} from '../../../lib/pg/types.js'
import * as pgBackups from '../../../lib/pg/backups.js'
import {nls} from '../../../nls.js'

const heredoc = tsheredoc
Expand All @@ -29,47 +30,58 @@ export default class Capture extends Command {
const {database} = args

const interval = Math.max(3, Number.parseInt(waitInterval || '3', 10))
const dbResolver = new utils.pg.DatabaseResolver(this.heroku)
const {addon: db} = await dbResolver.getAttachment(app, database)
const pgBackupsApi = backupsApi(app, this.heroku)
const {data} = new HerokuSDK({extensions: [backupExtensions, databaseExtensions]})

try {
const {body: dbInfo} = await this.heroku.get<PgDatabase>(`/client/v11/databases/${db.id}`, {hostname: utils.pg.host()})
const dbProtected = /On/.test(dbInfo.info.find(attribute => attribute.name === 'Continuous Protection')?.values[0] || '')
const dbInfo = await data.database.describe(app, database)
const dbProtected = /On/.test(String(dbInfo.info?.find(attribute => attribute.name === 'Continuous Protection')?.values?.[0]))
if (dbProtected) {
ux.warn('Continuous protection is already enabled for this database. Logical backups of large databases are likely to fail.')
ux.warn('See https://devcenter.heroku.com/articles/heroku-postgres-data-safety-and-continuous-protection#physical-backups-on-heroku-postgres.')
}
} catch (error: unknown) {
const httpError = error as HTTPError
if (httpError.statusCode !== 404)
throw httpError
} catch (error) {
if (!(error instanceof NotFoundError))
Comment thread
erika-wallace marked this conversation as resolved.
throw error
ux.error(
heredoc`
${color.datastore(db.name)} is not yet provisioned.
${color.datastore(database ?? 'DATABASE_URL')} is not yet provisioned.
Run ${color.code('heroku addons:wait')} to wait until the db is provisioned.
`,
{exit: 1},
)
}

ux.action.start(`Starting backup of ${color.datastore(db.name)}`)
const {body: backup} = await this.heroku.post<BackupTransfer>(`/client/v11/databases/${db.id}/backups`, {hostname: utils.pg.host()})
ux.action.stop()
ux.stdout(heredoc`
const waitOptions = pgBackups.constructWaitOptions(interval, verbose, backup => `Backing up ${backup.from_name ? color.datastore(backup.from_name) : 'UNKNOWN'} to ${color.cyan(pgBackups.name(backup))}`)

Use Ctrl-C at any time to stop monitoring progress; the backup will continue running.
Use ${color.code('heroku pg:backups:info')} to check progress.
Stop a running backup with ${color.code('heroku pg:backups:cancel')}.
`)
try {
await data.backup.captureAndWait(app, database, {
capturePoller: {
onStart(db) {
ux.action.start(`Starting backup of ${color.datastore(db.name)}`)
},
onStop(db) {
ux.action.stop()
ux.stdout(heredoc`

if (app !== db.app.name) {
ux.stdout(heredoc`
HINT: You are running this command with a non-billing application.
Use ${color.code('heroku pg:backups -a ' + db.app.name)} to check the list of backups.
`)
}
Use Ctrl-C at any time to stop monitoring progress; the backup will continue running.
Use ${color.code('heroku pg:backups:info')} to check progress.
Stop a running backup with ${color.code('heroku pg:backups:cancel')}.
`)

await pgBackupsApi.wait(`Backing up ${color.datastore(backup.from_name)} to ${color.cyan(pgBackupsApi.name(backup))}`, backup.uuid, interval, verbose, db.app.name || app)
if (app !== db.app.name) {
ux.stdout(heredoc`
HINT: You are running this command with a non-billing application.
Use ${color.code('heroku pg:backups -a ' + db.app.name)} to check the list of backups.
`)
}
},
},
verbose,
...waitOptions,
})
} catch (error) {
if (!(error instanceof TransferFailedError)) throw error
pgBackups.reportTransferFailure(error, 'backup')
}
}
}
58 changes: 35 additions & 23 deletions src/commands/pg/backups/restore.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import {Command, flags} from '@heroku-cli/command'
import {color, utils} from '@heroku/heroku-cli-util'
import {color} from '@heroku/heroku-cli-util'
import {HerokuSDK} from '@heroku/sdk'
import {restoreExtensions, TransferFailedError} from '@heroku/sdk/extensions/data'
import {Args, ux} from '@oclif/core'
import tsheredoc from 'tsheredoc'

import type {BackupTransfer} from '../../../lib/pg/types.js'

import ConfirmCommand from '../../../lib/confirm-command.js'
import backupsFactory from '../../../lib/pg/backups.js'
import * as pgBackups from '../../../lib/pg/backups.js'
import {nls} from '../../../nls.js'

const heredoc = tsheredoc
Expand Down Expand Up @@ -68,9 +68,8 @@ export default class Restore extends Command {
const {args, flags} = await this.parse(Restore)
const {app, confirm, extensions, verbose, 'wait-interval': waitInterval} = flags
const interval = Math.max(3, waitInterval)
const dbResolver = new utils.pg.DatabaseResolver(this.heroku)
const {addon: db} = await dbResolver.getAttachment(app as string, args.database)
const pgbackups = backupsFactory(app, this.heroku)
const {data} = new HerokuSDK({extensions: [restoreExtensions]})

let backupURL
let backupName = args.backup

Expand All @@ -84,12 +83,12 @@ export default class Restore extends Command {
backupApp = app
}

const {body: transfers} = await this.heroku.get<BackupTransfer[]>(`/client/v11/apps/${backupApp}/transfers`, {hostname: utils.pg.host()})
const transfers = await data.transfer.listByApp(backupApp)
const backups = transfers.filter(t => t.from_type === 'pg_dump' && t.to_type === 'gof3r')

let backup
if (backupName) {
backup = backups.find(b => pgbackups.name(b) === backupName)
backup = backups.find(b => pgBackups.name(b) === backupName)
if (!backup)
throw new Error(`Backup ${color.cyan(backupName)} not found for ${color.app(backupApp)}`)
if (!backup.succeeded)
Expand All @@ -108,28 +107,41 @@ export default class Restore extends Command {
throw new Error(`No backups for ${color.app(backupApp)}. Capture one with ${color.code('heroku pg:backups:capture')}`)
}

backupName = pgbackups.name(backup)
backupName = pgBackups.name(backup)
}

backupURL = backup.to_url
}

const confirmCmd = new ConfirmCommand()
await confirmCmd.confirm(app, confirm)
ux.action.start(`Starting restore of ${color.cyan(backupName)} to ${color.datastore(db.name)}`)
ux.stdout(heredoc(`

Use Ctrl-C at any time to stop monitoring progress; the backup will continue restoring.
Use ${color.code('heroku pg:backups')} to check progress.
Stop a running restore with ${color.code('heroku pg:backups:cancel')}.
`))

const {body: restore} = await this.heroku.post<{uuid: string}>(`/client/v11/databases/${db.id}/restores`, {
body: {backup_url: backupURL, extensions: this.getSortedExtensions(extensions as string)}, hostname: utils.pg.host(),
})

ux.action.stop()
await pgbackups.wait('Restoring', restore.uuid, interval, verbose, db.app.id as string)
const waitOptions = pgBackups.constructWaitOptions(interval, verbose, () => 'Restoring')

try {
await data.restore.restoreAndWait(app, args.database, backupURL as string, {
extensions: this.getSortedExtensions(extensions as string),
restorePoller: {
onStart(addon) {
ux.action.start(`Starting restore of ${color.cyan(backupName)} to ${color.datastore(addon.name)}`)
ux.stdout(heredoc(`

Use Ctrl-C at any time to stop monitoring progress; the backup will continue restoring.
Use ${color.code('heroku pg:backups')} to check progress.
Stop a running restore with ${color.code('heroku pg:backups:cancel')}.
`))
},
onStop() {
ux.action.stop()
},
},
verbose,
...waitOptions,
})
} catch (error) {
if (!(error instanceof TransferFailedError)) throw error
pgBackups.reportTransferFailure(error, 'restore')
}
}
}

Expand Down
66 changes: 61 additions & 5 deletions src/lib/pg/backups.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import type {HerokuSDK} from '@heroku/sdk'
import type {TransferFailedError} from '@heroku/sdk/extensions/data'
import type {CaptureAndWaitOptions, RestoreAndWaitOptions, WaitForTransferOptions} from '@heroku/sdk/extensions/data'
import type {TransferInfoByAppResult} from '@heroku/types/data'

import {color, utils} from '@heroku/heroku-cli-util'
import {ux} from '@oclif/core/ux'
import bytes from 'bytes'
import tsheredoc from 'tsheredoc'

const heredoc = tsheredoc

type Data = HerokuSDK['data']

Expand Down Expand Up @@ -86,15 +92,65 @@ function prefix(transfer: TransferInfoByAppResult) {
}
}

// TODO: Temporary default export so pg:backups:capture, pg:backups:restore, and pg:copy still compile. Remove it when those commands move to @heroku/sdk.
type TransferWaitOptions
= Pick<CaptureAndWaitOptions | RestoreAndWaitOptions, 'waitPoller'>
& Pick<WaitForTransferOptions, 'intervalMs' | 'onPoll'>

/**
* Wait-phase options for `captureAndWait` and `restoreAndWait`.
* Spread the result into the SDK call.
*/
export function constructWaitOptions(
interval: number,
verbose: boolean | undefined,
action: (transfer: TransferInfoByAppResult) => string,
): TransferWaitOptions {
const logs = new LogDisplay()
const tty = process.env.TERM !== 'dumb' && Boolean(process.stderr.isTTY)

return {
intervalMs: interval * 1000,
onPoll(transfer) {
if (verbose) {
logs.displayLogs(transfer?.logs)
} else if (tty) {
const msg = transfer?.started_at ? filesize(transfer.processed_bytes) : 'pending'
const log = transfer?.logs?.pop()
ux.action.status = log ? `${msg}\n${log.created_at + ' ' + log.message}` : msg
}
},
waitPoller: {
onStart(transfer) {
const label = action(transfer)
if (verbose) {
ux.stdout(`${label}...`)
}

ux.action.start(label)
},
onStop: () => ux.action.stop(),
},
}
}

export function reportTransferFailure(error: TransferFailedError, transferType: 'backup' | 'restore'): void {
ux.action.stop('!')

const {transfer} = error

ux.error(new Error(heredoc(`
An error occurred and the ${transferType} did not finish.

${(transfer.logs || []).slice(-5).map(l => l.message).join('\n')}

Run ${color.code('heroku pg:backups:info ' + name(transfer))} for more details.`)))
}

// TODO: Temporary default export so pg:copy still compiles. Remove it when that command moves to @heroku/sdk.
import {APIClient} from '@heroku-cli/command'
import {color, utils} from '@heroku/heroku-cli-util'
import tsheredoc from 'tsheredoc'

import type {BackupTransfer} from './types.js'

const heredoc = tsheredoc

export default function backupsFactory(app: string, heroku: APIClient) {
const logs = new LogDisplay()

Expand Down
Loading
Loading