diff --git a/src/commands/data/pg/get-ca.ts b/src/commands/data/pg/get-ca.ts new file mode 100644 index 0000000000..3bac898546 --- /dev/null +++ b/src/commands/data/pg/get-ca.ts @@ -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 = { + 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 { + if (REGION_ALIASES[herokuRegion]) return REGION_ALIASES[herokuRegion] + + const {body: regions} = await this.heroku.get('/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') + } + + return path.join(os.homedir(), '.postgres') + } + + public async download(url: string): Promise { + 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 { + 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}`) + } + } +} diff --git a/test/unit/commands/data/pg/get-ca.unit.test.ts b/test/unit/commands/data/pg/get-ca.unit.test.ts new file mode 100644 index 0000000000..ca7a83234a --- /dev/null +++ b/test/unit/commands/data/pg/get-ca.unit.test.ts @@ -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.') + }) +})