From 169c9814d72e2875f4fc1b7de6ebc9cdb882709e Mon Sep 17 00:00:00 2001 From: Eric Black Date: Tue, 23 Apr 2024 15:43:07 -0700 Subject: [PATCH 1/2] Convert spaces:topology to oclif --- packages/cli/src/commands/spaces/topology.ts | 114 ++++++++++++ packages/cli/test/fixtures/spaces/fixtures.ts | 89 +++++++++ .../commands/spaces/topology.unit.test.ts | 90 +++++++++ packages/spaces/commands/topology.js | 89 --------- packages/spaces/index.js | 1 - .../test/unit/commands/topology.unit.test.js | 174 ------------------ 6 files changed, 293 insertions(+), 264 deletions(-) create mode 100644 packages/cli/src/commands/spaces/topology.ts create mode 100644 packages/cli/test/unit/commands/spaces/topology.unit.test.ts delete mode 100644 packages/spaces/commands/topology.js delete mode 100644 packages/spaces/test/unit/commands/topology.unit.test.js diff --git a/packages/cli/src/commands/spaces/topology.ts b/packages/cli/src/commands/spaces/topology.ts new file mode 100644 index 0000000000..cf24843374 --- /dev/null +++ b/packages/cli/src/commands/spaces/topology.ts @@ -0,0 +1,114 @@ +import {Command, flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import * as Heroku from '@heroku-cli/schema' +import heredoc from 'tsheredoc' +import color from '@heroku-cli/color' + +export type SpaceTopology = { + version: number, + apps: Array<{ + id?: string + domains: string[] + formations: Array<{ + process_type: string + dynos: Array<{ + number: number + private_ip: string + hostname: string + }> + }> + }> +} + +export default class Topology extends Command { + static topic = 'spaces'; + static description = 'show space topology'; + static flags = { + space: flags.string({char: 's', description: 'space to get topology of'}), + json: flags.boolean({description: 'output in json format'}), + }; + + static args = { + space: Args.string({hidden: true}), + }; + + public async run(): Promise { + const {flags, args} = await this.parse(Topology) + const spaceName = flags.space || args.space + if (!spaceName) { + ux.error(heredoc(` + Error: Missing 1 required arg: + space + See more help with --help + `)) + } + + const {body: topology} = await this.heroku.get(`/spaces/${spaceName}/topology`) + let appInfo: Heroku.App[] = [] + if (topology.apps) { + appInfo = await Promise.all(topology.apps.map(async topologyApp => { + const {body: app} = await this.heroku.get(`/apps/${topologyApp.id}`) + return app + })) + } + + this.render(topology, appInfo, flags.json) + } + + protected render(topology: SpaceTopology, appInfo: Heroku.App[], json: boolean) { + if (json) { + ux.styledJSON(topology) + } else if (topology.apps) { + topology.apps.forEach(app => { + let formations: string[] = [] + let dynos: string[] = [] + if (app.formations) { + app.formations.forEach(formation => { + formations.push(formation.process_type) + if (formation.dynos) { + formation.dynos.forEach(dyno => { + const dynoS = [`${formation.process_type}.${dyno.number}`, dyno.private_ip, dyno.hostname].filter(Boolean) + dynos.push(dynoS.join(' - ')) + }) + } + }) + } + + const domains = app.domains.sort() + formations = formations.sort() + dynos = dynos.sort((a, b) => { + const apt = this.getProcessType(a) + const bpt = this.getProcessType(b) + if (apt > bpt) { + return 1 + } + + if (apt < bpt) { + return -1 + } + + return this.getProcessNum(a) - this.getProcessNum(b) + }) + const info = appInfo.find(info => info.id === app.id) + let header = info?.name + if (formations.length > 0) { + header += ` (${color.cyan(formations.join(', '))})` + } + + ux.styledHeader(header || '') + ux.styledObject({ + Domains: domains, Dynos: dynos, + }, ['Domains', 'Dynos']) + ux.log() + }) + } + } + + protected getProcessType(s: string) { + return s.split('-', 2)[0].split('.', 2)[0] + } + + protected getProcessNum(s: string) { + return Number.parseInt(s.split('-', 2)[0].split('.', 2)[1], 10) + } +} diff --git a/packages/cli/test/fixtures/spaces/fixtures.ts b/packages/cli/test/fixtures/spaces/fixtures.ts index cc8d147cc4..36eac5070a 100644 --- a/packages/cli/test/fixtures/spaces/fixtures.ts +++ b/packages/cli/test/fixtures/spaces/fixtures.ts @@ -1,4 +1,5 @@ import * as Heroku from '@heroku-cli/schema' +import type {SpaceTopology} from '../../../src/commands/spaces/topology' export const spaces: Record> = { 'non-shield-space': { @@ -42,3 +43,91 @@ export const spaces: Record> = { updated_at: '2016-01-06T03:23:13Z', }, } + +export const apps: Record = { + www: { + name: 'acme-inc-www', + id: 'a84b035c-4c83-11e5-9bda-2cf0ee2c94de', + }, +} + +export const topologies: Record = { + 'topology-one': { + version: 1, + apps: [ + { + id: apps.www.id, + domains: ['example.com', 'example.net'], + formations: [ + { + process_type: 'web', + dynos: [ + { + number: 1, + private_ip: '10.0.134.42', + hostname: '1.example-app-90210.app.localspace', + }, + { + number: 2, + private_ip: '10.0.134.42', + hostname: '1.example-app-90210.app.localspace', + }, + ], + }, + ], + }, + ], + }, + 'topology-two': { + version: 1, + apps: [ + { + id: apps.www.id, + domains: ['example.com', 'example.net'], + formations: [ + { + process_type: 'web', + dynos: [ + { + number: 2, + private_ip: '10.0.134.42', + hostname: '1.example-app-90210.app.localspace', + }, + { + number: 1, + private_ip: '10.0.134.42', + hostname: '1.example-app-90210.app.localspace', + }, + ], + }, + ], + }, + ], + }, + 'topology-three': { + version: 1, + apps: [ + { + id: apps.www.id, + domains: ['example.com', 'example.net'], + formations: [ + { + process_type: 'web', + dynos: [ + { + number: 1, + private_ip: '10.0.134.42', + hostname: '1.example-app-90210.app.localspace', + }, + { + number: 1, + private_ip: '10.0.134.42', + hostname: '1.example-app-90210.app.localspace', + }, + ], + }, + ], + }, + ], + }, +} diff --git a/packages/cli/test/unit/commands/spaces/topology.unit.test.ts b/packages/cli/test/unit/commands/spaces/topology.unit.test.ts new file mode 100644 index 0000000000..f01f88702b --- /dev/null +++ b/packages/cli/test/unit/commands/spaces/topology.unit.test.ts @@ -0,0 +1,90 @@ +import {stdout} from 'stdout-stderr' +import Cmd from '../../../../src/commands/spaces/topology' +import runCommand from '../../../helpers/runCommand' +import * as nock from 'nock' +import heredoc from 'tsheredoc' +import expectOutput from '../../../helpers/utils/expectOutput' +import * as fixtures from '../../../fixtures/spaces/fixtures' +import {expect} from 'chai' + +describe('spaces:topology', function () { + const topo1 = fixtures.topologies['topology-one'] + const topo2 = fixtures.topologies['topology-two'] + const topo3 = fixtures.topologies['topology-three'] + const app = fixtures.apps.www + + it('shows space topology', async function () { + nock('https://api.heroku.com') + .get('/spaces/my-space/topology') + .reply(200, topo1) + .get(`/apps/${app.id}`) + .reply(200, app) + + await runCommand(Cmd, [ + '--space', + 'my-space', + ]) + expectOutput(stdout.output, heredoc(` + === ${app.name} (web) + Domains: example.com + example.net + Dynos: web.1 - 10.0.134.42 - 1.example-app-90210.app.localspace + web.2 - 10.0.134.42 - 1.example-app-90210.app.localspace + `)) + }) + + it('shows space topology with first dyno having higher process number', async function () { + nock('https://api.heroku.com') + .get('/spaces/my-space/topology') + .reply(200, topo2) + .get(`/apps/${app.id}`) + .reply(200, app) + + await runCommand(Cmd, [ + '--space', + 'my-space', + ]) + expectOutput(stdout.output, heredoc(` + === ${app.name} (web) + Domains: example.com + example.net + Dynos: web.1 - 10.0.134.42 - 1.example-app-90210.app.localspace + web.2 - 10.0.134.42 - 1.example-app-90210.app.localspace + `)) + }) + + it('shows space topology with dynos having same process number', async function () { + nock('https://api.heroku.com') + .get('/spaces/my-space/topology') + .reply(200, topo3) + .get(`/apps/${app.id}`) + .reply(200, app) + + await runCommand(Cmd, [ + '--space', + 'my-space', + ]) + expectOutput(stdout.output, heredoc(` + === ${app.name} (web) + Domains: example.com + example.net + Dynos: web.1 - 10.0.134.42 - 1.example-app-90210.app.localspace + web.1 - 10.0.134.42 - 1.example-app-90210.app.localspace + `)) + }) + + it('shows space topology --json', async function () { + nock('https://api.heroku.com') + .get('/spaces/my-space/topology') + .reply(200, topo1) + .get(`/apps/${app.id}`) + .reply(200, app) + + await runCommand(Cmd, [ + '--space', + 'my-space', + '--json', + ]) + expect(JSON.parse(stdout.output)).to.eql(topo1) + }) +}) diff --git a/packages/spaces/commands/topology.js b/packages/spaces/commands/topology.js deleted file mode 100644 index 53f71c51c3..0000000000 --- a/packages/spaces/commands/topology.js +++ /dev/null @@ -1,89 +0,0 @@ -'use strict' - -const cli = require('heroku-cli-util') - -const getProcessType = s => s.split('-', 2)[0].split('.', 2)[0] -const getProcessNum = s => Number.parseInt(s.split('-', 2)[0].split('.', 2)[1]) - -async function run(context, heroku) { - let spaceName = context.flags.space || context.args.space - if (!spaceName) throw new Error('Space name required.\nUSAGE: heroku spaces:topology my-space') - - let topology = await heroku.get(`/spaces/${spaceName}/topology`) - let appInfo = [] - if (topology.apps) { - appInfo = await Promise.all(topology.apps.map(app => heroku.get(`/apps/${app.id}`))) - } - - render(spaceName, topology, appInfo, context.flags) -} - -function render(spaceName, topology, appInfo, flags) { - if (flags.json) { - cli.styledJSON(topology) - } else { - // eslint-disable-next-line no-lonely-if - if (topology.apps) { - topology.apps.forEach(app => { - let formations = [] - let dynos = [] - - if (app.formations) { - app.formations.forEach(formation => { - formations.push(formation.process_type) - - if (formation.dynos) { - formation.dynos.forEach(dyno => { - let dynoS = [`${formation.process_type}.${dyno.number}`, dyno.private_ip, dyno.hostname].filter(Boolean) - dynos.push(dynoS.join(' - ')) - }) - } - }) - } - - let domains = app.domains.sort() - formations = formations.sort() - dynos = dynos.sort((a, b) => { - let apt = getProcessType(a) - let bpt = getProcessType(b) - if (apt > bpt) { - return 1 - } - - if (apt < bpt) { - return -1 - } - - return getProcessNum(a) - getProcessNum(b) - }) - - let info = appInfo.find(info => info.id === app.id) - let header = info.name - if (formations.length > 0) { - header += ` (${cli.color.cyan(formations.join(', '))})` - } - - cli.styledHeader(header) - cli.styledObject({ - Domains: domains, - Dynos: dynos, - }, ['Domains', 'Dynos']) - cli.log() - }) - } - } -} - -module.exports = { - topic: 'spaces', - command: 'topology', - description: 'show space topology', - needsAuth: true, - args: [{name: 'space', optional: true, hidden: true}], - flags: [ - {name: 'space', char: 's', hasValue: true, description: 'space to get topology of'}, - {name: 'json', description: 'output in json format'}, - ], - render: render, - run: cli.command(run), -} diff --git a/packages/spaces/index.js b/packages/spaces/index.js index c77bef9735..8f0c3dd288 100644 --- a/packages/spaces/index.js +++ b/packages/spaces/index.js @@ -20,7 +20,6 @@ exports.commands = [ require('./commands/vpn/update'), require('./commands/ps'), require('./commands/transfer'), - require('./commands/topology'), require('./commands/drains/get'), require('./commands/drains/set'), require('./commands/trusted-ips'), diff --git a/packages/spaces/test/unit/commands/topology.unit.test.js b/packages/spaces/test/unit/commands/topology.unit.test.js deleted file mode 100644 index 0ca6e83442..0000000000 --- a/packages/spaces/test/unit/commands/topology.unit.test.js +++ /dev/null @@ -1,174 +0,0 @@ -'use strict' -/* globals beforeEach */ - -const nock = require('nock') -const cmd = require('../../../commands/topology') -const expect = require('chai').expect -const cli = require('heroku-cli-util') - -const topo = { - version: 1, - apps: [ - {id: '01234567-89ab-cdef-0123-456789abcdef', - domains: [ - 'example.com', - 'example.net', - ], - formations: [ - {'id"': '01234567-89ab-cdef-0123-456789abcdef', - process_type: 'web', - dynos: [ - {id: '01234567-89ab-cdef-0123-456789abcdef', - number: 1, - private_ip: '10.0.134.42', - hostname: '1.example-app-90210.app.localspace', - }, - {id: '01234567-89ab-cdef-0123-456789abcdeb', - number: 2, - private_ip: '10.0.134.42', - hostname: '1.example-app-90210.app.localspace', - }, - ], - }, - ], - }, - ], -} - -const topo2 = { - version: 1, - apps: [ - {id: '01234567-89ab-cdef-0123-456789abcdef', - domains: [ - 'example.com', - 'example.net', - ], - formations: [ - {'id"': '01234567-89ab-cdef-0123-456789abcdef', - process_type: 'web', - dynos: [ - {id: '01234567-89ab-cdef-0123-456789abcdef', - number: 2, - private_ip: '10.0.134.42', - hostname: '1.example-app-90210.app.localspace', - }, - {id: '01234567-89ab-cdef-0123-456789abcdeb', - number: 1, - private_ip: '10.0.134.42', - hostname: '1.example-app-90210.app.localspace', - }, - ], - }, - ], - }, - ], -} - -const topo3 = { - version: 1, - apps: [ - {id: '01234567-89ab-cdef-0123-456789abcdef', - domains: [ - 'example.com', - 'example.net', - ], - formations: [ - {'id"': '01234567-89ab-cdef-0123-456789abcdef', - process_type: 'web', - dynos: [ - {id: '01234567-89ab-cdef-0123-456789abcdef', - number: 1, - private_ip: '10.0.134.42', - hostname: '1.example-app-90210.app.localspace', - }, - {id: '01234567-89ab-cdef-0123-456789abcdeb', - number: 1, - private_ip: '10.0.134.42', - hostname: '1.example-app-90210.app.localspace', - }, - ], - }, - ], - }, - ], -} - -const app = { - id: '01234567-89ab-cdef-0123-456789abcdef', - name: 'app-name', -} - -describe('spaces:topology', function () { - beforeEach(() => cli.mockConsole()) - - it('shows space topology', function () { - let api = nock('https://api.heroku.com:443') - .get('/spaces/my-space/topology').reply(200, topo) - .get(`/apps/${app.id}`).reply(200, app) - - return cmd.run({flags: {space: 'my-space'}}) - .then(() => expect(cli.stdout).to.equal( - `=== app-name (web) -Domains: example.com - example.net -Dynos: web.1 - 10.0.134.42 - 1.example-app-90210.app.localspace - web.2 - 10.0.134.42 - 1.example-app-90210.app.localspace - -`)) - .then(() => api.done()) - }) - - it('shows space topology with first dyno having higher process number', function () { - let api = nock('https://api.heroku.com:443') - .get('/spaces/my-space/topology').reply(200, topo2) - .get(`/apps/${app.id}`).reply(200, app) - - return cmd.run({flags: {space: 'my-space'}}) - .then(() => expect(cli.stdout).to.equal( - `=== app-name (web) -Domains: example.com - example.net -Dynos: web.1 - 10.0.134.42 - 1.example-app-90210.app.localspace - web.2 - 10.0.134.42 - 1.example-app-90210.app.localspace - -`)) - .then(() => api.done()) - }) - - it('shows space topology with dynos having same process number', function () { - let api = nock('https://api.heroku.com:443') - .get('/spaces/my-space/topology').reply(200, topo3) - .get(`/apps/${app.id}`).reply(200, app) - - return cmd.run({flags: {space: 'my-space'}}) - .then(() => expect(cli.stdout).to.equal( - `=== app-name (web) -Domains: example.com - example.net -Dynos: web.1 - 10.0.134.42 - 1.example-app-90210.app.localspace - web.1 - 10.0.134.42 - 1.example-app-90210.app.localspace - -`)) - .then(() => api.done()) - }) - - it('shows space topology --json', function () { - let api = nock('https://api.heroku.com:443') - .get('/spaces/my-space/topology').reply(200, topo) - .get(`/apps/${app.id}`).reply(200, app) - - return cmd.run({flags: {space: 'my-space', json: true}}) - .then(() => expect(JSON.parse(cli.stdout)).to.eql(topo)) - .then(() => api.done()) - }) - - it('shows space topology --json', function () { - let api = nock('https://api.heroku.com:443') - .get('/spaces/my-space/topology').reply(200, topo) - .get(`/apps/${app.id}`).reply(200, app) - - return cmd.run({flags: {space: 'my-space', json: true}}) - .then(() => expect(JSON.parse(cli.stdout)).to.eql(topo)) - .then(() => api.done()) - }) -}) From 884db05bcdc6914100d689d5ae127f22ff2276b9 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Wed, 24 Apr 2024 09:59:33 -0700 Subject: [PATCH 2/2] No need to set .sort as it is an in-place operation --- packages/cli/src/commands/spaces/topology.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/spaces/topology.ts b/packages/cli/src/commands/spaces/topology.ts index cf24843374..5b164950c2 100644 --- a/packages/cli/src/commands/spaces/topology.ts +++ b/packages/cli/src/commands/spaces/topology.ts @@ -60,8 +60,8 @@ export default class Topology extends Command { ux.styledJSON(topology) } else if (topology.apps) { topology.apps.forEach(app => { - let formations: string[] = [] - let dynos: string[] = [] + const formations: string[] = [] + const dynos: string[] = [] if (app.formations) { app.formations.forEach(formation => { formations.push(formation.process_type) @@ -75,8 +75,8 @@ export default class Topology extends Command { } const domains = app.domains.sort() - formations = formations.sort() - dynos = dynos.sort((a, b) => { + formations.sort() + dynos.sort((a, b) => { const apt = this.getProcessType(a) const bpt = this.getProcessType(b) if (apt > bpt) {