Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(domains): add multiple SNI endpoint support to domains:add #1412

Merged
merged 3 commits into from
Jan 7, 2020
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 packages/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@oclif/command": "^1",
"@oclif/config": "^1",
"cli-ux": "^5.3.2",
"inquirer": "^7.0.1",
"shell-escape": "^0.2.0",
"tslib": "^1",
"urijs": "^1.19.1"
Expand Down
123 changes: 105 additions & 18 deletions packages/apps/src/commands/domains/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@ import {color} from '@heroku-cli/color'
import {Command, flags} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import cli from 'cli-ux'
import {prompt} from 'inquirer'
import * as shellescape from 'shell-escape'

import waitForDomain from '../../lib/wait-for-domain'

interface DomainCreatePayload {
hostname: string
sni_endpoint?: string
}

const MULTIPLE_SNI_ENDPOINT_FLAG = 'allow-multiple-sni-endpoints'

export default class DomainsAdd extends Command {
static description = 'add a domain to an app'

Expand All @@ -14,35 +22,114 @@ export default class DomainsAdd extends Command {
static flags = {
help: flags.help({char: 'h'}),
app: flags.app({required: true}),
cert: flags.string({description: 'the name of the SSL cert you want to use for this domain', char: 'c'}),
json: flags.boolean({description: 'output in json format', char: 'j'}),
wait: flags.boolean()
}

static args = [{name: 'hostname'}]

createDomain = async (appName: string, payload: DomainCreatePayload): Promise<Heroku.Domain> => {
cli.action.start(`Adding ${color.green(payload.hostname)} to ${color.app(appName)}`)
try {
const response = await this.heroku.post<Heroku.Domain>(`/apps/${appName}/domains`, {
headers: {Accept: 'application/vnd.heroku+json; version=3.allow_multiple_sni_endpoints'},
body: payload
})
return response.body
} catch (err) {
// If the error indicates that the app has multiple certs needs the user to specify which one
// to use, we ask them which cert to use, otherwise we rethrow the error and handle it like usual
if (err.body.id === 'invalid_params' && err.body.message.includes('sni_endpoint')) {
cli.action.stop('resolving SNI endpoint')
const {body: certs} = await this.heroku.get<Heroku.SniEndpoint>(`/apps/${appName}/sni-endpoints`, {
headers: {Accept: 'application/vnd.heroku+json; version=3.allow_multiple_sni_endpoints'}
})

const certChoices = certs.map((cert: Heroku.SniEndpoint) => {
const certName = cert.displayName || cert.name
const domainsLength = cert.ssl_cert.cert_domains.length

if (domainsLength) {
let domainsList = cert.ssl_cert.cert_domains.slice(0, 4).join(', ')

if (domainsLength > 5) {
domainsList = `${domainsList} (...and ${domainsLength - 4} more)`
}

domainsList = `${certName} -> ${domainsList}`

return {
name: domainsList,
value: cert.name
}
}

return {
name: certName,
value: cert.name
}
})

const selection = await prompt<{cert: string}>([
{
type: 'list',
name: 'cert',
message: 'Choose an SNI endpoint to associate with this domain',
choices: certChoices
}
])

payload.sni_endpoint = selection.cert

return this.createDomain(appName, payload)
} else {
throw err
}
}
}

async run() {
const {args, flags} = this.parse(DomainsAdd)
const {hostname} = args
cli.action.start(`Adding ${color.green(args.hostname)} to ${color.app(flags.app)}`)
const {body: domain} = await this.heroku.post<Heroku.Domain>(`/apps/${flags.app}/domains`, {
body: {hostname}
})
cli.action.stop()
if (flags.json) {
cli.styledJSON(domain)
} else {
cli.log(`Configure your app's DNS provider to point to the DNS Target ${color.green(domain.cname || '')}.
For help, see https://devcenter.heroku.com/articles/custom-domains`)
if (domain.status !== 'none') {
if (flags.wait) {
await waitForDomain(flags.app, this.heroku, domain)
} else {
cli.log('')
cli.log(`The domain ${color.green(hostname)} has been enqueued for addition`)
let command = `heroku domains:wait ${shellescape([hostname])}`
cli.log(`Run ${color.cmd(command)} to wait for completion`)

const {body: featureFlag} = await this.heroku.get<Heroku.AppFeature>(
`/apps/${flags.app}/features/${MULTIPLE_SNI_ENDPOINT_FLAG}`
)

const domainCreatePayload: DomainCreatePayload = {
hostname
}

if (featureFlag.enabled) {
// multiple SNI endpoints is enabled
if (flags.cert) {
domainCreatePayload.sni_endpoint = flags.cert
}
}

try {
const domain = await this.createDomain(flags.app, domainCreatePayload)
if (flags.json) {
cli.styledJSON(domain)
} else {
cli.log(`Configure your app's DNS provider to point to the DNS Target ${color.green(domain.cname || '')}.
For help, see https://devcenter.heroku.com/articles/custom-domains`)
if (domain.status !== 'none') {
if (flags.wait) {
await waitForDomain(flags.app, this.heroku, domain)
} else {
cli.log('')
cli.log(`The domain ${color.green(hostname)} has been enqueued for addition`)
let command = `heroku domains:wait ${shellescape([hostname])}`
cli.log(`Run ${color.cmd(command)} to wait for completion`)
}
}
}
} catch (err) {
cli.error(err)
} finally {
cli.action.stop()
}
}
}
102 changes: 93 additions & 9 deletions packages/apps/test/commands/domains/add.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as inquirer from 'inquirer'

import {expect, test} from '../../test'

describe('domains:add', () => {
Expand All @@ -17,14 +19,96 @@ describe('domains:add', () => {
status: 'pending'
}

test
.stderr()
.nock('https://api.heroku.com', api => api
.post('/apps/myapp/domains', {hostname: 'example.com'})
.reply(200, domainsResponse)
)
.command(['domains:add', 'example.com', '--app', 'myapp'])
.it('adds the domain to the app', ctx => {
expect(ctx.stderr).to.contain('Adding example.com to myapp... done')
describe('adding a domain without the feature flag on (the old way)', () => {
test
.stderr()
.nock('https://api.heroku.com', api => api
.get('/apps/myapp/features/allow-multiple-sni-endpoints')
.reply(200, {
enabled: false
})
.post('/apps/myapp/domains', {hostname: 'example.com'})
.reply(200, domainsResponse)
)
.command(['domains:add', 'example.com', '--app', 'myapp'])
.it('adds the domain to the app', ctx => {
expect(ctx.stderr).to.contain('Adding example.com to myapp... done')
})
})

describe('adding a domain to an app with multiple certs', () => {
describe('using the --cert flag', () => {
test
.stderr()
.nock('https://api.heroku.com', api => api
.get('/apps/myapp/features/allow-multiple-sni-endpoints')
.reply(200, {
enabled: true
})
.post('/apps/myapp/domains', {
hostname: 'example.com',
sni_endpoint: 'my-cert'
})
.reply(200, domainsResponse)
)
.command(['domains:add', 'example.com', '--app', 'myapp', '--cert', 'my-cert'])
.it('adds the domain to the app', ctx => {
expect(ctx.stderr).to.contain('Adding example.com to myapp... done')
})
})

describe('without passing a cert', () => {
const certsResponse = [
{
app: {
name: 'myapp',
},
name: 'cert1',
displayName: 'Best Cert Ever',
ssl_cert: {
cert_domains: ['foo.com', 'bar.com', 'baz.com', 'baq.com', 'blah.com', 'rejairieja.com'],
},
},
{
app: {
name: 'myapp',
},
name: 'cert2',
ssl_cert: {
cert_domains: ['foo.com', 'bar.com', 'baz.com', 'baq.com', 'blah.com', 'rejairieja.com'],
},
},
]

test
.stderr()
.stub(inquirer, 'prompt', () => {
return Promise.resolve({cert: 'my-cert'})
})
.nock('https://api.heroku.com', api => api
.get('/apps/myapp/features/allow-multiple-sni-endpoints')
.reply(200, {
enabled: true
})
.post('/apps/myapp/domains', {
hostname: 'example.com',
})
.reply(422, {
id: 'invalid_params',
message: '\'sni_endpoint\' param is required when adding a domain to an app with multiple SSL certs.'
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is fine for now, but I do find an issue with sni_endpoint... I really wish we just call it --cert or something

})
.post('/apps/myapp/domains', {
hostname: 'example.com',
sni_endpoint: 'my-cert'
})
.reply(200, domainsResponse)
.get('/apps/myapp/sni-endpoints')
.reply(200, certsResponse)
)
.command(['domains:add', 'example.com', '--app', 'myapp'])
.it('adds the domain to the app', ctx => {
expect(ctx.stderr).to.contain('Adding example.com to myapp... done')
})
})
})
})
45 changes: 30 additions & 15 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -352,20 +352,6 @@
heroku-exec-util "0.7.5"
lodash "^4.17.13"

"@heroku-cli/plugin-run-v5@7.24.0", "@heroku-cli/plugin-run-v5@^7.24.0":
version "7.24.0"
resolved "https://registry.yarnpkg.com/@heroku-cli/plugin-run-v5/-/plugin-run-v5-7.24.0.tgz#78a9852118ee8fd5ddac8e06a789df92c1ccda10"
integrity sha512-BqwSPaRl17mQiYM3TN9rzq1GMGaVTPuIQceArA++Q6SqofCo5pSMHgejEtOX5FXsCJMb2qYpkdvijhCEM/+z/A==
dependencies:
"@heroku-cli/color" "^1.1.14"
"@heroku-cli/command" "^8.2.10"
"@heroku-cli/notifications" "^1.2.2"
"@heroku/eventsource" "^1.0.7"
co "4.6.0"
fs-extra "^7.0.1"
heroku-cli-util "^8.0.11"
shellwords "^0.1.1"

"@heroku-cli/schema@^1.0.25":
version "1.0.25"
resolved "https://registry.yarnpkg.com/@heroku-cli/schema/-/schema-1.0.25.tgz#175d489d82c2ff0be700fe9fab8590b64c7e4f39"
Expand Down Expand Up @@ -4236,6 +4222,11 @@ find-up@^4.0.0:
locate-path "^5.0.0"
path-exists "^4.0.0"

fixture-stdout@0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/fixture-stdout/-/fixture-stdout-0.2.1.tgz#0b7966535ab87cf03f8dcbefabdac3effe195a24"
integrity sha1-C3lmU1q4fPA/jcvvq9rD7/4ZWiQ=

flat-cache@^1.2.1:
version "1.3.4"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.4.tgz#2c2ef77525cc2929007dfffa1dd314aa9c9dee6f"
Expand Down Expand Up @@ -5246,6 +5237,25 @@ inquirer@^7.0.0:
strip-ansi "^5.1.0"
through "^2.3.6"

inquirer@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.1.tgz#13f7980eedc73c689feff3994b109c4e799c6ebb"
integrity sha512-V1FFQ3TIO15det8PijPLFR9M9baSlnRs9nL7zWu1MNVA2T9YVl9ZbrHJhYs7e9X8jeMZ3lr2JH/rdHFgNCBdYw==
dependencies:
ansi-escapes "^4.2.1"
chalk "^2.4.2"
cli-cursor "^3.1.0"
cli-width "^2.0.0"
external-editor "^3.0.3"
figures "^3.0.0"
lodash "^4.17.15"
mute-stream "0.0.8"
run-async "^2.2.0"
rxjs "^6.5.3"
string-width "^4.1.0"
strip-ansi "^5.1.0"
through "^2.3.6"

into-stream@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-3.1.0.tgz#96fb0a936c12babd6ff1752a17d05616abd094c6"
Expand Down Expand Up @@ -6563,6 +6573,11 @@ netrc-parser@3.1.6, netrc-parser@^3.1.4, netrc-parser@^3.1.6:
debug "^3.1.0"
execa "^0.10.0"

netrc@0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/netrc/-/netrc-0.1.4.tgz#6be94fcaca8d77ade0a9670dc460914c94472444"
integrity sha1-a+lPysqNd63gqWcNxGCRTJRHJEQ=

nice-try@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
Expand Down Expand Up @@ -8149,7 +8164,7 @@ rxjs@^5.5.2:
dependencies:
symbol-observable "1.0.1"

rxjs@^6.1.0:
rxjs@^6.1.0, rxjs@^6.5.3:
version "6.5.3"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.3.tgz#510e26317f4db91a7eb1de77d9dd9ba0a4899a3a"
integrity sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA==
Expand Down