1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
14 "git.arvados.org/arvados.git/lib/config"
15 "git.arvados.org/arvados.git/sdk/go/arvados"
16 "git.arvados.org/arvados.git/sdk/go/arvadosclient"
17 "git.arvados.org/arvados.git/sdk/go/keepclient"
26 "github.com/sirupsen/logrus"
29 // LegacyNodeInfo is a struct for records created by Arvados Node Manager (Arvados <= 1.4.3)
32 // "total_cpu_cores":2,
33 // "total_scratch_mb":33770,
39 // "total_ram_mb":7986
41 type LegacyNodeInfo struct {
42 CPUCores int64 `json:"total_cpu_cores"`
43 ScratchMb int64 `json:"total_scratch_mb"`
44 RAMMb int64 `json:"total_ram_mb"`
45 CloudNode LegacyCloudNode `json:"cloud_node"`
48 // LegacyCloudNode is a struct for records created by Arvados Node Manager (Arvados <= 1.4.3)
49 type LegacyCloudNode struct {
50 Price float64 `json:"price"`
51 Size string `json:"size"`
54 // Node is a struct for records created by Arvados Dispatch Cloud (Arvados >= 2.0.0)
57 // "Name": "Standard_D1_v2",
58 // "ProviderType": "Standard_D1_v2",
61 // "Scratch": 50000000000,
62 // "IncludedScratch": 50000000000,
65 // "Preemptible": false
77 type arrayFlags []string
79 func (i *arrayFlags) String() string {
83 func (i *arrayFlags) Set(value string) error {
84 *i = append(*i, value)
88 func parseFlags(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stderr io.Writer) (exitCode int, uuids arrayFlags, resultsDir string, cache bool) {
89 flags := flag.NewFlagSet("", flag.ContinueOnError)
90 flags.SetOutput(stderr)
91 flags.Usage = func() {
92 fmt.Fprintf(flags.Output(), `
96 This program analyzes the cost of Arvados container requests. For each uuid
97 supplied, it creates a CSV report that lists all the containers used to
98 fulfill the container request, together with the machine type and cost of
101 When supplied with the uuid of a container request, it will calculate the
102 cost of that container request and all its children. When suplied with a
103 project uuid or when supplied with multiple container request uuids, it will
104 create a CSV report for each supplied uuid, as well as a CSV file with
105 aggregate cost accounting for all supplied uuids. The aggregate cost report
106 takes container reuse into account: if a container was reused between several
107 container requests, its cost will only be counted once.
109 To get the node costs, the progam queries the Arvados API for current cost
110 data for each node type used. This means that the reported cost always
111 reflects the cost data as currently defined in the Arvados API configuration
115 - the Arvados API configuration cost data may be out of sync with the cloud
117 - when generating reports for older container requests, the cost data in the
118 Arvados API configuration file may have changed since the container request
119 was fulfilled. This program uses the cost data stored at the time of the
120 execution of the container, stored in the 'node.json' file in its log
123 In order to get the data for the uuids supplied, the ARVADOS_API_HOST and
124 ARVADOS_API_TOKEN environment variables must be set.
128 flags.PrintDefaults()
130 loglevel := flags.String("log-level", "info", "logging level (debug, info, ...)")
131 resultsDir = *flags.String("output", "results", "output directory for the CSV reports")
132 flags.Var(&uuids, "uuid", "Toplevel project or container request uuid. May be specified more than once.")
133 flags.BoolVar(&cache, "cache", true, "create and use a local disk cache of Arvados objects")
134 err := flags.Parse(args)
135 if err == flag.ErrHelp {
138 } else if err != nil {
144 logger.Errorf("Error: no uuid(s) provided")
150 lvl, err := logrus.ParseLevel(*loglevel)
159 func ensureDirectory(logger *logrus.Logger, dir string) (err error) {
160 statData, err := os.Stat(dir)
161 if os.IsNotExist(err) {
162 err = os.MkdirAll(dir, 0700)
164 return fmt.Errorf("Error creating directory %s: %s\n", dir, err.Error())
167 if !statData.IsDir() {
168 return fmt.Errorf("The path %s is not a directory\n", dir)
174 func addContainerLine(logger *logrus.Logger, node interface{}, cr, container map[string]interface{}) (csv string, cost float64) {
175 csv = cr["uuid"].(string) + ","
176 csv += cr["name"].(string) + ","
177 csv += container["uuid"].(string) + ","
178 csv += container["state"].(string) + ","
179 if container["started_at"] != nil {
180 csv += container["started_at"].(string) + ","
185 var delta time.Duration
186 if container["finished_at"] != nil {
187 csv += container["finished_at"].(string) + ","
188 finishedTimestamp, err := time.Parse("2006-01-02T15:04:05.000000000Z", container["finished_at"].(string))
192 startedTimestamp, err := time.Parse("2006-01-02T15:04:05.000000000Z", container["started_at"].(string))
196 delta = finishedTimestamp.Sub(startedTimestamp)
197 csv += strconv.FormatFloat(delta.Seconds(), 'f', 0, 64) + ","
203 switch n := node.(type) {
206 size = n.ProviderType
208 price = n.CloudNode.Price
209 size = n.CloudNode.Size
211 logger.Warn("WARNING: unknown node type found!")
213 cost = delta.Seconds() / 3600 * price
214 csv += size + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(cost, 'f', 8, 64) + "\n"
218 func loadCachedObject(logger *logrus.Logger, file string, uuid string) (reload bool, object map[string]interface{}) {
220 // See if we have a cached copy of this object
221 if _, err := os.Stat(file); err == nil {
222 data, err := ioutil.ReadFile(file)
224 logger.Errorf("error reading %q: %s", file, err)
227 err = json.Unmarshal(data, &object)
229 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
233 // See if it is in a final state, if that makes sense
234 // Projects (j7d0g) do not have state so they should always be reloaded
235 if !strings.Contains(uuid, "-j7d0g-") {
236 if object["state"].(string) == "Complete" || object["state"].(string) == "Failed" {
238 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
246 // Load an Arvados object.
247 func loadObject(logger *logrus.Logger, arv *arvadosclient.ArvadosClient, path string, uuid string, cache bool) (object map[string]interface{}, err error) {
248 err = ensureDirectory(logger, path)
253 file := path + "/" + uuid + ".json"
259 reload, object = loadCachedObject(logger, file, uuid)
265 if strings.Contains(uuid, "-j7d0g-") {
266 err = arv.Get("groups", uuid, nil, &object)
267 } else if strings.Contains(uuid, "-xvhdp-") {
268 err = arv.Get("container_requests", uuid, nil, &object)
269 } else if strings.Contains(uuid, "-dz642-") {
270 err = arv.Get("containers", uuid, nil, &object)
272 err = arv.Get("jobs", uuid, nil, &object)
275 err = fmt.Errorf("Error loading object with UUID %q:\n %s\n", uuid, err)
278 encoded, err := json.MarshalIndent(object, "", " ")
280 err = fmt.Errorf("Error marshaling object with UUID %q:\n %s\n", uuid, err)
283 err = ioutil.WriteFile(file, encoded, 0644)
285 err = fmt.Errorf("Error writing file %s:\n %s\n", file, err)
291 func getNode(arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, itemMap map[string]interface{}) (node interface{}, err error) {
292 logUuid, ok := itemMap["log_uuid"]
294 err = errors.New("No log collection")
298 var collection arvados.Collection
299 err = arv.Get("collections", logUuid.(string), nil, &collection)
301 err = fmt.Errorf("Error getting collection: %s", err)
305 var fs arvados.CollectionFileSystem
306 fs, err = collection.FileSystem(ac, kc)
308 err = fmt.Errorf("Error opening collection as filesystem: %s", err)
312 f, err = fs.Open("node.json")
314 err = fmt.Errorf("Error opening file 'node.json' in collection %s: %s", logUuid.(string), err)
318 var nodeDict map[string]interface{}
319 buf := new(bytes.Buffer)
320 _, err = buf.ReadFrom(f)
322 err = fmt.Errorf("Error reading file 'node.json' in collection %s: %s", logUuid.(string), err)
325 contents := buf.String()
328 err = json.Unmarshal([]byte(contents), &nodeDict)
330 err = fmt.Errorf("Error unmarshalling: %s", err)
333 if val, ok := nodeDict["properties"]; ok {
335 encoded, err = json.MarshalIndent(val, "", " ")
337 err = fmt.Errorf("Error marshalling: %s", err)
340 // node is type LegacyNodeInfo
341 var newNode LegacyNodeInfo
342 err = json.Unmarshal(encoded, &newNode)
344 err = fmt.Errorf("Error unmarshalling: %s", err)
351 err = json.Unmarshal([]byte(contents), &newNode)
353 err = fmt.Errorf("Error unmarshalling: %s", err)
361 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) {
363 cost = make(map[string]float64)
365 project, err := loadObject(logger, arv, resultsDir+"/"+uuid, uuid, cache)
367 return nil, fmt.Errorf("Error loading object %s: %s\n", uuid, err.Error())
370 // arv -f uuid container_request list --filters '[["owner_uuid","=","<someuuid>"],["requesting_container_uuid","=",null]]'
372 // Now find all container requests that have the container we found above as requesting_container_uuid
373 var childCrs map[string]interface{}
374 filterset := []arvados.Filter{
378 Operand: project["uuid"].(string),
381 Attr: "requesting_container_uuid",
386 err = ac.RequestAndDecodeContext(context.Background(), &childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
387 "filters": filterset,
391 return nil, fmt.Errorf("Error querying container_requests: %s\n", err.Error())
393 if value, ok := childCrs["items"]; ok {
394 logger.Infof("Collecting top level container requests in project %s\n", uuid)
395 items := value.([]interface{})
396 for _, item := range items {
397 itemMap := item.(map[string]interface{})
398 crCsv, err := generateCrCsv(logger, itemMap["uuid"].(string), arv, ac, kc, resultsDir, cache)
400 return nil, fmt.Errorf("Error generating container_request CSV: %s\n", err.Error())
402 for k, v := range crCsv {
407 logger.Infof("No top level container requests found in project %s\n", uuid)
412 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) {
414 cost = make(map[string]float64)
416 csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Hourly node cost,Total cost\n"
418 var tmpTotalCost float64
419 var totalCost float64
421 // This is a container request, find the container
422 cr, err := loadObject(logger, arv, resultsDir+"/"+uuid, uuid, cache)
424 return nil, fmt.Errorf("Error loading object %s: %s", uuid, err)
426 container, err := loadObject(logger, arv, resultsDir+"/"+uuid, cr["container_uuid"].(string), cache)
428 return nil, fmt.Errorf("Error loading object %s: %s", cr["container_uuid"].(string), err)
431 topNode, err := getNode(arv, ac, kc, cr)
433 return nil, fmt.Errorf("Error getting node %s: %s\n", cr["uuid"], err)
435 tmpCsv, totalCost = addContainerLine(logger, topNode, cr, container)
437 totalCost += tmpTotalCost
438 cost[container["uuid"].(string)] = totalCost
440 // Now find all container requests that have the container we found above as requesting_container_uuid
441 var childCrs map[string]interface{}
442 filterset := []arvados.Filter{
444 Attr: "requesting_container_uuid",
446 Operand: container["uuid"].(string),
448 err = ac.RequestAndDecodeContext(context.Background(), &childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
449 "filters": filterset,
453 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
455 if value, ok := childCrs["items"]; ok {
456 logger.Infof("Collecting child containers for container request %s", uuid)
457 items := value.([]interface{})
458 for _, item := range items {
460 itemMap := item.(map[string]interface{})
461 node, err := getNode(arv, ac, kc, itemMap)
463 return nil, fmt.Errorf("Error getting node %s: %s\n", itemMap["uuid"], err)
465 logger.Debug("\nChild container: " + itemMap["container_uuid"].(string) + "\n")
466 c2, err := loadObject(logger, arv, resultsDir+"/"+uuid, itemMap["container_uuid"].(string), cache)
468 return nil, fmt.Errorf("Error loading object %s: %s", cr["container_uuid"].(string), err)
470 tmpCsv, tmpTotalCost = addContainerLine(logger, node, itemMap, c2)
471 cost[itemMap["container_uuid"].(string)] = tmpTotalCost
473 totalCost += tmpTotalCost
476 logger.Info(" done\n")
478 csv += "TOTAL,,,,,,,,," + strconv.FormatFloat(totalCost, 'f', 8, 64) + "\n"
480 // Write the resulting CSV file
481 fName := resultsDir + "/" + uuid + ".csv"
482 err = ioutil.WriteFile(fName, []byte(csv), 0644)
484 return nil, fmt.Errorf("Error writing file with path %s: %s\n", fName, err.Error())
490 func costanalyzer(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int) {
491 exitcode, uuids, resultsDir, cache := parseFlags(prog, args, loader, logger, stderr)
495 err := ensureDirectory(logger, resultsDir)
497 logger.Errorf("%s", err)
502 // Arvados Client setup
503 arv, err := arvadosclient.MakeArvadosClient()
505 logger.Errorf("error creating Arvados object: %s", err)
509 kc, err := keepclient.MakeKeepClient(arv)
511 logger.Errorf("error creating Keep object: %s", err)
516 ac := arvados.NewClientFromEnv()
518 cost := make(map[string]float64)
519 for _, uuid := range uuids {
520 if strings.Contains(uuid, "-j7d0g-") {
521 // This is a project (group)
522 cost, err = handleProject(logger, uuid, arv, ac, kc, resultsDir, cache)
525 logger.Info(err.Error())
529 for k, v := range cost {
532 } else if strings.Contains(uuid, "-xvhdp-") {
533 // This is a container request
534 crCsv, err := generateCrCsv(logger, uuid, arv, ac, kc, resultsDir, cache)
536 logger.Fatalf("Error generating container_request CSV: %s\n", err.Error())
538 for k, v := range crCsv {
541 } else if strings.Contains(uuid, "-tpzed-") {
542 // This is a user. The "Home" project for a user is not a real project.
543 // It is identified by the user uuid. As such, cost analysis for the
544 // "Home" project is not supported by this program.
545 logger.Errorf("Cost analysis is not supported for the 'Home' project: %s", uuid)
550 for k := range cost {
551 logger.Infof("Uuid report in %s/%s.csv\n", resultsDir, k)
555 logger.Info("Nothing to do!\n")
561 csv = "# Aggregate cost accounting for uuids:\n"
562 for _, uuid := range uuids {
563 csv += "# " + uuid + "\n"
567 for k, v := range cost {
568 csv += k + "," + strconv.FormatFloat(v, 'f', 8, 64) + "\n"
572 csv += "TOTAL," + strconv.FormatFloat(total, 'f', 8, 64) + "\n"
574 // Write the resulting CSV file
575 aFile := resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
576 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
578 logger.Errorf("Error writing file with path %s: %s\n", aFile, err.Error())
582 logger.Infof("\nAggregate cost accounting for all supplied uuids in %s\n", aFile)