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