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