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