Skip to content
Draft
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
72 changes: 72 additions & 0 deletions src/commands/data/pg/get-ca.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {Command, flags} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import fs from 'fs-extra'
import fetch from 'node-fetch'
import os from 'node:os'
import path from 'node:path'

const RDS_CERTIFICATE_HOST = 'https://truststore.pki.rds.amazonaws.com'
const REGION_ALIASES: Record<string, string> = {
eu: 'eu-west-1',
us: 'us-east-1',
}

export default class DataPgGetCa extends Command {
static description = 'download the RDS CA bundle for a Heroku region'
static examples = [
'<%= config.bin %> <%= command.id %> --region virginia',
'<%= config.bin %> <%= command.id %> --region global',
]
static flags = {
region: flags.string({
description: 'Heroku region or global for the AWS global CA bundle',
required: true,
}),
}

public async awsRegion(herokuRegion: string): Promise<string> {
if (REGION_ALIASES[herokuRegion]) return REGION_ALIASES[herokuRegion]

const {body: regions} = await this.heroku.get<Heroku.Region[]>('/regions')
const region = regions.find(candidate => candidate.name === herokuRegion)
const awsRegion = region?.provider?.region

if (!awsRegion) throw new Error(`${herokuRegion} is not a Heroku region backed by AWS.`)

return awsRegion
}

public destinationDirectory(): string {
if (process.platform === 'win32') {
if (!process.env.APPDATA) throw new Error('APPDATA is not set; unable to determine the PostgreSQL certificate directory.')

return path.join(process.env.APPDATA, 'postgresql')
}
Comment on lines +40 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destinationDirectory is stubbed in all tests, so nothing truly exercises this code path in ci. I guess it will likely work... should we have a flag for customers to define the destination directory in case of issues or alternative install directories?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: the stubbing, maybe we just don't stub the destinationDirectory. We test across windows, osx, ubuntu boxes and I think this may "just" work unstubbed.


return path.join(os.homedir(), '.postgres')
}

public async download(url: string): Promise<Buffer> {
const response = await fetch(url)
if (!response.ok) throw new Error(`AWS RDS returned ${response.status} ${response.statusText}.`)

return response.buffer()
}

public async run(): Promise<void> {
const {flags} = await this.parse(DataPgGetCa)
const awsRegion = flags.region === 'global' ? 'global' : await this.awsRegion(flags.region)
const fileName = `${awsRegion}-bundle.pem`
const destination = path.join(this.destinationDirectory(), fileName)
const url = `${RDS_CERTIFICATE_HOST}/${awsRegion}/${fileName}`

try {
const certificate = await this.download(url)
await fs.outputFile(destination, certificate, {mode: 0o600})
this.log(`RDS CA bundle retrieved successfully: ${destination}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.error(`Unable to retrieve the RDS CA bundle at ${destination}: ${message}`)
}
}
}
115 changes: 115 additions & 0 deletions test/unit/commands/data/pg/get-ca.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {runCommand} from '@heroku-cli/test-utils'
import {expect} from 'chai'
import fs from 'fs-extra'
import {
afterEach,
beforeEach,
describe,
it,
} from 'mocha'
import nock from 'nock'
import {restore, SinonStub, stub} from 'sinon'

import DataPgGetCa from '../../../../../src/commands/data/pg/get-ca.js'

describe('data:pg:get-ca', function () {
const certificate = '-----BEGIN CERTIFICATE-----\ncertificate\n-----END CERTIFICATE-----\n'
const destinationDirectory = '/tmp/postgres'
let outputFileStub: SinonStub

beforeEach(function () {
stub(DataPgGetCa.prototype, 'destinationDirectory').returns(destinationDirectory)
outputFileStub = stub(fs, 'outputFile').resolves()
})

afterEach(function () {
restore()
nock.cleanAll()
})

it('downloads the CA bundle for an AWS-backed Heroku region', async function () {
nock('https://api.heroku.com')
.get('/regions')
.reply(200, [{name: 'virginia', provider: {region: 'us-east-1'}}])
nock('https://truststore.pki.rds.amazonaws.com')
.get('/us-east-1/us-east-1-bundle.pem')
.reply(200, certificate)

const {stdout} = await runCommand(DataPgGetCa, ['--region', 'virginia'])

expect(outputFileStub.calledOnceWith(
'/tmp/postgres/us-east-1-bundle.pem',
Buffer.from(certificate),
{mode: 0o600},
)).to.be.true
expect(stdout).to.equal('RDS CA bundle retrieved successfully: /tmp/postgres/us-east-1-bundle.pem\n')
})

it('downloads the AWS global CA bundle without requesting Heroku regions', async function () {
nock('https://truststore.pki.rds.amazonaws.com')
.get('/global/global-bundle.pem')
.reply(200, certificate)

const {stdout} = await runCommand(DataPgGetCa, ['--region', 'global'])

expect(outputFileStub.calledOnceWith(
'/tmp/postgres/global-bundle.pem',
Buffer.from(certificate),
{mode: 0o600},
)).to.be.true
expect(stdout).to.equal('RDS CA bundle retrieved successfully: /tmp/postgres/global-bundle.pem\n')
})

it('downloads the US Common Runtime CA bundle without requesting Heroku regions', async function () {
nock('https://truststore.pki.rds.amazonaws.com')
.get('/us-east-1/us-east-1-bundle.pem')
.reply(200, certificate)

const {stdout} = await runCommand(DataPgGetCa, ['--region', 'us'])

expect(outputFileStub.calledOnceWith(
'/tmp/postgres/us-east-1-bundle.pem',
Buffer.from(certificate),
{mode: 0o600},
)).to.be.true
expect(stdout).to.equal('RDS CA bundle retrieved successfully: /tmp/postgres/us-east-1-bundle.pem\n')
})

it('downloads the EU Common Runtime CA bundle without requesting Heroku regions', async function () {
nock('https://truststore.pki.rds.amazonaws.com')
.get('/eu-west-1/eu-west-1-bundle.pem')
.reply(200, certificate)

const {stdout} = await runCommand(DataPgGetCa, ['--region', 'eu'])

expect(outputFileStub.calledOnceWith(
'/tmp/postgres/eu-west-1-bundle.pem',
Buffer.from(certificate),
{mode: 0o600},
)).to.be.true
expect(stdout).to.equal('RDS CA bundle retrieved successfully: /tmp/postgres/eu-west-1-bundle.pem\n')
})

it('denies retrieval when AWS cannot provide the CA bundle', async function () {
nock('https://api.heroku.com')
.get('/regions')
.reply(200, [{name: 'virginia', provider: {region: 'us-east-1'}}])
nock('https://truststore.pki.rds.amazonaws.com')
.get('/us-east-1/us-east-1-bundle.pem')
.reply(503, 'temporarily unavailable')

const error = 'Unable to retrieve the RDS CA bundle at /tmp/postgres/us-east-1-bundle.pem: AWS RDS returned 503 Service Unavailable.'
const {error: commandError} = await runCommand(DataPgGetCa, ['--region', 'virginia'])
expect(commandError?.message).to.equal(error)
expect(outputFileStub.called).to.be.false
})

it('rejects non-AWS Heroku regions', async function () {
nock('https://api.heroku.com')
.get('/regions')
.reply(200, [{name: 'example', provider: {region: null}}])

const {error} = await runCommand(DataPgGetCa, ['--region', 'example'])
expect(error?.message).to.equal('example is not a Heroku region backed by AWS.')
})
})
Loading