UNPKG

11.4 kBJavaScriptView Raw
1'use strict'
2
3const cli = require('heroku-cli-util')
4const co = require('co')
5
6function * run (ctx, api) {
7 const util = require('../../lib/util')
8 const table = util.table
9 const style = util.style
10 const formatPrice = util.formatPrice
11 const formatState = util.formatState
12 const grandfatheredPrice = util.grandfatheredPrice
13 const printf = require('printf')
14
15 const groupBy = require('lodash.groupby')
16 const some = require('lodash.some')
17 const sortBy = require('lodash.sortby')
18 const values = require('lodash.values')
19
20 // Gets *all* attachments and add-ons and filters locally because the API
21 // returns *owned* items not associated items.
22 function * addonGetter (api, app) {
23 let attachments, addons
24
25 if (app) { // don't disploy attachments globally
26 addons = api.get(`/apps/${app}/addons`, {headers: {
27 'Accept-Expansion': 'addon_service,plan',
28 'Accept': 'application/vnd.heroku+json; version=3.with-addon-billing-info'
29 }})
30
31 let sudoHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}')
32 if (sudoHeaders['X-Heroku-Sudo'] && !sudoHeaders['X-Heroku-Sudo-User']) {
33 // because the root /addon-attachments endpoint won't include relevant
34 // attachments when sudo-ing for another app, we will use the more
35 // specific API call and sacrifice listing foreign attachments.
36 attachments = api.request({
37 method: 'GET',
38 path: `/apps/${app}/addon-attachments`
39 })
40 } else {
41 // In order to display all foreign attachments, we'll get out entire
42 // attachment list
43 attachments = api.get('/addon-attachments')
44 }
45 } else {
46 addons = api.request({
47 method: 'GET',
48 path: '/addons',
49 headers: {
50 'Accept-Expansion': 'addon_service,plan',
51 'Accept': 'application/vnd.heroku+json; version=3.with-addon-billing-info'
52 }
53 })
54 }
55
56 // Get addons and attachments in parallel
57 let items = yield [addons, attachments]
58
59 function isRelevantToApp (addon) {
60 return !app ||
61 addon.app.name === app ||
62 some(addon.attachments, (att) => att.app.name === app)
63 }
64
65 attachments = groupBy(items[1], 'addon.id')
66
67 addons = []
68 items[0].forEach(function (addon) {
69 addon.attachments = attachments[addon.id] || []
70
71 delete attachments[addon.id]
72
73 if (isRelevantToApp(addon)) {
74 addons.push(addon)
75 }
76
77 addon.plan.price = grandfatheredPrice(addon)
78 })
79
80 // Any attachments left didn't have a corresponding add-on record in API.
81 // This is probably normal (because we are asking API for all attachments)
82 // but it could also be due to certain types of permissions issues, so check
83 // if the attachment looks relevant to the app, and then render whatever
84 // information we can.
85 values(attachments).forEach(function (atts) {
86 let inaccessibleAddon = {
87 app: atts[0].addon.app,
88 name: atts[0].addon.name,
89 addon_service: {},
90 plan: {},
91 attachments: atts
92 }
93
94 if (isRelevantToApp(inaccessibleAddon)) {
95 addons.push(inaccessibleAddon)
96 }
97 })
98
99 return addons
100 }
101
102 function displayAll (addons) {
103 addons = sortBy(addons, 'app.name', 'plan.name', 'addon.name')
104
105 if (addons.length === 0) {
106 cli.log('No add-ons.')
107 return
108 }
109
110 table(addons, {
111 headerAnsi: cli.color.bold,
112 columns: [{
113 key: 'app.name',
114 label: 'Owning App',
115 format: style('app')
116 }, {
117 key: 'name',
118 label: 'Add-on',
119 format: style('addon')
120 }, {
121 key: 'plan.name',
122 label: 'Plan',
123 format: function (plan) {
124 if (typeof plan === 'undefined') return style('dim', '?')
125 return plan
126 }
127 }, {
128 key: 'plan.price',
129 label: 'Price',
130 format: function (price) {
131 if (typeof price === 'undefined') return style('dim', '?')
132 return formatPrice(price)
133 }
134 },
135 {
136 key: 'state',
137 label: 'State',
138 format: function (state) {
139 switch (state) {
140 case 'provisioned':
141 state = 'created'
142 break
143 case 'provisioning':
144 state = 'creating'
145 break
146 case 'deprovisioned':
147 state = 'errored'
148 }
149 return state
150 }
151 }]
152 })
153 }
154
155 function formatAttachment (attachment, showApp) {
156 if (showApp === undefined) showApp = true
157
158 let attName = style('attachment', attachment.name)
159
160 let output = [style('dim', 'as'), attName]
161 if (showApp) {
162 let appInfo = `on ${style('app', attachment.app.name)} app`
163 output.push(style('dim', appInfo))
164 }
165
166 return output.join(' ')
167 }
168
169 function renderAttachment (attachment, app, isFirst) {
170 let line = isFirst ? '└─' : '├─'
171 let attName = formatAttachment(attachment, attachment.app.name !== app)
172 return printf(' %s %s', style('dim', line), attName)
173 }
174
175 function displayForApp (app, addons) {
176 if (addons.length === 0) {
177 cli.log(`No add-ons for app ${app}.`)
178 return
179 }
180
181 let isForeignApp = (attOrAddon) => attOrAddon.app.name !== app
182
183 function presentAddon (addon) {
184 let name = style('addon', addon.name)
185 let service = addon.addon_service.name
186
187 if (service === undefined) {
188 service = style('dim', '?')
189 }
190
191 let addonLine = `${service} (${name})`
192
193 let atts = sortBy(addon.attachments,
194 isForeignApp,
195 'app.name',
196 'name')
197
198 // render each attachment under the add-on
199 let attLines = atts.map(function (attachment, idx) {
200 let isFirst = (idx === addon.attachments.length - 1)
201 return renderAttachment(attachment, app, isFirst)
202 })
203
204 return [addonLine].concat(attLines).join('\n')
205 }
206
207 addons = sortBy(addons,
208 isForeignApp,
209 'plan.name',
210 'name')
211
212 cli.log()
213 table(addons, {
214 headerAnsi: cli.color.bold,
215 columns: [{
216 label: 'Add-on',
217 format: presentAddon
218 }, {
219 label: 'Plan',
220 key: 'plan.name',
221 format: function (name) {
222 if (name === undefined) return style('dim', '?')
223 return name.replace(/^[^:]+:/, '')
224 }
225 }, {
226 label: 'Price',
227 format: function (addon) {
228 if (addon.app.name === app) {
229 return formatPrice(addon.plan.price)
230 } else {
231 return style('dim', printf('(billed to %s app)', style('app', addon.app.name)))
232 }
233 }
234 }, {
235 label: 'State',
236 key: 'state',
237 format: formatState
238 }],
239
240 // Separate each add-on row by a blank line
241 after: () => cli.log('')
242 })
243
244 cli.log(`The table above shows ${style('addon', 'add-ons')} and the ` +
245 `${style('attachment', 'attachments')} to the current app (${app}) ` +
246 `or other ${style('app', 'apps')}.
247 `)
248 }
249
250 function displayJSON (addons) {
251 cli.log(JSON.stringify(addons, null, 2))
252 }
253
254 if (!ctx.flags.all && ctx.app) {
255 let addons = yield co(addonGetter(api, ctx.app))
256 if (ctx.flags.json) displayJSON(addons)
257 else displayForApp(ctx.app, addons)
258 } else {
259 let addons = yield co(addonGetter(api))
260 if (ctx.flags.json) displayJSON(addons)
261 else displayAll(addons)
262 }
263}
264
265let topic = 'addons'
266module.exports = {
267 topic: topic,
268 default: true,
269 needsAuth: true,
270 wantsApp: true,
271 flags: [
272 {
273 name: 'all',
274 char: 'A',
275 hasValue: false,
276 description: 'show add-ons and attachments for all accessible apps'
277 },
278 {
279 name: 'json',
280 hasValue: false,
281 description: 'return add-ons in json format'
282 }
283 ],
284
285 run: cli.command({preauth: true}, co.wrap(run)),
286 usage: `${topic} [--all|--app APP]`,
287 description: 'lists your add-ons and attachments',
288 help: `The default filter applied depends on whether you are in a Heroku app
289directory. If so, the --app flag is implied. If not, the default of --all
290is implied. Explicitly providing either flag overrides the default
291behavior.
292
293Examples:
294
295 $ heroku ${topic} --all
296 $ heroku ${topic} --app acme-inc-www
297
298Overview of Add-ons:
299
300 Add-ons are created with the \`addons:create\` command, providing a reference
301 to an add-on service (such as \`heroku-postgresql\`) or a service and plan
302 (such as \`heroku-postgresql:hobby-dev\`).
303
304 At creation, each add-on is given a globally unique name. In addition, each
305 add-on has at least one attachment alias to each application which uses the
306 add-on. In all cases, the owning application will be attached to the add-on.
307 An attachment alias is unique to its application, and is used as a prefix to
308 any environment variables it exports to the application.
309
310 In this example, a \`heroku-postgresql\` add-on is created and its given name
311 is \`postgresql-deep-6913\` with a default attachment alias of \`DATABASE\`:
312
313 $ heroku addons:create heroku-postgresql --app my-app
314 Creating postgresql-deep-6913... done, (free)
315 Adding postgresql-deep-6913 to my-app... done
316 Setting DATABASE_URL and restarting my-app... done, v5
317 Database has been created and is available
318
319 $ heroku addons --app my-app
320 Add-on Plan Price
321 ───────────────────────────────────────── ───────── ─────
322 heroku-postgresql (postgresql-deep-6913) hobby-dev free
323 └─ as DATABASE
324
325 The add-on name and, in some cases, the attachment alias can be specified by
326 the user. For instance, we can add a second database to the app, specifying
327 both these identifiers:
328
329 $ heroku addons:create heroku-postgresql --app my-app --name main-db --as PRIMARY_DB
330 Creating main-db... done, (free)
331 Adding main-db to my-app... done
332 Setting PRIMARY_DB_URL and restarting my-app... done, v6
333 Database has been created and is available
334
335 $ heroku addons --app my-app
336 Add-on Plan Price
337 ───────────────────────────────────────── ───────── ─────
338 heroku-postgresql (main-db) hobby-dev free
339 └─ as PRIMARY_DB
340
341 heroku-postgresql (postgresql-deep-6913) hobby-dev free
342 └─ as DATABASE
343
344 Attachment aliases can also be specified when making attachments:
345
346 $ heroku addons:attach main-db --app my-app --as ANOTHER_NAME
347 Attaching main-db as ANOTHER_NAME to my-app... done
348 Setting ANOTHER_NAME vars and restarting my-app... done, v7
349
350 $ heroku addons --app my-app
351 Add-on Plan Price
352 ───────────────────────────────────────── ───────── ─────
353 heroku-postgresql (main-db) hobby-dev free
354 ├─ as PRIMARY_DB
355 └─ as ANOTHER_NAME
356
357 heroku-postgresql (postgresql-deep-6913) hobby-dev free
358 └─ as DATABASE
359
360 For more information, read https://devcenter.heroku.com/articles/add-ons.`
361}