1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
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"
24 "github.com/sirupsen/logrus"
27 type nodeInfo struct {
28 // Legacy (records created by Arvados Node Manager with Arvados <= 1.4.3)
40 type arrayFlags []string
42 func (i *arrayFlags) String() string {
46 func (i *arrayFlags) Set(value string) error {
47 for _, s := range strings.Split(value, ",") {
53 func parseFlags(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stderr io.Writer) (exitCode int, uuids arrayFlags, resultsDir string, cache bool, err error) {
54 flags := flag.NewFlagSet("", flag.ContinueOnError)
55 flags.SetOutput(stderr)
56 flags.Usage = func() {
57 fmt.Fprintf(flags.Output(), `
59 %s [options ...] <uuid> ...
61 This program analyzes the cost of Arvados container requests. For each uuid
62 supplied, it creates a CSV report that lists all the containers used to
63 fulfill the container request, together with the machine type and cost of
64 each container. At least one uuid must be specified.
66 When supplied with the uuid of a container request, it will calculate the
67 cost of that container request and all its children.
69 When supplied with the uuid of a collection, it will see if there is a
70 container_request uuid in the properties of the collection, and if so, it
71 will calculate the cost of that container request and all its children.
73 When supplied with a project uuid or when supplied with multiple container
74 request or collection uuids, it will create a CSV report for each supplied
75 uuid, as well as a CSV file with aggregate cost accounting for all supplied
76 uuids. The aggregate cost report takes container reuse into account: if a
77 container was reused between several container requests, its cost will only
80 To get the node costs, the progam queries the Arvados API for current cost
81 data for each node type used. This means that the reported cost always
82 reflects the cost data as currently defined in the Arvados API configuration
86 - the Arvados API configuration cost data may be out of sync with the cloud
88 - when generating reports for older container requests, the cost data in the
89 Arvados API configuration file may have changed since the container request
90 was fulfilled. This program uses the cost data stored at the time of the
91 execution of the container, stored in the 'node.json' file in its log
94 In order to get the data for the uuids supplied, the ARVADOS_API_HOST and
95 ARVADOS_API_TOKEN environment variables must be set.
97 This program prints the total dollar amount from the aggregate cost
98 accounting across all provided uuids on stdout.
100 When the '-output' option is specified, a set of CSV files with cost details
101 will be written to the provided directory.
105 flags.PrintDefaults()
107 loglevel := flags.String("log-level", "info", "logging `level` (debug, info, ...)")
108 flags.StringVar(&resultsDir, "output", "", "output `directory` for the CSV reports")
109 flags.BoolVar(&cache, "cache", true, "create and use a local disk cache of Arvados objects")
110 err = flags.Parse(args)
111 if err == flag.ErrHelp {
115 } else if err != nil {
123 err = fmt.Errorf("error: no uuid(s) provided")
128 lvl, err := logrus.ParseLevel(*loglevel)
135 logger.Debug("Caching disabled\n")
140 func ensureDirectory(logger *logrus.Logger, dir string) (err error) {
141 statData, err := os.Stat(dir)
142 if os.IsNotExist(err) {
143 err = os.MkdirAll(dir, 0700)
145 return fmt.Errorf("error creating directory %s: %s", dir, err.Error())
148 if !statData.IsDir() {
149 return fmt.Errorf("the path %s is not a directory", dir)
155 func addContainerLine(logger *logrus.Logger, node nodeInfo, cr arvados.ContainerRequest, container arvados.Container) (csv string, cost float64) {
158 csv += container.UUID + ","
159 csv += string(container.State) + ","
160 if container.StartedAt != nil {
161 csv += container.StartedAt.String() + ","
166 var delta time.Duration
167 if container.FinishedAt != nil {
168 csv += container.FinishedAt.String() + ","
169 delta = container.FinishedAt.Sub(*container.StartedAt)
170 csv += strconv.FormatFloat(delta.Seconds(), 'f', 0, 64) + ","
176 if node.Properties.CloudNode.Price != 0 {
177 price = node.Properties.CloudNode.Price
178 size = node.Properties.CloudNode.Size
181 size = node.ProviderType
183 cost = delta.Seconds() / 3600 * price
184 csv += size + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(cost, 'f', 8, 64) + "\n"
188 func loadCachedObject(logger *logrus.Logger, file string, uuid string, object interface{}) (reload bool) {
190 if strings.Contains(uuid, "-j7d0g-") || strings.Contains(uuid, "-4zz18-") {
191 // We do not cache projects or collections, they have no final state
194 // See if we have a cached copy of this object
195 _, err := os.Stat(file)
199 data, err := ioutil.ReadFile(file)
201 logger.Errorf("error reading %q: %s", file, err)
204 err = json.Unmarshal(data, &object)
206 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
210 // See if it is in a final state, if that makes sense
211 switch v := object.(type) {
212 case *arvados.ContainerRequest:
213 if v.State == arvados.ContainerRequestStateFinal {
215 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
217 case *arvados.Container:
218 if v.State == arvados.ContainerStateComplete || v.State == arvados.ContainerStateCancelled {
220 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
226 // Load an Arvados object.
227 func loadObject(logger *logrus.Logger, ac *arvados.Client, path string, uuid string, cache bool, object interface{}) (err error) {
228 file := uuid + ".json"
236 homeDir, err := os.UserHomeDir()
239 logger.Info("Unable to determine current user home directory, not using cache")
241 cacheDir = homeDir + "/.cache/arvados/costanalyzer/"
242 err = ensureDirectory(logger, cacheDir)
245 logger.Infof("Unable to create cache directory at %s, not using cache: %s", cacheDir, err.Error())
247 reload = loadCachedObject(logger, cacheDir+file, uuid, object)
255 if strings.Contains(uuid, "-j7d0g-") {
256 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/groups/"+uuid, nil, nil)
257 } else if strings.Contains(uuid, "-xvhdp-") {
258 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/container_requests/"+uuid, nil, nil)
259 } else if strings.Contains(uuid, "-dz642-") {
260 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/containers/"+uuid, nil, nil)
261 } else if strings.Contains(uuid, "-4zz18-") {
262 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/collections/"+uuid, nil, nil)
264 err = fmt.Errorf("unsupported object type with UUID %q:\n %s", uuid, err)
268 err = fmt.Errorf("error loading object with UUID %q:\n %s", uuid, err)
271 encoded, err := json.MarshalIndent(object, "", " ")
273 err = fmt.Errorf("error marshaling object with UUID %q:\n %s", uuid, err)
277 err = ioutil.WriteFile(cacheDir+file, encoded, 0644)
279 err = fmt.Errorf("error writing file %s:\n %s", file, err)
286 func getNode(arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, cr arvados.ContainerRequest) (node nodeInfo, err error) {
287 if cr.LogUUID == "" {
288 err = errors.New("no log collection")
292 var collection arvados.Collection
293 err = ac.RequestAndDecode(&collection, "GET", "arvados/v1/collections/"+cr.LogUUID, nil, nil)
295 err = fmt.Errorf("error getting collection: %s", err)
299 var fs arvados.CollectionFileSystem
300 fs, err = collection.FileSystem(ac, kc)
302 err = fmt.Errorf("error opening collection as filesystem: %s", err)
306 f, err = fs.Open("node.json")
308 err = fmt.Errorf("error opening file 'node.json' in collection %s: %s", cr.LogUUID, err)
312 err = json.NewDecoder(f).Decode(&node)
314 err = fmt.Errorf("error reading file 'node.json' in collection %s: %s", cr.LogUUID, err)
320 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) {
321 cost = make(map[string]float64)
323 var project arvados.Group
324 err = loadObject(logger, ac, uuid, uuid, cache, &project)
326 return nil, fmt.Errorf("error loading object %s: %s", uuid, err.Error())
329 var childCrs map[string]interface{}
330 filterset := []arvados.Filter{
334 Operand: project.UUID,
337 Attr: "requesting_container_uuid",
342 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
343 "filters": filterset,
347 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
349 if value, ok := childCrs["items"]; ok {
350 logger.Infof("Collecting top level container requests in project %s\n", uuid)
351 items := value.([]interface{})
352 for _, item := range items {
353 itemMap := item.(map[string]interface{})
354 crCsv, err := generateCrCsv(logger, itemMap["uuid"].(string), arv, ac, kc, resultsDir, cache)
356 return nil, fmt.Errorf("error generating container_request CSV: %s", err.Error())
358 for k, v := range crCsv {
363 logger.Infof("No top level container requests found in project %s\n", uuid)
368 func generateCrCsv(logger *logrus.Logger, uuid string, arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, resultsDir string, cache bool) (cost map[string]float64, err error) {
370 cost = make(map[string]float64)
372 csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Hourly node cost,Total cost\n"
374 var tmpTotalCost float64
375 var totalCost float64
378 if strings.Contains(uuid, "-4zz18-") {
379 // This is a collection, find the associated container request (if any)
380 var c arvados.Collection
381 err = loadObject(logger, ac, uuid, uuid, cache, &c)
383 return nil, fmt.Errorf("error loading collection object %s: %s", uuid, err)
385 value, ok := c.Properties["container_request"]
387 return nil, fmt.Errorf("error: collection %s does not have a 'container_request' property", uuid)
389 crUUID, ok = value.(string)
391 return nil, fmt.Errorf("error: collection %s does not have a 'container_request' property of the string type", uuid)
395 // This is a container request, find the container
396 var cr arvados.ContainerRequest
397 err = loadObject(logger, ac, crUUID, crUUID, cache, &cr)
399 return nil, fmt.Errorf("error loading cr object %s: %s", uuid, err)
401 var container arvados.Container
402 err = loadObject(logger, ac, crUUID, cr.ContainerUUID, cache, &container)
404 return nil, fmt.Errorf("error loading container object %s: %s", cr.ContainerUUID, err)
407 topNode, err := getNode(arv, ac, kc, cr)
409 return nil, fmt.Errorf("error getting node %s: %s", cr.UUID, err)
411 tmpCsv, totalCost = addContainerLine(logger, topNode, cr, container)
413 totalCost += tmpTotalCost
414 cost[container.UUID] = totalCost
416 // Find all container requests that have the container we found above as requesting_container_uuid
417 var childCrs arvados.ContainerRequestList
418 filterset := []arvados.Filter{
420 Attr: "requesting_container_uuid",
422 Operand: container.UUID,
424 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
425 "filters": filterset,
429 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
431 logger.Infof("Collecting child containers for container request %s", crUUID)
432 for _, cr2 := range childCrs.Items {
434 node, err := getNode(arv, ac, kc, cr2)
436 return nil, fmt.Errorf("error getting node %s: %s", cr2.UUID, err)
438 logger.Debug("\nChild container: " + cr2.ContainerUUID + "\n")
439 var c2 arvados.Container
440 err = loadObject(logger, ac, cr.UUID, cr2.ContainerUUID, cache, &c2)
442 return nil, fmt.Errorf("error loading object %s: %s", cr2.ContainerUUID, err)
444 tmpCsv, tmpTotalCost = addContainerLine(logger, node, cr2, c2)
445 cost[cr2.ContainerUUID] = tmpTotalCost
447 totalCost += tmpTotalCost
449 logger.Info(" done\n")
451 csv += "TOTAL,,,,,,,,," + strconv.FormatFloat(totalCost, 'f', 8, 64) + "\n"
453 if resultsDir != "" {
454 // Write the resulting CSV file
455 fName := resultsDir + "/" + crUUID + ".csv"
456 err = ioutil.WriteFile(fName, []byte(csv), 0644)
458 return nil, fmt.Errorf("error writing file with path %s: %s", fName, err.Error())
460 logger.Infof("\nUUID report in %s\n\n", fName)
466 func costanalyzer(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int, err error) {
467 exitcode, uuids, resultsDir, cache, err := parseFlags(prog, args, loader, logger, stderr)
471 if resultsDir != "" {
472 err = ensureDirectory(logger, resultsDir)
479 // Arvados Client setup
480 arv, err := arvadosclient.MakeArvadosClient()
482 err = fmt.Errorf("error creating Arvados object: %s", err)
486 kc, err := keepclient.MakeKeepClient(arv)
488 err = fmt.Errorf("error creating Keep object: %s", err)
493 ac := arvados.NewClientFromEnv()
495 cost := make(map[string]float64)
496 for _, uuid := range uuids {
497 if strings.Contains(uuid, "-j7d0g-") {
498 // This is a project (group)
499 cost, err = handleProject(logger, uuid, arv, ac, kc, resultsDir, cache)
504 for k, v := range cost {
507 } else if strings.Contains(uuid, "-xvhdp-") || strings.Contains(uuid, "-4zz18-") {
508 // This is a container request
509 var crCsv map[string]float64
510 crCsv, err = generateCrCsv(logger, uuid, arv, ac, kc, resultsDir, cache)
512 err = fmt.Errorf("error generating CSV for uuid %s: %s", uuid, err.Error())
516 for k, v := range crCsv {
519 } else if strings.Contains(uuid, "-tpzed-") {
520 // This is a user. The "Home" project for a user is not a real project.
521 // It is identified by the user uuid. As such, cost analysis for the
522 // "Home" project is not supported by this program. Skip this uuid, but
524 logger.Errorf("cost analysis is not supported for the 'Home' project: %s", uuid)
526 logger.Errorf("this argument does not look like a uuid: %s\n", uuid)
533 logger.Info("Nothing to do!\n")
539 csv = "# Aggregate cost accounting for uuids:\n"
540 for _, uuid := range uuids {
541 csv += "# " + uuid + "\n"
545 for k, v := range cost {
546 csv += k + "," + strconv.FormatFloat(v, 'f', 8, 64) + "\n"
550 csv += "TOTAL," + strconv.FormatFloat(total, 'f', 8, 64) + "\n"
552 if resultsDir != "" {
553 // Write the resulting CSV file
554 aFile := resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
555 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
557 err = fmt.Errorf("error writing file with path %s: %s", aFile, err.Error())
561 logger.Infof("Aggregate cost accounting for all supplied uuids in %s\n", aFile)
564 // Output the total dollar amount on stdout
565 fmt.Fprintf(stdout, "%s\n", strconv.FormatFloat(total, 'f', 8, 64))