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