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(ps): add larger dyno information to ps:type and ps:scale #2598

Merged
merged 18 commits into from
Jan 30, 2024
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
29 changes: 27 additions & 2 deletions packages/apps-v5/src/commands/ps/scale.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,32 @@ https://devcenter.heroku.com/articles/procfile`)
async function run(context, heroku) {
let app = context.app

function parse(args) {
// will remove this flag once we have
// successfully launched larger dyno sizes
let isLargerDyno = false
const largerDynoFeatureFlag = await heroku.get('/account/features/frontend-larger-dynos')

async function parse(args) {
// checks for larger dyno sizes
// if the feature is not enabled
if (!largerDynoFeatureFlag.enabled) {
if (args.find(a => a.match(/=/))) {
// eslint-disable-next-line array-callback-return
compact(args.map(arg => {
let match = arg.match(/^([\w-]+)([=+-]\d+)(?::([\w-]+))?$/)
let size = match[3]

const largerDynoNames = /^(?!standard-[12]x$)(performance|private|shield)-(l-ram|xl|2xl)$/i
isLargerDyno = largerDynoNames.test(size)

if (isLargerDyno) {
const availableDynoSizes = 'eco, basic, standard-1x, standard-2x, performance-m, performance-l, private-s, private-m, private-l, shield-s, shield-m, shield-l'
throw new Error(`No such size as ${size}. Use ${availableDynoSizes}.`)
}
}))
}
}

return compact(args.map(arg => {
let change = arg.match(/^([\w-]+)([=+-]\d+)(?::([\w-]+))?$/)
if (!change) return
Expand All @@ -22,7 +47,7 @@ async function run(context, heroku) {
}))
}

let changes = parse(context.args)
let changes = await parse(context.args)
if (changes.length === 0) {
let formation = await heroku.get(`/apps/${app}/formation`)

Expand Down
28 changes: 27 additions & 1 deletion packages/apps-v5/src/commands/ps/type.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
let cli = require('heroku-cli-util')
const {sortBy, compact} = require('lodash')

const costMonthly = {Free: 0, Eco: 0, Hobby: 7, Basic: 7, 'Standard-1X': 25, 'Standard-2X': 50, 'Performance-M': 250, Performance: 500, 'Performance-L': 500, '1X': 36, '2X': 72, PX: 576}
const costMonthly = {Free: 0, Eco: 0, Hobby: 7, Basic: 7, 'Standard-1X': 25, 'Standard-2X': 50, 'Performance-M': 250, Performance: 500, 'Performance-L': 500, '1X': 36, '2X': 72, PX: 576, 'Performance-L-RAM': 500, 'Performance-XL': 750, 'Performance-2XL': 1500}

let emptyFormationErr = app => {
return new Error(`No process types on ${app}.
Expand All @@ -14,8 +14,34 @@ https://devcenter.heroku.com/articles/procfile`)
async function run(context, heroku) {
let app = context.app

// will remove this flag once we have
// successfully launched larger dyno sizes
let isLargerDyno = false
zwhitfield3 marked this conversation as resolved.
Show resolved Hide resolved
const largerDynoFeatureFlag = await heroku.get('/account/features/frontend-larger-dynos')

let parse = async function (args) {
if (!args || args.length === 0) return []

// checks for larger dyno sizes
// if the feature is not enabled
if (!largerDynoFeatureFlag.enabled) {
if (args.find(a => a.match(/=/))) {
// eslint-disable-next-line array-callback-return
compact(args.map(arg => {
let match = arg.match(/^([a-zA-Z0-9_]+)=([\w-]+)$/)
let size = match[2]

const largerDynoNames = /^(?!standard-[12]x$)(performance|private|shield)-(l-ram|xl|2xl)$/i
isLargerDyno = largerDynoNames.test(size)

if (isLargerDyno) {
const availableDynoSizes = 'eco, basic, standard-1x, standard-2x, performance-m, performance-l, private-s, private-m, private-l, shield-s, shield-m, shield-l'
throw new Error(`No such size as ${size}. Use ${availableDynoSizes}.`)
}
}))
}
}

let formation = await heroku.get(`/apps/${app}/formation`)
if (args.find(a => a.match(/=/))) {
return compact(args.map(arg => {
Expand Down
45 changes: 45 additions & 0 deletions packages/apps-v5/test/unit/commands/ps/scale.unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@ const nock = require('nock')
const cmd = commands.find(c => c.topic === 'ps' && c.command === 'scale')
const {expect} = require('chai')

// will remove this flag once we have
// successfully launched larger dyno sizes
function featureFlagPayload(isEnabled = false) {
return {
enabled: isEnabled,
}
}

describe('ps:scale', () => {
beforeEach(() => cli.mockConsole())

afterEach(() => nock.cleanAll())

it('shows formation with no args', () => {
let api = nock('https://api.heroku.com')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())
.get('/apps/myapp/formation')
.reply(200, [{type: 'web', quantity: 1, size: 'Free'}, {type: 'worker', quantity: 2, size: 'Free'}])
.get('/apps/myapp')
Expand All @@ -24,8 +34,25 @@ describe('ps:scale', () => {
.then(() => api.done())
})

it('scales up a new large dyno size if feature flag is enabled', () => {
let api = nock('https://api.heroku.com:443')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload(true))
.patch('/apps/myapp/formation', {updates: [{type: 'web', quantity: '1', size: 'Performance-L-RAM'}]})
.reply(200, [{type: 'web', quantity: 1, size: 'Performance-L-RAM'}])
.get('/apps/myapp')
.reply(200, {name: 'myapp'})

return cmd.run({app: 'myapp', args: ['web=1:Performance-L-RAM']})
.then(() => expect(cli.stdout, 'to be empty'))
.then(() => expect(cli.stderr).to.equal('Scaling dynos... done, now running web at 1:Performance-L-RAM\n'))
.then(() => api.done())
})

it('shows formation with shield dynos for apps in a shielded private space', () => {
let api = nock('https://api.heroku.com')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())
.get('/apps/myapp/formation')
.reply(200, [{type: 'web', quantity: 1, size: 'Private-L'}, {type: 'worker', quantity: 2, size: 'Private-M'}])
.get('/apps/myapp')
Expand All @@ -39,6 +66,8 @@ describe('ps:scale', () => {

it('errors with no process types', () => {
let api = nock('https://api.heroku.com')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())
.get('/apps/myapp/formation')
.reply(200, [])
.get('/apps/myapp')
Expand All @@ -53,6 +82,8 @@ describe('ps:scale', () => {

it('scales web=1 worker=2', () => {
let api = nock('https://api.heroku.com:443')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())
.patch('/apps/myapp/formation', {updates: [{type: 'web', quantity: '1'}, {type: 'worker', quantity: '2'}]})
.reply(200, [{type: 'web', quantity: 1, size: 'Free'}, {type: 'worker', quantity: 2, size: 'Free'}])
.get('/apps/myapp')
Expand All @@ -66,6 +97,8 @@ describe('ps:scale', () => {

it('scales up a shield dyno if the app is in a shielded private space', () => {
let api = nock('https://api.heroku.com:443')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())
.patch('/apps/myapp/formation', {updates: [{type: 'web', quantity: '1', size: 'Private-L'}]})
.reply(200, [{type: 'web', quantity: 1, size: 'Private-L'}])
.get('/apps/myapp')
Expand All @@ -79,6 +112,8 @@ describe('ps:scale', () => {

it('scales web-1', () => {
let api = nock('https://api.heroku.com:443')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())
.patch('/apps/myapp/formation', {updates: [{type: 'web', quantity: '+1'}]})
.reply(200, [{type: 'web', quantity: 2, size: 'Free'}])
.get('/apps/myapp')
Expand All @@ -90,3 +125,13 @@ describe('ps:scale', () => {
.then(() => api.done())
})
})

it('errors if user attempts to scale up using new larger dyno size and feature flag is NOT enabled', function () {
let api = nock('https://api.heroku.com')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())

return cmd.run({app: 'myapp', args: ['web=1:Performance-L-RAM']})
.catch(error => expect(error.message).to.eq('No such size as Performance-L-RAM. Use eco, basic, standard-1x, standard-2x, performance-m, performance-l, private-s, private-m, private-l, shield-s, shield-m, shield-l.'))
.then(() => api.done())
})
94 changes: 78 additions & 16 deletions packages/apps-v5/test/unit/commands/ps/type.unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ const cmd = commands.find(c => c.topic === 'ps' && c.command === 'type')
const nock = require('nock')
const expect = require('chai').expect

// will remove this flag once we have
// successfully launched larger dyno sizes
function featureFlagPayload(isEnabled = false) {
return {
enabled: isEnabled,
}
}

function app(args = {}) {
let base = {name: 'myapp'}
return Object.assign(base, args)
Expand All @@ -19,6 +27,8 @@ describe('ps:type', function () {

it('displays cost/hour and max cost/month for all individually-priced dyno sizes', function () {
let api = nock('https://api.heroku.com')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload(true))
.get('/apps/myapp')
.reply(200, app())
.get('/apps/myapp/formation')
Expand All @@ -29,37 +39,75 @@ describe('ps:type', function () {
{type: 'web', quantity: 1, size: 'Standard-2X'},
{type: 'web', quantity: 1, size: 'Performance-M'},
{type: 'web', quantity: 1, size: 'Performance-L'},
{type: 'web', quantity: 1, size: 'Performance-L-RAM'},
{type: 'web', quantity: 1, size: 'Performance-XL'},
{type: 'web', quantity: 1, size: 'Performance-2XL'},
])

return cmd.run({app: 'myapp'})
.then(() => {
expect(cli.stdout).to.eq(`=== Dyno Types
type size qty cost/hour max cost/month
──── ───────────── ─── ───────── ──────────────
web Eco 1
web Basic 1 ~$0.010 $7
web Standard-1X 1 ~$0.035 $25
web Standard-2X 1 ~$0.069 $50
web Performance-M 1 ~$0.347 $250
web Performance-L 1 ~$0.694 $500
type size qty cost/hour max cost/month
──── ───────────────── ─── ───────── ──────────────
web Eco 1
web Basic 1 ~$0.010 $7
web Standard-1X 1 ~$0.035 $25
web Standard-2X 1 ~$0.069 $50
web Performance-M 1 ~$0.347 $250
web Performance-L 1 ~$0.694 $500
web Performance-L-RAM 1 ~$0.694 $500
web Performance-XL 1 ~$1.042 $750
web Performance-2XL 1 ~$2.083 $1500
=== Dyno Totals
type total
───────────── ─────
Eco 1
Basic 1
Standard-1X 1
Standard-2X 1
Performance-M 1
Performance-L 1
type total
───────────────── ─────
Eco 1
Basic 1
Standard-1X 1
Standard-2X 1
Performance-M 1
Performance-L 1
Performance-L-RAM 1
Performance-XL 1
Performance-2XL 1

$5 (flat monthly fee, shared across all Eco dynos)
`)
})
.then(() => api.done())
})

it('switches to performance-l-ram dyno when feature flag is enabled', function () {
let api = nock('https://api.heroku.com')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload(true))
.get('/apps/myapp')
.reply(200, app())
.get('/apps/myapp/formation')
.reply(200, [{type: 'web', quantity: 1, size: 'Eco'}])
.patch('/apps/myapp/formation', {updates: [{type: 'web', size: 'performance-l-ram'}]})
.reply(200, [{type: 'web', quantity: 1, size: 'Performance-L-RAM'}])
.get('/apps/myapp/formation')
.reply(200, [{type: 'web', quantity: 1, size: 'Performance-L-RAM'}])

return cmd.run({app: 'myapp', args: ['web=performance-l-ram']})
.then(() => expect(cli.stdout).to.eq(`=== Dyno Types
type size qty cost/hour max cost/month
──── ───────────────── ─── ───────── ──────────────
web Performance-L-RAM 1 ~$0.694 $500
=== Dyno Totals
type total
───────────────── ─────
Performance-L-RAM 1
`))
.then(() => expect(cli.stderr).to.eq('Scaling dynos on myapp... done\n'))
.then(() => api.done())
})

it('switches to hobby dynos', function () {
let api = nock('https://api.heroku.com')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())
.get('/apps/myapp')
.reply(200, app())
.get('/apps/myapp/formation')
Expand All @@ -86,6 +134,8 @@ Basic 3

it('switches to standard-1x and standard-2x dynos', function () {
let api = nock('https://api.heroku.com')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())
.get('/apps/myapp')
.reply(200, app())
.get('/apps/myapp/formation')
Expand Down Expand Up @@ -113,6 +163,8 @@ Standard-2X 2

it('displays Shield dynos for apps in shielded spaces', function () {
let api = nock('https://api.heroku.com')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())
.get('/apps/myapp')
.reply(200, app({space: {shield: true}}))
.get('/apps/myapp/formation')
Expand All @@ -133,3 +185,13 @@ Shield-L 0
.then(() => api.done())
})
})

it('errors when user requests larger dynos and feature flag is NOT enabled', function () {
let api = nock('https://api.heroku.com')
.get('/account/features/frontend-larger-dynos')
.reply(200, featureFlagPayload())

return cmd.run({app: 'myapp', args: ['web=performance-l-ram']})
.catch(error => expect(error.message).to.eq('No such size as performance-l-ram. Use eco, basic, standard-1x, standard-2x, performance-m, performance-l, private-s, private-m, private-l, shield-s, shield-m, shield-l.'))
.then(() => api.done())
})
Loading