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(), `
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
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.
101 loglevel := flags.String("log-level", "info", "logging `level` (debug, info, ...)")
102 flags.StringVar(&resultsDir, "output", "", "output `directory` for the CSV reports (required)")
103 flags.Var(&uuids, "uuid", "object uuid. May be specified more than once. Also accepts a comma separated list of uuids (required)")
104 flags.BoolVar(&cache, "cache", true, "create and use a local disk cache of Arvados objects")
105 err = flags.Parse(args)
106 if err == flag.ErrHelp {
110 } else if err != nil {
117 err = fmt.Errorf("Error: no uuid(s) provided")
122 if resultsDir == "" {
124 err = fmt.Errorf("Error: output directory must be specified")
129 lvl, err := logrus.ParseLevel(*loglevel)
136 logger.Debug("Caching disabled\n")
141 func ensureDirectory(logger *logrus.Logger, dir string) (err error) {
142 statData, err := os.Stat(dir)
143 if os.IsNotExist(err) {
144 err = os.MkdirAll(dir, 0700)
146 return fmt.Errorf("error creating directory %s: %s", dir, err.Error())
149 if !statData.IsDir() {
150 return fmt.Errorf("the path %s is not a directory", dir)
156 func addContainerLine(logger *logrus.Logger, node nodeInfo, cr arvados.ContainerRequest, container arvados.Container) (csv string, cost float64) {
159 csv += container.UUID + ","
160 csv += string(container.State) + ","
161 if container.StartedAt != nil {
162 csv += container.StartedAt.String() + ","
167 var delta time.Duration
168 if container.FinishedAt != nil {
169 csv += container.FinishedAt.String() + ","
170 delta = container.FinishedAt.Sub(*container.StartedAt)
171 csv += strconv.FormatFloat(delta.Seconds(), 'f', 0, 64) + ","
177 if node.Properties.CloudNode.Price != 0 {
178 price = node.Properties.CloudNode.Price
179 size = node.Properties.CloudNode.Size
182 size = node.ProviderType
184 cost = delta.Seconds() / 3600 * price
185 csv += size + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(cost, 'f', 8, 64) + "\n"
189 func loadCachedObject(logger *logrus.Logger, file string, uuid string, object interface{}) (reload bool) {
191 if strings.Contains(uuid, "-j7d0g-") || strings.Contains(uuid, "-4zz18-") {
192 // We do not cache projects or collections, they have no final state
195 // See if we have a cached copy of this object
196 _, err := os.Stat(file)
200 data, err := ioutil.ReadFile(file)
202 logger.Errorf("error reading %q: %s", file, err)
205 err = json.Unmarshal(data, &object)
207 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
211 // See if it is in a final state, if that makes sense
212 switch v := object.(type) {
213 case *arvados.ContainerRequest:
214 if v.State == arvados.ContainerRequestStateFinal {
216 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
218 case *arvados.Container:
219 if v.State == arvados.ContainerStateComplete || v.State == arvados.ContainerStateCancelled {
221 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
227 // Load an Arvados object.
228 func loadObject(logger *logrus.Logger, ac *arvados.Client, path string, uuid string, cache bool, object interface{}) (err error) {
229 file := uuid + ".json"
237 homeDir, err := os.UserHomeDir()
240 logger.Info("Unable to determine current user home directory, not using cache")
242 cacheDir = homeDir + "/.cache/arvados/costanalyzer/"
243 err = ensureDirectory(logger, cacheDir)
246 logger.Infof("Unable to create cache directory at %s, not using cache: %s", cacheDir, err.Error())
248 reload = loadCachedObject(logger, cacheDir+file, uuid, object)
256 if strings.Contains(uuid, "-j7d0g-") {
257 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/groups/"+uuid, nil, nil)
258 } else if strings.Contains(uuid, "-xvhdp-") {
259 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/container_requests/"+uuid, nil, nil)
260 } else if strings.Contains(uuid, "-dz642-") {
261 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/containers/"+uuid, nil, nil)
262 } else if strings.Contains(uuid, "-4zz18-") {
263 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/collections/"+uuid, nil, nil)
265 err = fmt.Errorf("unsupported object type with UUID %q:\n %s", uuid, err)
269 err = fmt.Errorf("error loading object with UUID %q:\n %s", uuid, err)
272 encoded, err := json.MarshalIndent(object, "", " ")
274 err = fmt.Errorf("error marshaling object with UUID %q:\n %s", uuid, err)
278 err = ioutil.WriteFile(cacheDir+file, encoded, 0644)
280 err = fmt.Errorf("error writing file %s:\n %s", file, err)
287 func getNode(arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, cr arvados.ContainerRequest) (node nodeInfo, err error) {
288 if cr.LogUUID == "" {
289 err = errors.New("No log collection")
293 var collection arvados.Collection
294 err = ac.RequestAndDecode(&collection, "GET", "arvados/v1/collections/"+cr.LogUUID, nil, nil)
296 err = fmt.Errorf("error getting collection: %s", err)
300 var fs arvados.CollectionFileSystem
301 fs, err = collection.FileSystem(ac, kc)
303 err = fmt.Errorf("error opening collection as filesystem: %s", err)
307 f, err = fs.Open("node.json")
309 err = fmt.Errorf("error opening file 'node.json' in collection %s: %s", cr.LogUUID, err)
313 err = json.NewDecoder(f).Decode(&node)
315 err = fmt.Errorf("error reading file 'node.json' in collection %s: %s", cr.LogUUID, err)
321 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) {
322 cost = make(map[string]float64)
324 var project arvados.Group
325 err = loadObject(logger, ac, uuid, uuid, cache, &project)
327 return nil, fmt.Errorf("error loading object %s: %s", uuid, err.Error())
330 var childCrs map[string]interface{}
331 filterset := []arvados.Filter{
335 Operand: project.UUID,
338 Attr: "requesting_container_uuid",
343 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
344 "filters": filterset,
348 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
350 if value, ok := childCrs["items"]; ok {
351 logger.Infof("Collecting top level container requests in project %s\n", uuid)
352 items := value.([]interface{})
353 for _, item := range items {
354 itemMap := item.(map[string]interface{})
355 crCsv, err := generateCrCsv(logger, itemMap["uuid"].(string), arv, ac, kc, resultsDir, cache)
357 return nil, fmt.Errorf("error generating container_request CSV: %s", err.Error())
359 for k, v := range crCsv {
364 logger.Infof("No top level container requests found in project %s\n", uuid)
369 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) {
371 cost = make(map[string]float64)
373 csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Hourly node cost,Total cost\n"
375 var tmpTotalCost float64
376 var totalCost float64
379 if strings.Contains(uuid, "-4zz18-") {
380 // This is a collection, find the associated container request (if any)
381 var c arvados.Collection
382 err = loadObject(logger, ac, uuid, uuid, cache, &c)
384 return nil, fmt.Errorf("error loading collection object %s: %s", uuid, err)
386 value, ok := c.Properties["container_request"]
388 return nil, fmt.Errorf("error: collection %s does not have a 'container_request' property", uuid)
390 crUUID = value.(string)
393 // This is a container request, find the container
394 var cr arvados.ContainerRequest
395 err = loadObject(logger, ac, crUUID, crUUID, cache, &cr)
397 return nil, fmt.Errorf("error loading cr object %s: %s", uuid, err)
399 var container arvados.Container
400 err = loadObject(logger, ac, uuid, cr.ContainerUUID, cache, &container)
402 return nil, fmt.Errorf("error loading container object %s: %s", cr.ContainerUUID, err)
405 topNode, err := getNode(arv, ac, kc, cr)
407 return nil, fmt.Errorf("error getting node %s: %s", cr.UUID, err)
409 tmpCsv, totalCost = addContainerLine(logger, topNode, cr, container)
411 totalCost += tmpTotalCost
412 cost[container.UUID] = totalCost
414 // Find all container requests that have the container we found above as requesting_container_uuid
415 var childCrs arvados.ContainerRequestList
416 filterset := []arvados.Filter{
418 Attr: "requesting_container_uuid",
420 Operand: container.UUID,
422 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
423 "filters": filterset,
427 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
429 logger.Infof("Collecting child containers for container request %s", uuid)
430 for _, cr2 := range childCrs.Items {
432 node, err := getNode(arv, ac, kc, cr2)
434 return nil, fmt.Errorf("error getting node %s: %s", cr2.UUID, err)
436 logger.Debug("\nChild container: " + cr2.ContainerUUID + "\n")
437 var c2 arvados.Container
438 err = loadObject(logger, ac, uuid, cr2.ContainerUUID, cache, &c2)
440 return nil, fmt.Errorf("error loading object %s: %s", cr2.ContainerUUID, err)
442 tmpCsv, tmpTotalCost = addContainerLine(logger, node, cr2, c2)
443 cost[cr2.ContainerUUID] = tmpTotalCost
445 totalCost += tmpTotalCost
447 logger.Info(" done\n")
449 csv += "TOTAL,,,,,,,,," + strconv.FormatFloat(totalCost, 'f', 8, 64) + "\n"
451 // Write the resulting CSV file
452 fName := resultsDir + "/" + uuid + ".csv"
453 err = ioutil.WriteFile(fName, []byte(csv), 0644)
455 return nil, fmt.Errorf("error writing file with path %s: %s", fName, err.Error())
457 logger.Infof("\nUUID report in %s\n\n", fName)
462 func costanalyzer(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int, err error) {
463 exitcode, uuids, resultsDir, cache, err := parseFlags(prog, args, loader, logger, stderr)
467 err = ensureDirectory(logger, resultsDir)
473 // Arvados Client setup
474 arv, err := arvadosclient.MakeArvadosClient()
476 err = fmt.Errorf("error creating Arvados object: %s", err)
480 kc, err := keepclient.MakeKeepClient(arv)
482 err = fmt.Errorf("error creating Keep object: %s", err)
487 ac := arvados.NewClientFromEnv()
489 cost := make(map[string]float64)
490 for _, uuid := range uuids {
491 if strings.Contains(uuid, "-j7d0g-") {
492 // This is a project (group)
493 cost, err = handleProject(logger, uuid, arv, ac, kc, resultsDir, cache)
498 for k, v := range cost {
501 } else if strings.Contains(uuid, "-xvhdp-") || strings.Contains(uuid, "-4zz18-") {
502 // This is a container request
503 var crCsv map[string]float64
504 crCsv, err = generateCrCsv(logger, uuid, arv, ac, kc, resultsDir, cache)
506 err = fmt.Errorf("Error generating CSV for uuid %s: %s", uuid, err.Error())
510 for k, v := range crCsv {
513 } else if strings.Contains(uuid, "-tpzed-") {
514 // This is a user. The "Home" project for a user is not a real project.
515 // It is identified by the user uuid. As such, cost analysis for the
516 // "Home" project is not supported by this program. Skip this uuid, but
518 logger.Errorf("Cost analysis is not supported for the 'Home' project: %s", uuid)
523 logger.Info("Nothing to do!\n")
529 csv = "# Aggregate cost accounting for uuids:\n"
530 for _, uuid := range uuids {
531 csv += "# " + uuid + "\n"
535 for k, v := range cost {
536 csv += k + "," + strconv.FormatFloat(v, 'f', 8, 64) + "\n"
540 csv += "TOTAL," + strconv.FormatFloat(total, 'f', 8, 64) + "\n"
542 // Write the resulting CSV file
543 aFile := resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
544 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
546 err = fmt.Errorf("Error writing file with path %s: %s", aFile, err.Error())
550 logger.Infof("Aggregate cost accounting for all supplied uuids in %s\n", aFile)