1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
13 "git.arvados.org/arvados.git/lib/config"
14 "git.arvados.org/arvados.git/sdk/go/arvados"
15 "git.arvados.org/arvados.git/sdk/go/arvadosclient"
16 "git.arvados.org/arvados.git/sdk/go/keepclient"
26 "github.com/sirupsen/logrus"
29 // Dict is a helper type so we don't have to write out 'map[string]interface{}' every time.
30 type Dict map[string]interface{}
32 // LegacyNodeInfo is a struct for records created by Arvados Node Manager (Arvados <= 1.4.3)
35 // "total_cpu_cores":2,
36 // "total_scratch_mb":33770,
42 // "total_ram_mb":7986
44 type LegacyNodeInfo struct {
45 CPUCores int64 `json:"total_cpu_cores"`
46 ScratchMb int64 `json:"total_scratch_mb"`
47 RAMMb int64 `json:"total_ram_mb"`
48 CloudNode LegacyCloudNode `json:"cloud_node"`
51 // LegacyCloudNode is a struct for records created by Arvados Node Manager (Arvados <= 1.4.3)
52 type LegacyCloudNode struct {
53 Price float64 `json:"price"`
54 Size string `json:"size"`
57 // Node is a struct for records created by Arvados Dispatch Cloud (Arvados >= 2.0.0)
60 // "Name": "Standard_D1_v2",
61 // "ProviderType": "Standard_D1_v2",
64 // "Scratch": 50000000000,
65 // "IncludedScratch": 50000000000,
68 // "Preemptible": false
80 type arrayFlags []string
82 func (i *arrayFlags) String() string {
86 func (i *arrayFlags) Set(value string) error {
87 *i = append(*i, value)
91 func parseFlags(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stderr io.Writer) (exitCode int, uuids arrayFlags, resultsDir string) {
92 flags := flag.NewFlagSet("", flag.ContinueOnError)
93 flags.SetOutput(stderr)
94 flags.Usage = func() {
95 fmt.Fprintf(flags.Output(), `
99 This program analyzes the cost of Arvados container requests. For each uuid
100 supplied, it creates a CSV report that lists all the containers used to
101 fulfill the container request, together with the machine type and cost of
104 When supplied with the uuid of a container request, it will calculate the
105 cost of that container request and all its children. When suplied with a
106 project uuid or when supplied with multiple container request uuids, it will
107 create a CSV report for each supplied uuid, as well as a CSV file with
108 aggregate cost accounting for all supplied uuids. The aggregate cost report
109 takes container reuse into account: if a container was reused between several
110 container requests, its cost will only be counted once.
112 To get the node costs, the progam queries the Arvados API for current cost
113 data for each node type used. This means that the reported cost always
114 reflects the cost data as currently defined in the Arvados API configuration
118 - the Arvados API configuration cost data may be out of sync with the cloud
120 - when generating reports for older container requests, the cost data in the
121 Arvados API configuration file may have changed since the container request
124 In order to get the data for the uuids supplied, the ARVADOS_API_HOST and
125 ARVADOS_API_TOKEN environment variables must be set.
129 flags.PrintDefaults()
131 loglevel := flags.String("log-level", "info", "logging level (debug, info, ...)")
132 resultsDir = *flags.String("output", "results", "output directory for the CSV reports")
133 flags.Var(&uuids, "uuid", "Toplevel project or container request uuid. May be specified more than once.")
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) {
160 statData, err := os.Stat(dir)
161 if os.IsNotExist(err) {
162 err = os.MkdirAll(dir, 0700)
164 logger.Errorf("Error creating directory %s: %s\n", dir, err.Error())
168 if !statData.IsDir() {
169 logger.Errorf("The path %s is not a directory\n", dir)
175 func addContainerLine(logger *logrus.Logger, node interface{}, cr Dict, container Dict) (csv string, cost float64) {
176 csv = cr["uuid"].(string) + ","
177 csv += cr["name"].(string) + ","
178 csv += container["uuid"].(string) + ","
179 csv += container["state"].(string) + ","
180 if container["started_at"] != nil {
181 csv += container["started_at"].(string) + ","
186 var delta time.Duration
187 if container["finished_at"] != nil {
188 csv += container["finished_at"].(string) + ","
189 finishedTimestamp, err := time.Parse("2006-01-02T15:04:05.000000000Z", container["finished_at"].(string))
193 startedTimestamp, err := time.Parse("2006-01-02T15:04:05.000000000Z", container["started_at"].(string))
197 delta = finishedTimestamp.Sub(startedTimestamp)
198 csv += strconv.FormatFloat(delta.Seconds(), 'f', 0, 64) + ","
204 switch n := node.(type) {
207 size = n.ProviderType
209 price = n.CloudNode.Price
210 size = n.CloudNode.Size
212 logger.Warn("WARNING: unknown node type found!")
214 cost = delta.Seconds() / 3600 * price
215 csv += size + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(cost, 'f', 8, 64) + "\n"
219 func loadCachedObject(logger *logrus.Logger, file string, uuid string) (reload bool, object Dict) {
221 // See if we have a cached copy of this object
222 if _, err := os.Stat(file); err == nil {
223 data, err := ioutil.ReadFile(file)
225 logger.Errorf("error reading %q: %s", file, err)
228 err = json.Unmarshal(data, &object)
230 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
234 // See if it is in a final state, if that makes sense
235 // Projects (j7d0g) do not have state so they should always be reloaded
236 if !strings.Contains(uuid, "-j7d0g-") {
237 if object["state"].(string) == "Complete" || object["state"].(string) == "Failed" {
239 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
247 // Load an Arvados object.
248 func loadObject(logger *logrus.Logger, arv *arvadosclient.ArvadosClient, path string, uuid string) (object Dict) {
250 ensureDirectory(logger, path)
252 file := path + "/" + uuid + ".json"
255 reload, object = loadCachedObject(logger, file, uuid)
259 if strings.Contains(uuid, "-d1hrv-") {
260 err = arv.Get("pipeline_instances", uuid, nil, &object)
261 } else if strings.Contains(uuid, "-j7d0g-") {
262 err = arv.Get("groups", uuid, nil, &object)
263 } else if strings.Contains(uuid, "-xvhdp-") {
264 err = arv.Get("container_requests", uuid, nil, &object)
265 } else if strings.Contains(uuid, "-dz642-") {
266 err = arv.Get("containers", uuid, nil, &object)
268 err = arv.Get("jobs", uuid, nil, &object)
271 logger.Errorf("Error loading object with UUID %q:\n %s\n", uuid, err)
274 encoded, err := json.MarshalIndent(object, "", " ")
276 logger.Errorf("Error marshaling object with UUID %q:\n %s\n", uuid, err)
279 err = ioutil.WriteFile(file, encoded, 0644)
281 logger.Errorf("Error writing file %s:\n %s\n", file, err)
288 func getNode(logger *logrus.Logger, arv *arvadosclient.ArvadosClient, arv2 *arvados.Client, kc *keepclient.KeepClient, itemMap Dict) (node interface{}, err error) {
289 if _, ok := itemMap["log_uuid"]; ok {
290 if itemMap["log_uuid"] == nil {
291 err = errors.New("No log collection")
295 var collection arvados.Collection
296 err = arv.Get("collections", itemMap["log_uuid"].(string), nil, &collection)
298 logger.Errorf("error getting collection: %s\n", err)
302 var fs arvados.CollectionFileSystem
303 fs, err = collection.FileSystem(arv2, kc)
305 logger.Errorf("error opening collection as filesystem: %s\n", err)
309 f, err = fs.Open("node.json")
311 logger.Errorf("error opening file in collection: %s\n", err)
316 // TODO: checkout io (ioutil?) readall function
317 buf := new(bytes.Buffer)
318 _, err = buf.ReadFrom(f)
320 logger.Errorf("error reading %q: %s\n", f, err)
323 contents := buf.String()
326 err = json.Unmarshal([]byte(contents), &nodeDict)
328 logger.Errorf("error unmarshalling: %s\n", err)
331 if val, ok := nodeDict["properties"]; ok {
333 encoded, err = json.MarshalIndent(val, "", " ")
335 logger.Errorf("error marshalling: %s\n", err)
338 // node is type LegacyNodeInfo
339 var newNode LegacyNodeInfo
340 err = json.Unmarshal(encoded, &newNode)
342 logger.Errorf("error unmarshalling: %s\n", err)
349 err = json.Unmarshal([]byte(contents), &newNode)
351 logger.Errorf("error unmarshalling: %s\n", err)
360 func handleProject(logger *logrus.Logger, uuid string, arv *arvadosclient.ArvadosClient, arv2 *arvados.Client, kc *keepclient.KeepClient, resultsDir string) (cost map[string]float64) {
362 cost = make(map[string]float64)
364 project := loadObject(logger, arv, resultsDir+"/"+uuid, uuid)
366 // arv -f uuid container_request list --filters '[["owner_uuid","=","<someuuid>"],["requesting_container_uuid","=",null]]'
368 // Now find all container requests that have the container we found above as requesting_container_uuid
369 var childCrs map[string]interface{}
370 filterset := []arvados.Filter{
374 Operand: project["uuid"].(string),
377 Attr: "requesting_container_uuid",
382 err := arv.List("container_requests", arvadosclient.Dict{"filters": filterset, "limit": 10000}, &childCrs)
384 logger.Fatalf("Error querying container_requests: %s\n", err.Error())
386 if value, ok := childCrs["items"]; ok {
387 logger.Infof("Collecting top level container requests in project %s\n", uuid)
388 items := value.([]interface{})
389 for _, item := range items {
390 itemMap := item.(map[string]interface{})
391 for k, v := range generateCrCsv(logger, itemMap["uuid"].(string), arv, arv2, kc, resultsDir) {
396 logger.Infof("No top level container requests found in project %s\n", uuid)
401 func generateCrCsv(logger *logrus.Logger, uuid string, arv *arvadosclient.ArvadosClient, arv2 *arvados.Client, kc *keepclient.KeepClient, resultsDir string) (cost map[string]float64) {
403 cost = make(map[string]float64)
405 csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Hourly node cost,Total cost\n"
407 var tmpTotalCost float64
408 var totalCost float64
410 // This is a container request, find the container
411 cr := loadObject(logger, arv, resultsDir+"/"+uuid, uuid)
412 container := loadObject(logger, arv, resultsDir+"/"+uuid, cr["container_uuid"].(string))
414 topNode, err := getNode(logger, arv, arv2, kc, cr)
416 log.Fatalf("error getting node: %s", err)
418 tmpCsv, totalCost = addContainerLine(logger, topNode, cr, container)
420 totalCost += tmpTotalCost
422 cost[container["uuid"].(string)] = totalCost
424 // Now find all container requests that have the container we found above as requesting_container_uuid
425 var childCrs map[string]interface{}
426 filterset := []arvados.Filter{
428 Attr: "requesting_container_uuid",
430 Operand: container["uuid"].(string),
432 err = arv.List("container_requests", arvadosclient.Dict{"filters": filterset, "limit": 10000}, &childCrs)
434 log.Fatal("error querying container_requests", err.Error())
436 if value, ok := childCrs["items"]; ok {
437 logger.Infof("Collecting child containers for container request %s", uuid)
438 items := value.([]interface{})
439 for _, item := range items {
441 itemMap := item.(map[string]interface{})
442 node, _ := getNode(logger, arv, arv2, kc, itemMap)
443 logger.Debug("\nChild container: " + itemMap["container_uuid"].(string) + "\n")
444 c2 := loadObject(logger, arv, resultsDir+"/"+uuid, itemMap["container_uuid"].(string))
445 tmpCsv, tmpTotalCost = addContainerLine(logger, node, itemMap, c2)
446 cost[itemMap["container_uuid"].(string)] = tmpTotalCost
448 totalCost += tmpTotalCost
451 logger.Info(" done\n")
453 csv += "TOTAL,,,,,,,,," + strconv.FormatFloat(totalCost, 'f', 8, 64) + "\n"
455 // Write the resulting CSV file
456 fName := resultsDir + "/" + uuid + ".csv"
457 err = ioutil.WriteFile(fName, []byte(csv), 0644)
459 logger.Errorf("Error writing file with path %s: %s\n", fName, err.Error())
466 func costanalyzer(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int) {
467 exitcode, uuids, resultsDir := parseFlags(prog, args, loader, logger, stderr)
472 ensureDirectory(logger, resultsDir)
474 // Arvados Client setup
475 arv, err := arvadosclient.MakeArvadosClient()
477 logger.Errorf("error creating Arvados object: %s", err)
480 kc, err := keepclient.MakeKeepClient(arv)
482 logger.Errorf("error creating Keep object: %s", err)
486 arv2 := arvados.NewClientFromEnv()
488 cost := make(map[string]float64)
490 for _, uuid := range uuids {
491 //csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Hourly node cost,Total cost\n"
493 if strings.Contains(uuid, "-d1hrv-") {
494 // This is a pipeline instance, not a job! Find the cwl-runner job.
495 pi := loadObject(logger, arv, resultsDir+"/"+uuid, uuid)
496 for _, v := range pi["components"].(map[string]interface{}) {
497 x := v.(map[string]interface{})
498 y := x["job"].(map[string]interface{})
499 uuid = y["uuid"].(string)
504 // arv -f uuid container_request list --filters '[["owner_uuid","=","<someuuid>"],["requesting_container_uuid","=",null]]'
506 if strings.Contains(uuid, "-j7d0g-") {
507 // This is a project (group)
508 for k, v := range handleProject(logger, uuid, arv, arv2, kc, resultsDir) {
511 } else if strings.Contains(uuid, "-xvhdp-") {
512 // This is a container request
513 for k, v := range generateCrCsv(logger, uuid, arv, arv2, kc, resultsDir) {
516 } else if strings.Contains(uuid, "-tpzed-") {
517 // This is a user. The "Home" project for a user is not a real project.
518 // It is identified by the user uuid. As such, cost analysis for the
519 // "Home" project is not supported by this program.
520 logger.Errorf("Cost analysis is not supported for the 'Home' project: %s", uuid)
525 for k := range cost {
526 logger.Infof("Uuid report in %s/%s.csv\n", resultsDir, k)
530 logger.Info("Nothing to do!\n")
536 csv = "# Aggregate cost accounting for uuids:\n"
537 for _, uuid := range uuids {
538 csv += "# " + uuid + "\n"
542 for k, v := range cost {
543 csv += k + "," + strconv.FormatFloat(v, 'f', 8, 64) + "\n"
547 csv += "TOTAL," + strconv.FormatFloat(total, 'f', 8, 64) + "\n"
549 // Write the resulting CSV file
550 aFile := resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
551 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
553 logger.Errorf("Error writing file with path %s: %s\n", aFile, err.Error())
556 logger.Infof("\nAggregate cost accounting for all supplied uuids in %s\n", aFile)