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