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