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