Skip to content

Commit e0890e1

Browse files
feat: add data:pg:get-ca (#3880)
* feat: add data pg get-ca command * add support for common runtime region alias names * fix: use standard postgres certificate directory * test: use platform-specific certificate paths * fix: handle region lookup errors consistently * refactor: hide get ca implementation methods * refactor: use native fetch for get ca --------- Co-authored-by: Johnny Winn <88165065+heroku-johnny@users.noreply.github.com>
1 parent 7c24954 commit e0890e1

2 files changed

Lines changed: 196 additions & 0 deletions

File tree

‎src/commands/data/pg/get-ca.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {Command, flags} from '@heroku-cli/command'
2+
import * as Heroku from '@heroku-cli/schema'
3+
import fs from 'fs-extra'
4+
import os from 'node:os'
5+
import path from 'node:path'
6+
7+
const RDS_CERTIFICATE_HOST = 'https://truststore.pki.rds.amazonaws.com'
8+
const REGION_ALIASES: Record<string, string> = {
9+
eu: 'eu-west-1',
10+
us: 'us-east-1',
11+
}
12+
13+
export default class DataPgGetCa extends Command {
14+
static description = 'download the RDS CA bundle for a Heroku region'
15+
static examples = [
16+
'<%= config.bin %> <%= command.id %> --region virginia',
17+
'<%= config.bin %> <%= command.id %> --region global',
18+
]
19+
static flags = {
20+
region: flags.string({
21+
description: 'Heroku region or global for the AWS global CA bundle',
22+
required: true,
23+
}),
24+
}
25+
26+
public async run(): Promise<void> {
27+
const {flags} = await this.parse(DataPgGetCa)
28+
let destination = ''
29+
30+
try {
31+
destination = this.destinationDirectory()
32+
const awsRegion = flags.region === 'global' ? 'global' : await this.awsRegion(flags.region)
33+
const fileName = `${awsRegion}-bundle.pem`
34+
destination = path.join(destination, fileName)
35+
const url = `${RDS_CERTIFICATE_HOST}/${awsRegion}/${fileName}`
36+
const certificate = await this.download(url)
37+
await fs.outputFile(destination, certificate, {mode: 0o600})
38+
this.log(`RDS CA bundle retrieved successfully: ${destination}`)
39+
} catch (error) {
40+
const message = error instanceof Error ? error.message : String(error)
41+
this.error(`Unable to retrieve the RDS CA bundle at ${destination}: ${message}`)
42+
}
43+
}
44+
45+
private async awsRegion(herokuRegion: string): Promise<string> {
46+
if (REGION_ALIASES[herokuRegion]) return REGION_ALIASES[herokuRegion]
47+
48+
const {body: regions} = await this.heroku.get<Heroku.Region[]>('/regions')
49+
const region = regions.find(candidate => candidate.name === herokuRegion)
50+
const awsRegion = region?.provider?.region
51+
52+
if (!awsRegion) throw new Error(`${herokuRegion} is not a Heroku region backed by AWS.`)
53+
54+
return awsRegion
55+
}
56+
57+
private destinationDirectory(): string {
58+
if (process.platform === 'win32') {
59+
if (!process.env.APPDATA) throw new Error('APPDATA is not set; unable to determine the PostgreSQL certificate directory.')
60+
61+
return path.join(process.env.APPDATA, 'postgresql')
62+
}
63+
64+
return path.join(os.homedir(), '.postgresql')
65+
}
66+
67+
private async download(url: string): Promise<Buffer> {
68+
const response = await fetch(url)
69+
if (!response.ok) throw new Error(`AWS RDS returned ${response.status} ${response.statusText}.`)
70+
71+
return Buffer.from(await response.arrayBuffer())
72+
}
73+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import {runCommand} from '@heroku-cli/test-utils'
2+
import {expect} from 'chai'
3+
import fs from 'fs-extra'
4+
import {
5+
afterEach,
6+
beforeEach,
7+
describe,
8+
it,
9+
} from 'mocha'
10+
import nock from 'nock'
11+
import os from 'node:os'
12+
import path from 'node:path'
13+
import {restore, SinonStub, stub} from 'sinon'
14+
15+
import DataPgGetCa from '../../../../../src/commands/data/pg/get-ca.js'
16+
17+
describe('data:pg:get-ca', function () {
18+
const certificate = '-----BEGIN CERTIFICATE-----\ncertificate\n-----END CERTIFICATE-----\n'
19+
const destinationDirectory = () => process.platform === 'win32'
20+
? path.join(process.env.APPDATA ?? '', 'postgresql')
21+
: path.join(os.homedir(), '.postgresql')
22+
let outputFileStub: SinonStub
23+
24+
beforeEach(function () {
25+
outputFileStub = stub(fs, 'outputFile').resolves()
26+
})
27+
28+
afterEach(function () {
29+
restore()
30+
nock.cleanAll()
31+
})
32+
33+
it('downloads the CA bundle for an AWS-backed Heroku region', async function () {
34+
nock('https://api.heroku.com')
35+
.get('/regions')
36+
.reply(200, [{name: 'virginia', provider: {region: 'us-east-1'}}])
37+
nock('https://truststore.pki.rds.amazonaws.com')
38+
.get('/us-east-1/us-east-1-bundle.pem')
39+
.reply(200, certificate)
40+
41+
const {stdout} = await runCommand(DataPgGetCa, ['--region', 'virginia'])
42+
const destination = path.join(destinationDirectory(), 'us-east-1-bundle.pem')
43+
44+
expect(outputFileStub.calledOnceWith(
45+
destination,
46+
Buffer.from(certificate),
47+
{mode: 0o600},
48+
)).to.be.true
49+
expect(stdout).to.equal(`RDS CA bundle retrieved successfully: ${destination}\n`)
50+
})
51+
52+
it('downloads the AWS global CA bundle without requesting Heroku regions', async function () {
53+
nock('https://truststore.pki.rds.amazonaws.com')
54+
.get('/global/global-bundle.pem')
55+
.reply(200, certificate)
56+
57+
const {stdout} = await runCommand(DataPgGetCa, ['--region', 'global'])
58+
const destination = path.join(destinationDirectory(), 'global-bundle.pem')
59+
60+
expect(outputFileStub.calledOnceWith(
61+
destination,
62+
Buffer.from(certificate),
63+
{mode: 0o600},
64+
)).to.be.true
65+
expect(stdout).to.equal(`RDS CA bundle retrieved successfully: ${destination}\n`)
66+
})
67+
68+
it('downloads the US Common Runtime CA bundle without requesting Heroku regions', async function () {
69+
nock('https://truststore.pki.rds.amazonaws.com')
70+
.get('/us-east-1/us-east-1-bundle.pem')
71+
.reply(200, certificate)
72+
73+
const {stdout} = await runCommand(DataPgGetCa, ['--region', 'us'])
74+
const destination = path.join(destinationDirectory(), 'us-east-1-bundle.pem')
75+
76+
expect(outputFileStub.calledOnceWith(
77+
destination,
78+
Buffer.from(certificate),
79+
{mode: 0o600},
80+
)).to.be.true
81+
expect(stdout).to.equal(`RDS CA bundle retrieved successfully: ${destination}\n`)
82+
})
83+
84+
it('downloads the EU Common Runtime CA bundle without requesting Heroku regions', async function () {
85+
nock('https://truststore.pki.rds.amazonaws.com')
86+
.get('/eu-west-1/eu-west-1-bundle.pem')
87+
.reply(200, certificate)
88+
89+
const {stdout} = await runCommand(DataPgGetCa, ['--region', 'eu'])
90+
const destination = path.join(destinationDirectory(), 'eu-west-1-bundle.pem')
91+
92+
expect(outputFileStub.calledOnceWith(
93+
destination,
94+
Buffer.from(certificate),
95+
{mode: 0o600},
96+
)).to.be.true
97+
expect(stdout).to.equal(`RDS CA bundle retrieved successfully: ${destination}\n`)
98+
})
99+
100+
it('denies retrieval when AWS cannot provide the CA bundle', async function () {
101+
nock('https://api.heroku.com')
102+
.get('/regions')
103+
.reply(200, [{name: 'virginia', provider: {region: 'us-east-1'}}])
104+
nock('https://truststore.pki.rds.amazonaws.com')
105+
.get('/us-east-1/us-east-1-bundle.pem')
106+
.reply(503, 'temporarily unavailable')
107+
108+
const {error: commandError} = await runCommand(DataPgGetCa, ['--region', 'virginia'])
109+
const destination = path.join(destinationDirectory(), 'us-east-1-bundle.pem')
110+
expect(commandError?.message).to.equal(`Unable to retrieve the RDS CA bundle at ${destination}: AWS RDS returned 503 Service Unavailable.`)
111+
expect(outputFileStub.called).to.be.false
112+
})
113+
114+
it('rejects non-AWS Heroku regions', async function () {
115+
nock('https://api.heroku.com')
116+
.get('/regions')
117+
.reply(200, [{name: 'example', provider: {region: null}}])
118+
119+
const {error} = await runCommand(DataPgGetCa, ['--region', 'example'])
120+
const destination = destinationDirectory()
121+
expect(error?.message).to.equal(`Unable to retrieve the RDS CA bundle at ${destination}: example is not a Heroku region backed by AWS.`)
122+
})
123+
})

0 commit comments

Comments
 (0)