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