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