16306: Merge branch 'master'
[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         "git.arvados.org/arvados.git/lib/config"
13         "git.arvados.org/arvados.git/sdk/go/arvados"
14         "git.arvados.org/arvados.git/sdk/go/arvadosclient"
15         "git.arvados.org/arvados.git/sdk/go/keepclient"
16         "io"
17         "io/ioutil"
18         "net/http"
19         "os"
20         "strconv"
21         "strings"
22         "time"
23
24         "github.com/sirupsen/logrus"
25 )
26
27 type nodeInfo struct {
28         // Legacy (records created by Arvados Node Manager with Arvados <= 1.4.3)
29         Properties struct {
30                 CloudNode struct {
31                         Price float64
32                         Size  string
33                 } `json:"cloud_node"`
34         }
35         // Modern
36         ProviderType string
37         Price        float64
38 }
39
40 type arrayFlags []string
41
42 func (i *arrayFlags) String() string {
43         return ""
44 }
45
46 func (i *arrayFlags) Set(value string) error {
47         for _, s := range strings.Split(value, ",") {
48                 *i = append(*i, s)
49         }
50         return nil
51 }
52
53 func parseFlags(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stderr io.Writer) (exitCode int, uuids arrayFlags, resultsDir string, cache bool, err error) {
54         flags := flag.NewFlagSet("", flag.ContinueOnError)
55         flags.SetOutput(stderr)
56         flags.Usage = func() {
57                 fmt.Fprintf(flags.Output(), `
58 Usage:
59   %s [options ...] <uuid> ...
60
61         This program analyzes the cost of Arvados container requests. For each uuid
62         supplied, it creates a CSV report that lists all the containers used to
63         fulfill the container request, together with the machine type and cost of
64         each container. At least one uuid must be specified.
65
66         When supplied with the uuid of a container request, it will calculate the
67         cost of that container request and all its children.
68
69         When supplied with the uuid of a collection, it will see if there is a
70         container_request uuid in the properties of the collection, and if so, it
71         will calculate the cost of that container request and all its children.
72
73         When supplied with a project uuid or when supplied with multiple container
74         request or collection uuids, it will create a CSV report for each supplied
75         uuid, as well as a CSV file with aggregate cost accounting for all supplied
76         uuids. The aggregate cost report takes container reuse into account: if a
77         container was reused between several container requests, its cost will only
78         be counted once.
79
80         To get the node costs, the progam queries the Arvados API for current cost
81         data for each node type used. This means that the reported cost always
82         reflects the cost data as currently defined in the Arvados API configuration
83         file.
84
85         Caveats:
86         - the Arvados API configuration cost data may be out of sync with the cloud
87         provider.
88         - when generating reports for older container requests, the cost data in the
89         Arvados API configuration file may have changed since the container request
90         was fulfilled. This program uses the cost data stored at the time of the
91         execution of the container, stored in the 'node.json' file in its log
92         collection.
93
94         In order to get the data for the uuids supplied, the ARVADOS_API_HOST and
95         ARVADOS_API_TOKEN environment variables must be set.
96
97         This program prints the total dollar amount from the aggregate cost
98         accounting across all provided uuids on stdout.
99
100         When the '-output' option is specified, a set of CSV files with cost details
101         will be written to the provided directory.
102
103 Options:
104 `, prog)
105                 flags.PrintDefaults()
106         }
107         loglevel := flags.String("log-level", "info", "logging `level` (debug, info, ...)")
108         flags.StringVar(&resultsDir, "output", "", "output `directory` for the CSV reports")
109         flags.BoolVar(&cache, "cache", true, "create and use a local disk cache of Arvados objects")
110         err = flags.Parse(args)
111         if err == flag.ErrHelp {
112                 err = nil
113                 exitCode = 1
114                 return
115         } else if err != nil {
116                 exitCode = 2
117                 return
118         }
119         uuids = flags.Args()
120
121         if len(uuids) < 1 {
122                 flags.Usage()
123                 err = fmt.Errorf("error: no uuid(s) provided")
124                 exitCode = 2
125                 return
126         }
127
128         lvl, err := logrus.ParseLevel(*loglevel)
129         if err != nil {
130                 exitCode = 2
131                 return
132         }
133         logger.SetLevel(lvl)
134         if !cache {
135                 logger.Debug("Caching disabled\n")
136         }
137         return
138 }
139
140 func ensureDirectory(logger *logrus.Logger, dir string) (err error) {
141         statData, err := os.Stat(dir)
142         if os.IsNotExist(err) {
143                 err = os.MkdirAll(dir, 0700)
144                 if err != nil {
145                         return fmt.Errorf("error creating directory %s: %s", dir, err.Error())
146                 }
147         } else {
148                 if !statData.IsDir() {
149                         return fmt.Errorf("the path %s is not a directory", dir)
150                 }
151         }
152         return
153 }
154
155 func addContainerLine(logger *logrus.Logger, node nodeInfo, cr arvados.ContainerRequest, container arvados.Container) (csv string, cost float64) {
156         csv = cr.UUID + ","
157         csv += cr.Name + ","
158         csv += container.UUID + ","
159         csv += string(container.State) + ","
160         if container.StartedAt != nil {
161                 csv += container.StartedAt.String() + ","
162         } else {
163                 csv += ","
164         }
165
166         var delta time.Duration
167         if container.FinishedAt != nil {
168                 csv += container.FinishedAt.String() + ","
169                 delta = container.FinishedAt.Sub(*container.StartedAt)
170                 csv += strconv.FormatFloat(delta.Seconds(), 'f', 0, 64) + ","
171         } else {
172                 csv += ",,"
173         }
174         var price float64
175         var size string
176         if node.Properties.CloudNode.Price != 0 {
177                 price = node.Properties.CloudNode.Price
178                 size = node.Properties.CloudNode.Size
179         } else {
180                 price = node.Price
181                 size = node.ProviderType
182         }
183         cost = delta.Seconds() / 3600 * price
184         csv += size + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(cost, 'f', 8, 64) + "\n"
185         return
186 }
187
188 func loadCachedObject(logger *logrus.Logger, file string, uuid string, object interface{}) (reload bool) {
189         reload = true
190         if strings.Contains(uuid, "-j7d0g-") || strings.Contains(uuid, "-4zz18-") {
191                 // We do not cache projects or collections, they have no final state
192                 return
193         }
194         // See if we have a cached copy of this object
195         _, err := os.Stat(file)
196         if err != nil {
197                 return
198         }
199         data, err := ioutil.ReadFile(file)
200         if err != nil {
201                 logger.Errorf("error reading %q: %s", file, err)
202                 return
203         }
204         err = json.Unmarshal(data, &object)
205         if err != nil {
206                 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
207                 return
208         }
209
210         // See if it is in a final state, if that makes sense
211         switch v := object.(type) {
212         case *arvados.ContainerRequest:
213                 if v.State == arvados.ContainerRequestStateFinal {
214                         reload = false
215                         logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
216                 }
217         case *arvados.Container:
218                 if v.State == arvados.ContainerStateComplete || v.State == arvados.ContainerStateCancelled {
219                         reload = false
220                         logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
221                 }
222         }
223         return
224 }
225
226 // Load an Arvados object.
227 func loadObject(logger *logrus.Logger, ac *arvados.Client, path string, uuid string, cache bool, object interface{}) (err error) {
228         file := uuid + ".json"
229
230         var reload bool
231         var cacheDir string
232
233         if !cache {
234                 reload = true
235         } else {
236                 homeDir, err := os.UserHomeDir()
237                 if err != nil {
238                         reload = true
239                         logger.Info("Unable to determine current user home directory, not using cache")
240                 } else {
241                         cacheDir = homeDir + "/.cache/arvados/costanalyzer/"
242                         err = ensureDirectory(logger, cacheDir)
243                         if err != nil {
244                                 reload = true
245                                 logger.Infof("Unable to create cache directory at %s, not using cache: %s", cacheDir, err.Error())
246                         } else {
247                                 reload = loadCachedObject(logger, cacheDir+file, uuid, object)
248                         }
249                 }
250         }
251         if !reload {
252                 return
253         }
254
255         if strings.Contains(uuid, "-j7d0g-") {
256                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/groups/"+uuid, nil, nil)
257         } else if strings.Contains(uuid, "-xvhdp-") {
258                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/container_requests/"+uuid, nil, nil)
259         } else if strings.Contains(uuid, "-dz642-") {
260                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/containers/"+uuid, nil, nil)
261         } else if strings.Contains(uuid, "-4zz18-") {
262                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/collections/"+uuid, nil, nil)
263         } else {
264                 err = fmt.Errorf("unsupported object type with UUID %q:\n  %s", uuid, err)
265                 return
266         }
267         if err != nil {
268                 err = fmt.Errorf("error loading object with UUID %q:\n  %s", uuid, err)
269                 return
270         }
271         encoded, err := json.MarshalIndent(object, "", " ")
272         if err != nil {
273                 err = fmt.Errorf("error marshaling object with UUID %q:\n  %s", uuid, err)
274                 return
275         }
276         if cacheDir != "" {
277                 err = ioutil.WriteFile(cacheDir+file, encoded, 0644)
278                 if err != nil {
279                         err = fmt.Errorf("error writing file %s:\n  %s", file, err)
280                         return
281                 }
282         }
283         return
284 }
285
286 func getNode(arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, cr arvados.ContainerRequest) (node nodeInfo, err error) {
287         if cr.LogUUID == "" {
288                 err = errors.New("no log collection")
289                 return
290         }
291
292         var collection arvados.Collection
293         err = ac.RequestAndDecode(&collection, "GET", "arvados/v1/collections/"+cr.LogUUID, nil, nil)
294         if err != nil {
295                 err = fmt.Errorf("error getting collection: %s", err)
296                 return
297         }
298
299         var fs arvados.CollectionFileSystem
300         fs, err = collection.FileSystem(ac, kc)
301         if err != nil {
302                 err = fmt.Errorf("error opening collection as filesystem: %s", err)
303                 return
304         }
305         var f http.File
306         f, err = fs.Open("node.json")
307         if err != nil {
308                 err = fmt.Errorf("error opening file 'node.json' in collection %s: %s", cr.LogUUID, err)
309                 return
310         }
311
312         err = json.NewDecoder(f).Decode(&node)
313         if err != nil {
314                 err = fmt.Errorf("error reading file 'node.json' in collection %s: %s", cr.LogUUID, err)
315                 return
316         }
317         return
318 }
319
320 func handleProject(logger *logrus.Logger, uuid string, arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, resultsDir string, cache bool) (cost map[string]float64, err error) {
321         cost = make(map[string]float64)
322
323         var project arvados.Group
324         err = loadObject(logger, ac, uuid, uuid, cache, &project)
325         if err != nil {
326                 return nil, fmt.Errorf("error loading object %s: %s", uuid, err.Error())
327         }
328
329         var childCrs map[string]interface{}
330         filterset := []arvados.Filter{
331                 {
332                         Attr:     "owner_uuid",
333                         Operator: "=",
334                         Operand:  project.UUID,
335                 },
336                 {
337                         Attr:     "requesting_container_uuid",
338                         Operator: "=",
339                         Operand:  nil,
340                 },
341         }
342         err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
343                 "filters": filterset,
344                 "limit":   10000,
345         })
346         if err != nil {
347                 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
348         }
349         if value, ok := childCrs["items"]; ok {
350                 logger.Infof("Collecting top level container requests in project %s\n", uuid)
351                 items := value.([]interface{})
352                 for _, item := range items {
353                         itemMap := item.(map[string]interface{})
354                         crCsv, err := generateCrCsv(logger, itemMap["uuid"].(string), arv, ac, kc, resultsDir, cache)
355                         if err != nil {
356                                 return nil, fmt.Errorf("error generating container_request CSV: %s", err.Error())
357                         }
358                         for k, v := range crCsv {
359                                 cost[k] = v
360                         }
361                 }
362         } else {
363                 logger.Infof("No top level container requests found in project %s\n", uuid)
364         }
365         return
366 }
367
368 func generateCrCsv(logger *logrus.Logger, uuid string, arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, resultsDir string, cache bool) (cost map[string]float64, err error) {
369
370         cost = make(map[string]float64)
371
372         csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Hourly node cost,Total cost\n"
373         var tmpCsv string
374         var tmpTotalCost float64
375         var totalCost float64
376
377         var crUUID = uuid
378         if strings.Contains(uuid, "-4zz18-") {
379                 // This is a collection, find the associated container request (if any)
380                 var c arvados.Collection
381                 err = loadObject(logger, ac, uuid, uuid, cache, &c)
382                 if err != nil {
383                         return nil, fmt.Errorf("error loading collection object %s: %s", uuid, err)
384                 }
385                 value, ok := c.Properties["container_request"]
386                 if !ok {
387                         return nil, fmt.Errorf("error: collection %s does not have a 'container_request' property", uuid)
388                 }
389                 crUUID, ok = value.(string)
390                 if !ok {
391                         return nil, fmt.Errorf("error: collection %s does not have a 'container_request' property of the string type", uuid)
392                 }
393         }
394
395         // This is a container request, find the container
396         var cr arvados.ContainerRequest
397         err = loadObject(logger, ac, crUUID, crUUID, cache, &cr)
398         if err != nil {
399                 return nil, fmt.Errorf("error loading cr object %s: %s", uuid, err)
400         }
401         var container arvados.Container
402         err = loadObject(logger, ac, crUUID, cr.ContainerUUID, cache, &container)
403         if err != nil {
404                 return nil, fmt.Errorf("error loading container object %s: %s", cr.ContainerUUID, err)
405         }
406
407         topNode, err := getNode(arv, ac, kc, cr)
408         if err != nil {
409                 return nil, fmt.Errorf("error getting node %s: %s", cr.UUID, err)
410         }
411         tmpCsv, totalCost = addContainerLine(logger, topNode, cr, container)
412         csv += tmpCsv
413         totalCost += tmpTotalCost
414         cost[container.UUID] = totalCost
415
416         // Find all container requests that have the container we found above as requesting_container_uuid
417         var childCrs arvados.ContainerRequestList
418         filterset := []arvados.Filter{
419                 {
420                         Attr:     "requesting_container_uuid",
421                         Operator: "=",
422                         Operand:  container.UUID,
423                 }}
424         err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
425                 "filters": filterset,
426                 "limit":   10000,
427         })
428         if err != nil {
429                 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
430         }
431         logger.Infof("Collecting child containers for container request %s", crUUID)
432         for _, cr2 := range childCrs.Items {
433                 logger.Info(".")
434                 node, err := getNode(arv, ac, kc, cr2)
435                 if err != nil {
436                         return nil, fmt.Errorf("error getting node %s: %s", cr2.UUID, err)
437                 }
438                 logger.Debug("\nChild container: " + cr2.ContainerUUID + "\n")
439                 var c2 arvados.Container
440                 err = loadObject(logger, ac, cr.UUID, cr2.ContainerUUID, cache, &c2)
441                 if err != nil {
442                         return nil, fmt.Errorf("error loading object %s: %s", cr2.ContainerUUID, err)
443                 }
444                 tmpCsv, tmpTotalCost = addContainerLine(logger, node, cr2, c2)
445                 cost[cr2.ContainerUUID] = tmpTotalCost
446                 csv += tmpCsv
447                 totalCost += tmpTotalCost
448         }
449         logger.Info(" done\n")
450
451         csv += "TOTAL,,,,,,,,," + strconv.FormatFloat(totalCost, 'f', 8, 64) + "\n"
452
453         if resultsDir != "" {
454                 // Write the resulting CSV file
455                 fName := resultsDir + "/" + crUUID + ".csv"
456                 err = ioutil.WriteFile(fName, []byte(csv), 0644)
457                 if err != nil {
458                         return nil, fmt.Errorf("error writing file with path %s: %s", fName, err.Error())
459                 }
460                 logger.Infof("\nUUID report in %s\n\n", fName)
461         }
462
463         return
464 }
465
466 func costanalyzer(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int, err error) {
467         exitcode, uuids, resultsDir, cache, err := parseFlags(prog, args, loader, logger, stderr)
468         if exitcode != 0 {
469                 return
470         }
471         if resultsDir != "" {
472                 err = ensureDirectory(logger, resultsDir)
473                 if err != nil {
474                         exitcode = 3
475                         return
476                 }
477         }
478
479         // Arvados Client setup
480         arv, err := arvadosclient.MakeArvadosClient()
481         if err != nil {
482                 err = fmt.Errorf("error creating Arvados object: %s", err)
483                 exitcode = 1
484                 return
485         }
486         kc, err := keepclient.MakeKeepClient(arv)
487         if err != nil {
488                 err = fmt.Errorf("error creating Keep object: %s", err)
489                 exitcode = 1
490                 return
491         }
492
493         ac := arvados.NewClientFromEnv()
494
495         cost := make(map[string]float64)
496         for _, uuid := range uuids {
497                 if strings.Contains(uuid, "-j7d0g-") {
498                         // This is a project (group)
499                         cost, err = handleProject(logger, uuid, arv, ac, kc, resultsDir, cache)
500                         if err != nil {
501                                 exitcode = 1
502                                 return
503                         }
504                         for k, v := range cost {
505                                 cost[k] = v
506                         }
507                 } else if strings.Contains(uuid, "-xvhdp-") || strings.Contains(uuid, "-4zz18-") {
508                         // This is a container request
509                         var crCsv map[string]float64
510                         crCsv, err = generateCrCsv(logger, uuid, arv, ac, kc, resultsDir, cache)
511                         if err != nil {
512                                 err = fmt.Errorf("error generating CSV for uuid %s: %s", uuid, err.Error())
513                                 exitcode = 2
514                                 return
515                         }
516                         for k, v := range crCsv {
517                                 cost[k] = v
518                         }
519                 } else if strings.Contains(uuid, "-tpzed-") {
520                         // This is a user. The "Home" project for a user is not a real project.
521                         // It is identified by the user uuid. As such, cost analysis for the
522                         // "Home" project is not supported by this program. Skip this uuid, but
523                         // keep going.
524                         logger.Errorf("cost analysis is not supported for the 'Home' project: %s", uuid)
525                 } else {
526                         logger.Errorf("this argument does not look like a uuid: %s\n", uuid)
527                         exitcode = 3
528                         return
529                 }
530         }
531
532         if len(cost) == 0 {
533                 logger.Info("Nothing to do!\n")
534                 return
535         }
536
537         var csv string
538
539         csv = "# Aggregate cost accounting for uuids:\n"
540         for _, uuid := range uuids {
541                 csv += "# " + uuid + "\n"
542         }
543
544         var total float64
545         for k, v := range cost {
546                 csv += k + "," + strconv.FormatFloat(v, 'f', 8, 64) + "\n"
547                 total += v
548         }
549
550         csv += "TOTAL," + strconv.FormatFloat(total, 'f', 8, 64) + "\n"
551
552         if resultsDir != "" {
553                 // Write the resulting CSV file
554                 aFile := resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
555                 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
556                 if err != nil {
557                         err = fmt.Errorf("error writing file with path %s: %s", aFile, err.Error())
558                         exitcode = 1
559                         return
560                 }
561                 logger.Infof("Aggregate cost accounting for all supplied uuids in %s\n", aFile)
562         }
563
564         // Output the total dollar amount on stdout
565         fmt.Fprintf(stdout, "%s\n", strconv.FormatFloat(total, 'f', 8, 64))
566
567         return
568 }