17840: Deduplicate flag-parsing code.
[arvados.git] / lib / costanalyzer / costanalyzer.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package costanalyzer
6
7 import (
8         "encoding/json"
9         "errors"
10         "flag"
11         "fmt"
12         "io"
13         "io/ioutil"
14         "net/http"
15         "os"
16         "strconv"
17         "strings"
18         "time"
19
20         "git.arvados.org/arvados.git/lib/cmd"
21         "git.arvados.org/arvados.git/sdk/go/arvados"
22         "git.arvados.org/arvados.git/sdk/go/arvadosclient"
23         "git.arvados.org/arvados.git/sdk/go/keepclient"
24         "github.com/sirupsen/logrus"
25 )
26
27 const timestampFormat = "2006-01-02T15:04:05"
28
29 type nodeInfo struct {
30         // Legacy (records created by Arvados Node Manager with Arvados <= 1.4.3)
31         Properties struct {
32                 CloudNode struct {
33                         Price float64
34                         Size  string
35                 } `json:"cloud_node"`
36         }
37         // Modern
38         ProviderType string
39         Price        float64
40         Preemptible  bool
41 }
42
43 type consumption struct {
44         cost     float64
45         duration float64
46 }
47
48 func (c *consumption) Add(n consumption) {
49         c.cost += n.cost
50         c.duration += n.duration
51 }
52
53 type arrayFlags []string
54
55 func (i *arrayFlags) String() string {
56         return ""
57 }
58
59 func (i *arrayFlags) Set(value string) error {
60         for _, s := range strings.Split(value, ",") {
61                 *i = append(*i, s)
62         }
63         return nil
64 }
65
66 func (c *command) parseFlags(prog string, args []string, logger *logrus.Logger, stderr io.Writer) (ok bool, exitCode int) {
67         var beginStr, endStr string
68         flags := flag.NewFlagSet("", flag.ContinueOnError)
69         flags.Usage = func() {
70                 fmt.Fprintf(flags.Output(), `
71 Usage:
72   %s [options ...] [UUID ...]
73
74         This program analyzes the cost of Arvados container requests and calculates
75         the total cost across all requests. At least one UUID or a timestamp range
76         must be specified.
77
78         When the '-output' option is specified, a set of CSV files with cost details
79         will be written to the provided directory. Each file is a CSV report that lists
80         all the containers used to fulfill the container request, together with the
81         machine type and cost of each container.
82
83         When supplied with the UUID of a container request, it will calculate the
84         cost of that container request and all its children.
85
86         When supplied with the UUID of a collection, it will see if there is a
87         container_request UUID in the properties of the collection, and if so, it
88         will calculate the cost of that container request and all its children.
89
90         When supplied with a project UUID or when supplied with multiple container
91         request or collection UUIDs, it will calculate the total cost for all
92         supplied UUIDs.
93
94         When supplied with a 'begin' and 'end' timestamp (format:
95         %s), it will calculate the cost for all top-level container
96         requests whose containers finished during the specified interval.
97
98         The total cost calculation takes container reuse into account: if a container
99         was reused between several container requests, its cost will only be counted
100         once.
101
102         Caveats:
103
104         - This program uses the cost data from config.yml at the time of the
105         execution of the container, stored in the 'node.json' file in its log
106         collection. If the cost data was not correctly configured at the time the
107         container was executed, the output from this program will be incorrect.
108
109         - If a container was run on a preemptible ("spot") instance, the cost data
110         reported by this program may be wildly inaccurate, because it does not have
111         access to the spot pricing in effect for the node then the container ran. The
112         UUID report file that is generated when the '-output' option is specified has
113         a column that indicates the preemptible state of the instance that ran the
114         container.
115
116         - This program does not take into account overhead costs like the time spent
117         starting and stopping compute nodes that run containers, the cost of the
118         permanent cloud nodes that provide the Arvados services, the cost of data
119         stored in Arvados, etc.
120
121         - When provided with a project UUID, subprojects will not be considered.
122
123         In order to get the data for the UUIDs supplied, the ARVADOS_API_HOST and
124         ARVADOS_API_TOKEN environment variables must be set.
125
126         This program prints the total dollar amount from the aggregate cost
127         accounting across all provided UUIDs on stdout.
128
129 Options:
130 `, prog, timestampFormat)
131                 flags.PrintDefaults()
132         }
133         loglevel := flags.String("log-level", "info", "logging `level` (debug, info, ...)")
134         flags.StringVar(&c.resultsDir, "output", "", "output `directory` for the CSV reports")
135         flags.StringVar(&beginStr, "begin", "", fmt.Sprintf("timestamp `begin` for date range operation (format: %s)", timestampFormat))
136         flags.StringVar(&endStr, "end", "", fmt.Sprintf("timestamp `end` for date range operation (format: %s)", timestampFormat))
137         flags.BoolVar(&c.cache, "cache", true, "create and use a local disk cache of Arvados objects")
138         if ok, code := cmd.ParseFlags(flags, prog, args, "[uuid ...]", stderr); !ok {
139                 return false, code
140         }
141         c.uuids = flags.Args()
142
143         if (len(beginStr) != 0 && len(endStr) == 0) || (len(beginStr) == 0 && len(endStr) != 0) {
144                 fmt.Fprintf(stderr, "When specifying a date range, both begin and end must be specified (try -help)\n")
145                 return false, 2
146         }
147
148         if len(beginStr) != 0 {
149                 var errB, errE error
150                 c.begin, errB = time.Parse(timestampFormat, beginStr)
151                 c.end, errE = time.Parse(timestampFormat, endStr)
152                 if (errB != nil) || (errE != nil) {
153                         fmt.Fprintf(stderr, "When specifying a date range, both begin and end must be of the format %s %+v, %+v\n", timestampFormat, errB, errE)
154                         return false, 2
155                 }
156         }
157
158         if (len(c.uuids) < 1) && (len(beginStr) == 0) {
159                 fmt.Fprintf(stderr, "error: no uuid(s) provided (try -help)\n")
160                 return false, 2
161         }
162
163         lvl, err := logrus.ParseLevel(*loglevel)
164         if err != nil {
165                 fmt.Fprintf(stderr, "invalid argument to -log-level: %s\n", err)
166                 return false, 2
167         }
168         logger.SetLevel(lvl)
169         if !c.cache {
170                 logger.Debug("Caching disabled")
171         }
172         return true, 0
173 }
174
175 func ensureDirectory(logger *logrus.Logger, dir string) (err error) {
176         statData, err := os.Stat(dir)
177         if os.IsNotExist(err) {
178                 err = os.MkdirAll(dir, 0700)
179                 if err != nil {
180                         return fmt.Errorf("error creating directory %s: %s", dir, err.Error())
181                 }
182         } else {
183                 if !statData.IsDir() {
184                         return fmt.Errorf("the path %s is not a directory", dir)
185                 }
186         }
187         return
188 }
189
190 func addContainerLine(logger *logrus.Logger, node nodeInfo, cr arvados.ContainerRequest, container arvados.Container) (string, consumption) {
191         var csv string
192         var containerConsumption consumption
193         csv = cr.UUID + ","
194         csv += cr.Name + ","
195         csv += container.UUID + ","
196         csv += string(container.State) + ","
197         if container.StartedAt != nil {
198                 csv += container.StartedAt.String() + ","
199         } else {
200                 csv += ","
201         }
202
203         var delta time.Duration
204         if container.FinishedAt != nil {
205                 csv += container.FinishedAt.String() + ","
206                 delta = container.FinishedAt.Sub(*container.StartedAt)
207                 csv += strconv.FormatFloat(delta.Seconds(), 'f', 3, 64) + ","
208         } else {
209                 csv += ",,"
210         }
211         var price float64
212         var size string
213         if node.Properties.CloudNode.Price != 0 {
214                 price = node.Properties.CloudNode.Price
215                 size = node.Properties.CloudNode.Size
216         } else {
217                 price = node.Price
218                 size = node.ProviderType
219         }
220         containerConsumption.cost = delta.Seconds() / 3600 * price
221         containerConsumption.duration = delta.Seconds()
222         csv += size + "," + fmt.Sprintf("%+v", node.Preemptible) + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(containerConsumption.cost, 'f', 8, 64) + "\n"
223         return csv, containerConsumption
224 }
225
226 func loadCachedObject(logger *logrus.Logger, file string, uuid string, object interface{}) (reload bool) {
227         reload = true
228         if strings.Contains(uuid, "-j7d0g-") || strings.Contains(uuid, "-4zz18-") {
229                 // We do not cache projects or collections, they have no final state
230                 return
231         }
232         // See if we have a cached copy of this object
233         _, err := os.Stat(file)
234         if err != nil {
235                 return
236         }
237         data, err := ioutil.ReadFile(file)
238         if err != nil {
239                 logger.Errorf("error reading %q: %s", file, err)
240                 return
241         }
242         err = json.Unmarshal(data, &object)
243         if err != nil {
244                 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
245                 return
246         }
247
248         // See if it is in a final state, if that makes sense
249         switch v := object.(type) {
250         case *arvados.ContainerRequest:
251                 if v.State == arvados.ContainerRequestStateFinal {
252                         reload = false
253                         logger.Debugf("Loaded object %s from local cache (%s)", uuid, file)
254                 }
255         case *arvados.Container:
256                 if v.State == arvados.ContainerStateComplete || v.State == arvados.ContainerStateCancelled {
257                         reload = false
258                         logger.Debugf("Loaded object %s from local cache (%s)", uuid, file)
259                 }
260         }
261         return
262 }
263
264 // Load an Arvados object.
265 func loadObject(logger *logrus.Logger, ac *arvados.Client, path string, uuid string, cache bool, object interface{}) (err error) {
266         file := uuid + ".json"
267
268         var reload bool
269         var cacheDir string
270
271         if !cache {
272                 reload = true
273         } else {
274                 homeDir, err := os.UserHomeDir()
275                 if err != nil {
276                         reload = true
277                         logger.Info("Unable to determine current user home directory, not using cache")
278                 } else {
279                         cacheDir = homeDir + "/.cache/arvados/costanalyzer/"
280                         err = ensureDirectory(logger, cacheDir)
281                         if err != nil {
282                                 reload = true
283                                 logger.Infof("Unable to create cache directory at %s, not using cache: %s", cacheDir, err.Error())
284                         } else {
285                                 reload = loadCachedObject(logger, cacheDir+file, uuid, object)
286                         }
287                 }
288         }
289         if !reload {
290                 return
291         }
292
293         if strings.Contains(uuid, "-j7d0g-") {
294                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/groups/"+uuid, nil, nil)
295         } else if strings.Contains(uuid, "-xvhdp-") {
296                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/container_requests/"+uuid, nil, nil)
297         } else if strings.Contains(uuid, "-dz642-") {
298                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/containers/"+uuid, nil, nil)
299         } else if strings.Contains(uuid, "-4zz18-") {
300                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/collections/"+uuid, nil, nil)
301         } else {
302                 err = fmt.Errorf("unsupported object type with UUID %q:\n  %s", uuid, err)
303                 return
304         }
305         if err != nil {
306                 err = fmt.Errorf("error loading object with UUID %q:\n  %s", uuid, err)
307                 return
308         }
309         encoded, err := json.MarshalIndent(object, "", " ")
310         if err != nil {
311                 err = fmt.Errorf("error marshaling object with UUID %q:\n  %s", uuid, err)
312                 return
313         }
314         if cacheDir != "" {
315                 err = ioutil.WriteFile(cacheDir+file, encoded, 0644)
316                 if err != nil {
317                         err = fmt.Errorf("error writing file %s:\n  %s", file, err)
318                         return
319                 }
320         }
321         return
322 }
323
324 func getNode(arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, cr arvados.ContainerRequest) (node nodeInfo, err error) {
325         if cr.LogUUID == "" {
326                 err = errors.New("no log collection")
327                 return
328         }
329
330         var collection arvados.Collection
331         err = ac.RequestAndDecode(&collection, "GET", "arvados/v1/collections/"+cr.LogUUID, nil, nil)
332         if err != nil {
333                 err = fmt.Errorf("error getting collection: %s", err)
334                 return
335         }
336
337         var fs arvados.CollectionFileSystem
338         fs, err = collection.FileSystem(ac, kc)
339         if err != nil {
340                 err = fmt.Errorf("error opening collection as filesystem: %s", err)
341                 return
342         }
343         var f http.File
344         f, err = fs.Open("node.json")
345         if err != nil {
346                 err = fmt.Errorf("error opening file 'node.json' in collection %s: %s", cr.LogUUID, err)
347                 return
348         }
349
350         err = json.NewDecoder(f).Decode(&node)
351         if err != nil {
352                 err = fmt.Errorf("error reading file 'node.json' in collection %s: %s", cr.LogUUID, err)
353                 return
354         }
355         return
356 }
357
358 func handleProject(logger *logrus.Logger, uuid string, arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, resultsDir string, cache bool) (cost map[string]consumption, err error) {
359         cost = make(map[string]consumption)
360
361         var project arvados.Group
362         err = loadObject(logger, ac, uuid, uuid, cache, &project)
363         if err != nil {
364                 return nil, fmt.Errorf("error loading object %s: %s", uuid, err.Error())
365         }
366
367         var childCrs map[string]interface{}
368         filterset := []arvados.Filter{
369                 {
370                         Attr:     "owner_uuid",
371                         Operator: "=",
372                         Operand:  project.UUID,
373                 },
374                 {
375                         Attr:     "requesting_container_uuid",
376                         Operator: "=",
377                         Operand:  nil,
378                 },
379         }
380         err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
381                 "filters": filterset,
382                 "limit":   10000,
383         })
384         if err != nil {
385                 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
386         }
387         if value, ok := childCrs["items"]; ok {
388                 logger.Infof("Collecting top level container requests in project %s", uuid)
389                 items := value.([]interface{})
390                 for _, item := range items {
391                         itemMap := item.(map[string]interface{})
392                         crInfo, err := generateCrInfo(logger, itemMap["uuid"].(string), arv, ac, kc, resultsDir, cache)
393                         if err != nil {
394                                 return nil, fmt.Errorf("error generating container_request CSV: %s", err.Error())
395                         }
396                         for k, v := range crInfo {
397                                 cost[k] = v
398                         }
399                 }
400         } else {
401                 logger.Infof("No top level container requests found in project %s", uuid)
402         }
403         return
404 }
405
406 func generateCrInfo(logger *logrus.Logger, uuid string, arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, resultsDir string, cache bool) (cost map[string]consumption, err error) {
407
408         cost = make(map[string]consumption)
409
410         csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Preemptible,Hourly node cost,Total cost\n"
411         var tmpCsv string
412         var total, tmpTotal consumption
413         logger.Debugf("Processing %s", uuid)
414
415         var crUUID = uuid
416         if strings.Contains(uuid, "-4zz18-") {
417                 // This is a collection, find the associated container request (if any)
418                 var c arvados.Collection
419                 err = loadObject(logger, ac, uuid, uuid, cache, &c)
420                 if err != nil {
421                         return nil, fmt.Errorf("error loading collection object %s: %s", uuid, err)
422                 }
423                 value, ok := c.Properties["container_request"]
424                 if !ok {
425                         return nil, fmt.Errorf("error: collection %s does not have a 'container_request' property", uuid)
426                 }
427                 crUUID, ok = value.(string)
428                 if !ok {
429                         return nil, fmt.Errorf("error: collection %s does not have a 'container_request' property of the string type", uuid)
430                 }
431         }
432
433         // This is a container request, find the container
434         var cr arvados.ContainerRequest
435         err = loadObject(logger, ac, crUUID, crUUID, cache, &cr)
436         if err != nil {
437                 return nil, fmt.Errorf("error loading cr object %s: %s", uuid, err)
438         }
439         if len(cr.ContainerUUID) == 0 {
440                 // Nothing to do! E.g. a CR in 'Uncommitted' state.
441                 logger.Infof("No container associated with container request %s, skipping", crUUID)
442                 return nil, nil
443         }
444         var container arvados.Container
445         err = loadObject(logger, ac, crUUID, cr.ContainerUUID, cache, &container)
446         if err != nil {
447                 return nil, fmt.Errorf("error loading container object %s: %s", cr.ContainerUUID, err)
448         }
449
450         topNode, err := getNode(arv, ac, kc, cr)
451         if err != nil {
452                 logger.Errorf("Skipping container request %s: error getting node %s: %s", cr.UUID, cr.UUID, err)
453                 return nil, nil
454         }
455         tmpCsv, total = addContainerLine(logger, topNode, cr, container)
456         csv += tmpCsv
457         cost[container.UUID] = total
458
459         // Find all container requests that have the container we found above as requesting_container_uuid
460         var childCrs arvados.ContainerRequestList
461         filterset := []arvados.Filter{
462                 {
463                         Attr:     "requesting_container_uuid",
464                         Operator: "=",
465                         Operand:  container.UUID,
466                 }}
467         err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
468                 "filters": filterset,
469                 "limit":   10000,
470         })
471         if err != nil {
472                 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
473         }
474         logger.Infof("Collecting child containers for container request %s (%s)", crUUID, container.FinishedAt)
475         progressTicker := time.NewTicker(5 * time.Second)
476         defer progressTicker.Stop()
477         for i, cr2 := range childCrs.Items {
478                 select {
479                 case <-progressTicker.C:
480                         logger.Infof("... %d of %d", i+1, len(childCrs.Items))
481                 default:
482                 }
483                 node, err := getNode(arv, ac, kc, cr2)
484                 if err != nil {
485                         logger.Errorf("Skipping container request %s: error getting node %s: %s", cr2.UUID, cr2.UUID, err)
486                         continue
487                 }
488                 logger.Debug("Child container: " + cr2.ContainerUUID)
489                 var c2 arvados.Container
490                 err = loadObject(logger, ac, cr.UUID, cr2.ContainerUUID, cache, &c2)
491                 if err != nil {
492                         return nil, fmt.Errorf("error loading object %s: %s", cr2.ContainerUUID, err)
493                 }
494                 tmpCsv, tmpTotal = addContainerLine(logger, node, cr2, c2)
495                 cost[cr2.ContainerUUID] = tmpTotal
496                 csv += tmpCsv
497                 total.Add(tmpTotal)
498         }
499         logger.Debug("Done collecting child containers")
500
501         csv += "TOTAL,,,,,," + strconv.FormatFloat(total.duration, 'f', 3, 64) + ",,,," + strconv.FormatFloat(total.cost, 'f', 2, 64) + "\n"
502
503         if resultsDir != "" {
504                 // Write the resulting CSV file
505                 fName := resultsDir + "/" + crUUID + ".csv"
506                 err = ioutil.WriteFile(fName, []byte(csv), 0644)
507                 if err != nil {
508                         return nil, fmt.Errorf("error writing file with path %s: %s", fName, err.Error())
509                 }
510                 logger.Infof("\nUUID report in %s", fName)
511         }
512
513         return
514 }
515
516 func (c *command) costAnalyzer(prog string, args []string, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int, err error) {
517         var ok bool
518         ok, exitcode = c.parseFlags(prog, args, logger, stderr)
519         if !ok {
520                 return
521         }
522         if c.resultsDir != "" {
523                 err = ensureDirectory(logger, c.resultsDir)
524                 if err != nil {
525                         exitcode = 3
526                         return
527                 }
528         }
529
530         uuidChannel := make(chan string)
531
532         // Arvados Client setup
533         arv, err := arvadosclient.MakeArvadosClient()
534         if err != nil {
535                 err = fmt.Errorf("error creating Arvados object: %s", err)
536                 exitcode = 1
537                 return
538         }
539         kc, err := keepclient.MakeKeepClient(arv)
540         if err != nil {
541                 err = fmt.Errorf("error creating Keep object: %s", err)
542                 exitcode = 1
543                 return
544         }
545
546         ac := arvados.NewClientFromEnv()
547
548         // Populate uuidChannel with the requested uuid list
549         go func() {
550                 defer close(uuidChannel)
551                 for _, uuid := range c.uuids {
552                         uuidChannel <- uuid
553                 }
554
555                 if !c.begin.IsZero() {
556                         initialParams := arvados.ResourceListParams{
557                                 Filters: []arvados.Filter{{"container.finished_at", ">=", c.begin}, {"container.finished_at", "<", c.end}, {"requesting_container_uuid", "=", nil}},
558                                 Order:   "created_at",
559                         }
560                         params := initialParams
561                         for {
562                                 // This list variable must be a new one declared
563                                 // inside the loop: otherwise, items in the API
564                                 // response would get deep-merged into the items
565                                 // loaded in previous iterations.
566                                 var list arvados.ContainerRequestList
567
568                                 err := ac.RequestAndDecode(&list, "GET", "arvados/v1/container_requests", nil, params)
569                                 if err != nil {
570                                         logger.Errorf("Error getting container request list from Arvados API: %s", err)
571                                         break
572                                 }
573                                 if len(list.Items) == 0 {
574                                         break
575                                 }
576
577                                 for _, i := range list.Items {
578                                         uuidChannel <- i.UUID
579                                 }
580                                 params.Offset += len(list.Items)
581                         }
582
583                 }
584         }()
585
586         cost := make(map[string]consumption)
587
588         for uuid := range uuidChannel {
589                 logger.Debugf("Considering %s", uuid)
590                 if strings.Contains(uuid, "-j7d0g-") {
591                         // This is a project (group)
592                         cost, err = handleProject(logger, uuid, arv, ac, kc, c.resultsDir, c.cache)
593                         if err != nil {
594                                 exitcode = 1
595                                 return
596                         }
597                         for k, v := range cost {
598                                 cost[k] = v
599                         }
600                 } else if strings.Contains(uuid, "-xvhdp-") || strings.Contains(uuid, "-4zz18-") {
601                         // This is a container request or collection
602                         var crInfo map[string]consumption
603                         crInfo, err = generateCrInfo(logger, uuid, arv, ac, kc, c.resultsDir, c.cache)
604                         if err != nil {
605                                 err = fmt.Errorf("error generating CSV for uuid %s: %s", uuid, err.Error())
606                                 exitcode = 2
607                                 return
608                         }
609                         for k, v := range crInfo {
610                                 cost[k] = v
611                         }
612                 } else if strings.Contains(uuid, "-tpzed-") {
613                         // This is a user. The "Home" project for a user is not a real project.
614                         // It is identified by the user uuid. As such, cost analysis for the
615                         // "Home" project is not supported by this program. Skip this uuid, but
616                         // keep going.
617                         logger.Errorf("cost analysis is not supported for the 'Home' project: %s", uuid)
618                 } else {
619                         logger.Errorf("this argument does not look like a uuid: %s", uuid)
620                         exitcode = 3
621                         return
622                 }
623         }
624
625         if len(cost) == 0 {
626                 logger.Info("Nothing to do!")
627                 return
628         }
629
630         var csv string
631
632         csv = "# Aggregate cost accounting for uuids:\n# UUID, Duration in seconds, Total cost\n"
633         for _, uuid := range c.uuids {
634                 csv += "# " + uuid + "\n"
635         }
636
637         var total consumption
638         for k, v := range cost {
639                 csv += k + "," + strconv.FormatFloat(v.duration, 'f', 3, 64) + "," + strconv.FormatFloat(v.cost, 'f', 8, 64) + "\n"
640                 total.Add(v)
641         }
642
643         csv += "TOTAL," + strconv.FormatFloat(total.duration, 'f', 3, 64) + "," + strconv.FormatFloat(total.cost, 'f', 2, 64) + "\n"
644
645         if c.resultsDir != "" {
646                 // Write the resulting CSV file
647                 aFile := c.resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
648                 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
649                 if err != nil {
650                         err = fmt.Errorf("error writing file with path %s: %s", aFile, err.Error())
651                         exitcode = 1
652                         return
653                 }
654                 logger.Infof("Aggregate cost accounting for all supplied uuids in %s", aFile)
655         }
656
657         // Output the total dollar amount on stdout
658         fmt.Fprintf(stdout, "%s\n", strconv.FormatFloat(total.cost, 'f', 2, 64))
659
660         return
661 }