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 *i = append(*i, value)
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(), `
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
64 When supplied with the uuid of a container request, it will calculate the
65 cost of that container request and all its children.
67 When supplied with the uuid of a collection, it will see if there is a
68 container_request uuid in the properties of the collection, and if so, it
69 will calculate the cost of that container request and all its children.
71 When supplied with a project uuid or when supplied with multiple container
72 request or collection uuids, it will create a CSV report for each supplied
73 uuid, as well as a CSV file with aggregate cost accounting for all supplied
74 uuids. The aggregate cost report takes container reuse into account: if a
75 container was reused between several container requests, its cost will only
78 To get the node costs, the progam queries the Arvados API for current cost
79 data for each node type used. This means that the reported cost always
80 reflects the cost data as currently defined in the Arvados API configuration
84 - the Arvados API configuration cost data may be out of sync with the cloud
86 - when generating reports for older container requests, the cost data in the
87 Arvados API configuration file may have changed since the container request
88 was fulfilled. This program uses the cost data stored at the time of the
89 execution of the container, stored in the 'node.json' file in its log
92 In order to get the data for the uuids supplied, the ARVADOS_API_HOST and
93 ARVADOS_API_TOKEN environment variables must be set.
99 loglevel := flags.String("log-level", "info", "logging `level` (debug, info, ...)")
100 flags.StringVar(&resultsDir, "output", "", "output `directory` for the CSV reports (required)")
101 flags.Var(&uuids, "uuid", "Toplevel `project or container request` uuid. May be specified more than once. (required)")
102 flags.BoolVar(&cache, "cache", true, "create and use a local disk cache of Arvados objects")
103 err = flags.Parse(args)
104 if err == flag.ErrHelp {
108 } else if err != nil {
115 err = fmt.Errorf("Error: no uuid(s) provided")
120 if resultsDir == "" {
122 err = fmt.Errorf("Error: output directory must be specified")
127 lvl, err := logrus.ParseLevel(*loglevel)
134 logger.Debug("Caching disabled\n")
139 func ensureDirectory(logger *logrus.Logger, dir string) (err error) {
140 statData, err := os.Stat(dir)
141 if os.IsNotExist(err) {
142 err = os.MkdirAll(dir, 0700)
144 return fmt.Errorf("error creating directory %s: %s", dir, err.Error())
147 if !statData.IsDir() {
148 return fmt.Errorf("the path %s is not a directory", dir)
154 func addContainerLine(logger *logrus.Logger, node nodeInfo, cr arvados.ContainerRequest, container arvados.Container) (csv string, cost float64) {
157 csv += container.UUID + ","
158 csv += string(container.State) + ","
159 if container.StartedAt != nil {
160 csv += container.StartedAt.String() + ","
165 var delta time.Duration
166 if container.FinishedAt != nil {
167 csv += container.FinishedAt.String() + ","
168 delta = container.FinishedAt.Sub(*container.StartedAt)
169 csv += strconv.FormatFloat(delta.Seconds(), 'f', 0, 64) + ","
175 if node.Properties.CloudNode.Price != 0 {
176 price = node.Properties.CloudNode.Price
177 size = node.Properties.CloudNode.Size
180 size = node.ProviderType
182 cost = delta.Seconds() / 3600 * price
183 csv += size + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(cost, 'f', 8, 64) + "\n"
187 func loadCachedObject(logger *logrus.Logger, file string, uuid string, object interface{}) (reload bool) {
189 if strings.Contains(uuid, "-j7d0g-") || strings.Contains(uuid, "-4zz18-") {
190 // We do not cache projects or collections, they have no final state
193 // See if we have a cached copy of this object
194 _, err := os.Stat(file)
198 data, err := ioutil.ReadFile(file)
200 logger.Errorf("error reading %q: %s", file, err)
203 err = json.Unmarshal(data, &object)
205 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
209 // See if it is in a final state, if that makes sense
210 switch v := object.(type) {
211 case *arvados.ContainerRequest:
212 if v.State == arvados.ContainerRequestStateFinal {
214 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
216 case *arvados.Container:
217 if v.State == arvados.ContainerStateComplete || v.State == arvados.ContainerStateCancelled {
219 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
225 // Load an Arvados object.
226 func loadObject(logger *logrus.Logger, ac *arvados.Client, path string, uuid string, cache bool, object interface{}) (err error) {
227 file := uuid + ".json"
235 homeDir, err := os.UserHomeDir()
238 logger.Info("Unable to determine current user home directory, not using cache")
240 cacheDir = homeDir + "/.cache/arvados/costanalyzer/"
241 err = ensureDirectory(logger, cacheDir)
244 logger.Infof("Unable to create cache directory at %s, not using cache: %s", cacheDir, err.Error())
246 reload = loadCachedObject(logger, cacheDir+file, uuid, object)
254 if strings.Contains(uuid, "-j7d0g-") {
255 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/groups/"+uuid, nil, nil)
256 } else if strings.Contains(uuid, "-xvhdp-") {
257 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/container_requests/"+uuid, nil, nil)
258 } else if strings.Contains(uuid, "-dz642-") {
259 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/containers/"+uuid, nil, nil)
260 } else if strings.Contains(uuid, "-4zz18-") {
261 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/collections/"+uuid, nil, nil)
263 err = fmt.Errorf("unsupported object type with UUID %q:\n %s", uuid, err)
267 err = fmt.Errorf("error loading object with UUID %q:\n %s", uuid, err)
270 encoded, err := json.MarshalIndent(object, "", " ")
272 err = fmt.Errorf("error marshaling object with UUID %q:\n %s", uuid, err)
276 err = ioutil.WriteFile(cacheDir+file, encoded, 0644)
278 err = fmt.Errorf("error writing file %s:\n %s", file, err)
285 func getNode(arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, cr arvados.ContainerRequest) (node nodeInfo, err error) {
286 if cr.LogUUID == "" {
287 err = errors.New("No log collection")
291 var collection arvados.Collection
292 err = ac.RequestAndDecode(&collection, "GET", "arvados/v1/collections/"+cr.LogUUID, nil, nil)
294 err = fmt.Errorf("error getting collection: %s", err)
298 var fs arvados.CollectionFileSystem
299 fs, err = collection.FileSystem(ac, kc)
301 err = fmt.Errorf("error opening collection as filesystem: %s", err)
305 f, err = fs.Open("node.json")
307 err = fmt.Errorf("error opening file 'node.json' in collection %s: %s", cr.LogUUID, err)
311 err = json.NewDecoder(f).Decode(&node)
313 err = fmt.Errorf("error reading file 'node.json' in collection %s: %s", cr.LogUUID, err)
319 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) {
320 cost = make(map[string]float64)
322 var project arvados.Group
323 err = loadObject(logger, ac, uuid, uuid, cache, &project)
325 return nil, fmt.Errorf("error loading object %s: %s", uuid, err.Error())
328 var childCrs map[string]interface{}
329 filterset := []arvados.Filter{
333 Operand: project.UUID,
336 Attr: "requesting_container_uuid",
341 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
342 "filters": filterset,
346 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
348 if value, ok := childCrs["items"]; ok {
349 logger.Infof("Collecting top level container requests in project %s\n", uuid)
350 items := value.([]interface{})
351 for _, item := range items {
352 itemMap := item.(map[string]interface{})
353 crCsv, err := generateCrCsv(logger, itemMap["uuid"].(string), arv, ac, kc, resultsDir, cache)
355 return nil, fmt.Errorf("error generating container_request CSV: %s", err.Error())
357 for k, v := range crCsv {
362 logger.Infof("No top level container requests found in project %s\n", uuid)
367 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) {
369 cost = make(map[string]float64)
371 csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Hourly node cost,Total cost\n"
373 var tmpTotalCost float64
374 var totalCost float64
377 if strings.Contains(uuid, "-4zz18-") {
378 // This is a collection, find the associated container request (if any)
379 var c arvados.Collection
380 err = loadObject(logger, ac, uuid, uuid, cache, &c)
382 return nil, fmt.Errorf("error loading collection object %s: %s", uuid, err)
384 value, ok := c.Properties["container_request"]
386 return nil, fmt.Errorf("error: collection %s does not have a 'container_request' property", uuid)
388 crUUID = value.(string)
391 // This is a container request, find the container
392 var cr arvados.ContainerRequest
393 err = loadObject(logger, ac, crUUID, crUUID, cache, &cr)
395 return nil, fmt.Errorf("error loading cr object %s: %s", uuid, err)
397 var container arvados.Container
398 err = loadObject(logger, ac, uuid, cr.ContainerUUID, cache, &container)
400 return nil, fmt.Errorf("error loading container object %s: %s", cr.ContainerUUID, err)
403 topNode, err := getNode(arv, ac, kc, cr)
405 return nil, fmt.Errorf("error getting node %s: %s", cr.UUID, err)
407 tmpCsv, totalCost = addContainerLine(logger, topNode, cr, container)
409 totalCost += tmpTotalCost
410 cost[container.UUID] = totalCost
412 // Find all container requests that have the container we found above as requesting_container_uuid
413 var childCrs arvados.ContainerRequestList
414 filterset := []arvados.Filter{
416 Attr: "requesting_container_uuid",
418 Operand: container.UUID,
420 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
421 "filters": filterset,
425 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
427 logger.Infof("Collecting child containers for container request %s", uuid)
428 for _, cr2 := range childCrs.Items {
430 node, err := getNode(arv, ac, kc, cr2)
432 return nil, fmt.Errorf("error getting node %s: %s", cr2.UUID, err)
434 logger.Debug("\nChild container: " + cr2.ContainerUUID + "\n")
435 var c2 arvados.Container
436 err = loadObject(logger, ac, uuid, cr2.ContainerUUID, cache, &c2)
438 return nil, fmt.Errorf("error loading object %s: %s", cr2.ContainerUUID, err)
440 tmpCsv, tmpTotalCost = addContainerLine(logger, node, cr2, c2)
441 cost[cr2.ContainerUUID] = tmpTotalCost
443 totalCost += tmpTotalCost
445 logger.Info(" done\n")
447 csv += "TOTAL,,,,,,,,," + strconv.FormatFloat(totalCost, 'f', 8, 64) + "\n"
449 // Write the resulting CSV file
450 fName := resultsDir + "/" + uuid + ".csv"
451 err = ioutil.WriteFile(fName, []byte(csv), 0644)
453 return nil, fmt.Errorf("error writing file with path %s: %s", fName, err.Error())
455 logger.Infof("\nUUID report in %s\n\n", fName)
460 func costanalyzer(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int, err error) {
461 exitcode, uuids, resultsDir, cache, err := parseFlags(prog, args, loader, logger, stderr)
465 err = ensureDirectory(logger, resultsDir)
471 // Arvados Client setup
472 arv, err := arvadosclient.MakeArvadosClient()
474 err = fmt.Errorf("error creating Arvados object: %s", err)
478 kc, err := keepclient.MakeKeepClient(arv)
480 err = fmt.Errorf("error creating Keep object: %s", err)
485 ac := arvados.NewClientFromEnv()
487 cost := make(map[string]float64)
488 for _, uuid := range uuids {
489 if strings.Contains(uuid, "-j7d0g-") {
490 // This is a project (group)
491 cost, err = handleProject(logger, uuid, arv, ac, kc, resultsDir, cache)
496 for k, v := range cost {
499 } else if strings.Contains(uuid, "-xvhdp-") || strings.Contains(uuid, "-4zz18-") {
500 // This is a container request
501 var crCsv map[string]float64
502 crCsv, err = generateCrCsv(logger, uuid, arv, ac, kc, resultsDir, cache)
504 err = fmt.Errorf("Error generating CSV for uuid %s: %s", uuid, err.Error())
508 for k, v := range crCsv {
511 } else if strings.Contains(uuid, "-tpzed-") {
512 // This is a user. The "Home" project for a user is not a real project.
513 // It is identified by the user uuid. As such, cost analysis for the
514 // "Home" project is not supported by this program. Skip this uuid, but
516 logger.Errorf("Cost analysis is not supported for the 'Home' project: %s", uuid)
521 logger.Info("Nothing to do!\n")
527 csv = "# Aggregate cost accounting for uuids:\n"
528 for _, uuid := range uuids {
529 csv += "# " + uuid + "\n"
533 for k, v := range cost {
534 csv += k + "," + strconv.FormatFloat(v, 'f', 8, 64) + "\n"
538 csv += "TOTAL," + strconv.FormatFloat(total, 'f', 8, 64) + "\n"
540 // Write the resulting CSV file
541 aFile := resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
542 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
544 err = fmt.Errorf("Error writing file with path %s: %s", aFile, err.Error())
548 logger.Infof("Aggregate cost accounting for all supplied uuids in %s\n", aFile)