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"
25 "github.com/sirupsen/logrus"
28 type nodeInfo struct {
29 // Legacy (records created by Arvados Node Manager with Arvados <= 1.4.3)
41 type arrayFlags []string
43 func (i *arrayFlags) String() string {
47 func (i *arrayFlags) Set(value string) error {
48 *i = append(*i, value)
52 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) {
53 flags := flag.NewFlagSet("", flag.ContinueOnError)
54 flags.SetOutput(stderr)
55 flags.Usage = func() {
56 fmt.Fprintf(flags.Output(), `
60 This program analyzes the cost of Arvados container requests. For each uuid
61 supplied, it creates a CSV report that lists all the containers used to
62 fulfill the container request, together with the machine type and cost of
65 When supplied with the uuid of a container request, it will calculate the
66 cost of that container request and all its children. When suplied with a
67 project uuid or when supplied with multiple container request uuids, it will
68 create a CSV report for each supplied uuid, as well as a CSV file with
69 aggregate cost accounting for all supplied uuids. The aggregate cost report
70 takes container reuse into account: if a container was reused between several
71 container requests, its cost will only be counted once.
73 To get the node costs, the progam queries the Arvados API for current cost
74 data for each node type used. This means that the reported cost always
75 reflects the cost data as currently defined in the Arvados API configuration
79 - the Arvados API configuration cost data may be out of sync with the cloud
81 - when generating reports for older container requests, the cost data in the
82 Arvados API configuration file may have changed since the container request
83 was fulfilled. This program uses the cost data stored at the time of the
84 execution of the container, stored in the 'node.json' file in its log
87 In order to get the data for the uuids supplied, the ARVADOS_API_HOST and
88 ARVADOS_API_TOKEN environment variables must be set.
94 loglevel := flags.String("log-level", "info", "logging `level` (debug, info, ...)")
95 flags.StringVar(&resultsDir, "output", "", "output `directory` for the CSV reports (required)")
96 flags.Var(&uuids, "uuid", "Toplevel `project or container request` uuid. May be specified more than once. (required)")
97 flags.BoolVar(&cache, "cache", true, "create and use a local disk cache of Arvados objects")
98 err = flags.Parse(args)
99 if err == flag.ErrHelp {
103 } else if err != nil {
110 err = fmt.Errorf("Error: no uuid(s) provided")
115 if resultsDir == "" {
117 err = fmt.Errorf("Error: output directory must be specified")
122 lvl, err := logrus.ParseLevel(*loglevel)
129 logger.Debug("Caching disabled\n")
134 func ensureDirectory(logger *logrus.Logger, dir string) (err error) {
135 statData, err := os.Stat(dir)
136 if os.IsNotExist(err) {
137 err = os.MkdirAll(dir, 0700)
139 return fmt.Errorf("error creating directory %s: %s", dir, err.Error())
142 if !statData.IsDir() {
143 return fmt.Errorf("the path %s is not a directory", dir)
149 func addContainerLine(logger *logrus.Logger, node nodeInfo, cr arvados.ContainerRequest, container arvados.Container) (csv string, cost float64) {
152 csv += container.UUID + ","
153 csv += string(container.State) + ","
154 if container.StartedAt != nil {
155 csv += container.StartedAt.String() + ","
160 var delta time.Duration
161 if container.FinishedAt != nil {
162 csv += container.FinishedAt.String() + ","
163 delta = container.FinishedAt.Sub(*container.StartedAt)
164 csv += strconv.FormatFloat(delta.Seconds(), 'f', 0, 64) + ","
170 if node.Properties.CloudNode.Price != 0 {
171 price = node.Properties.CloudNode.Price
172 size = node.Properties.CloudNode.Size
175 size = node.ProviderType
177 cost = delta.Seconds() / 3600 * price
178 csv += size + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(cost, 'f', 8, 64) + "\n"
182 func loadCachedObject(logger *logrus.Logger, file string, uuid string, object interface{}) (reload bool) {
184 if strings.Contains(uuid, "-j7d0g-") {
185 // We do not cache projects, they have no final state
188 // See if we have a cached copy of this object
189 _, err := os.Stat(file)
193 data, err := ioutil.ReadFile(file)
195 logger.Errorf("error reading %q: %s", file, err)
198 err = json.Unmarshal(data, &object)
200 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
204 // See if it is in a final state, if that makes sense
205 switch v := object.(type) {
206 case *arvados.ContainerRequest:
207 if v.State == arvados.ContainerRequestStateFinal {
209 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
211 case *arvados.Container:
212 if v.State == arvados.ContainerStateComplete || v.State == arvados.ContainerStateCancelled {
214 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
220 // Load an Arvados object.
221 func loadObject(logger *logrus.Logger, ac *arvados.Client, path string, uuid string, cache bool, object interface{}) (err error) {
222 file := uuid + ".json"
230 user, err := user.Current()
233 logger.Info("Unable to determine current user, not using cache")
235 cacheDir = user.HomeDir + "/.cache/arvados/costanalyzer/"
236 err = ensureDirectory(logger, cacheDir)
239 logger.Infof("Unable to create cache directory at %s, not using cache: %s", cacheDir, err.Error())
241 reload = loadCachedObject(logger, cacheDir+file, uuid, object)
249 if strings.Contains(uuid, "-j7d0g-") {
250 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/groups/"+uuid, nil, nil)
251 } else if strings.Contains(uuid, "-xvhdp-") {
252 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/container_requests/"+uuid, nil, nil)
253 } else if strings.Contains(uuid, "-dz642-") {
254 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/containers/"+uuid, nil, nil)
256 err = fmt.Errorf("unsupported object type with UUID %q:\n %s", uuid, err)
260 err = fmt.Errorf("error loading object with UUID %q:\n %s", uuid, err)
263 encoded, err := json.MarshalIndent(object, "", " ")
265 err = fmt.Errorf("error marshaling object with UUID %q:\n %s", uuid, err)
269 err = ioutil.WriteFile(cacheDir+file, encoded, 0644)
271 err = fmt.Errorf("error writing file %s:\n %s", file, err)
278 func getNode(arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, cr arvados.ContainerRequest) (node nodeInfo, err error) {
279 if cr.LogUUID == "" {
280 err = errors.New("No log collection")
284 var collection arvados.Collection
285 err = ac.RequestAndDecode(&collection, "GET", "arvados/v1/collections/"+cr.LogUUID, nil, nil)
287 err = fmt.Errorf("error getting collection: %s", err)
291 var fs arvados.CollectionFileSystem
292 fs, err = collection.FileSystem(ac, kc)
294 err = fmt.Errorf("error opening collection as filesystem: %s", err)
298 f, err = fs.Open("node.json")
300 err = fmt.Errorf("error opening file 'node.json' in collection %s: %s", cr.LogUUID, err)
304 err = json.NewDecoder(f).Decode(&node)
306 err = fmt.Errorf("error reading file 'node.json' in collection %s: %s", cr.LogUUID, err)
312 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) {
314 cost = make(map[string]float64)
316 var project arvados.Group
317 err = loadObject(logger, ac, uuid, uuid, cache, &project)
319 return nil, fmt.Errorf("error loading object %s: %s", uuid, err.Error())
322 var childCrs map[string]interface{}
323 filterset := []arvados.Filter{
327 Operand: project.UUID,
330 Attr: "requesting_container_uuid",
335 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
336 "filters": filterset,
340 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
342 if value, ok := childCrs["items"]; ok {
343 logger.Infof("Collecting top level container requests in project %s\n", uuid)
344 items := value.([]interface{})
345 for _, item := range items {
346 itemMap := item.(map[string]interface{})
347 crCsv, err := generateCrCsv(logger, itemMap["uuid"].(string), arv, ac, kc, resultsDir, cache)
349 return nil, fmt.Errorf("error generating container_request CSV: %s", err.Error())
351 for k, v := range crCsv {
356 logger.Infof("No top level container requests found in project %s\n", uuid)
361 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) {
363 cost = make(map[string]float64)
365 csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Hourly node cost,Total cost\n"
367 var tmpTotalCost float64
368 var totalCost float64
370 // This is a container request, find the container
371 var cr arvados.ContainerRequest
372 err = loadObject(logger, ac, uuid, uuid, cache, &cr)
374 return nil, fmt.Errorf("error loading cr object %s: %s", uuid, err)
376 var container arvados.Container
377 err = loadObject(logger, ac, uuid, cr.ContainerUUID, cache, &container)
379 return nil, fmt.Errorf("error loading container object %s: %s", cr.ContainerUUID, err)
382 topNode, err := getNode(arv, ac, kc, cr)
384 return nil, fmt.Errorf("error getting node %s: %s", cr.UUID, err)
386 tmpCsv, totalCost = addContainerLine(logger, topNode, cr, container)
388 totalCost += tmpTotalCost
389 cost[container.UUID] = totalCost
391 // Find all container requests that have the container we found above as requesting_container_uuid
392 var childCrs arvados.ContainerRequestList
393 filterset := []arvados.Filter{
395 Attr: "requesting_container_uuid",
397 Operand: container.UUID,
399 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
400 "filters": filterset,
404 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
406 logger.Infof("Collecting child containers for container request %s", uuid)
407 for _, cr2 := range childCrs.Items {
409 node, err := getNode(arv, ac, kc, cr2)
411 return nil, fmt.Errorf("error getting node %s: %s", cr2.UUID, err)
413 logger.Debug("\nChild container: " + cr2.ContainerUUID + "\n")
414 var c2 arvados.Container
415 err = loadObject(logger, ac, uuid, cr2.ContainerUUID, cache, &c2)
417 return nil, fmt.Errorf("error loading object %s: %s", cr2.ContainerUUID, err)
419 tmpCsv, tmpTotalCost = addContainerLine(logger, node, cr2, c2)
420 cost[cr2.ContainerUUID] = tmpTotalCost
422 totalCost += tmpTotalCost
424 logger.Info(" done\n")
426 csv += "TOTAL,,,,,,,,," + strconv.FormatFloat(totalCost, 'f', 8, 64) + "\n"
428 // Write the resulting CSV file
429 fName := resultsDir + "/" + uuid + ".csv"
430 err = ioutil.WriteFile(fName, []byte(csv), 0644)
432 return nil, fmt.Errorf("error writing file with path %s: %s", fName, err.Error())
434 logger.Infof("\nUUID report in %s\n\n", fName)
439 func costanalyzer(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int, err error) {
440 exitcode, uuids, resultsDir, cache, err := parseFlags(prog, args, loader, logger, stderr)
444 err = ensureDirectory(logger, resultsDir)
450 // Arvados Client setup
451 arv, err := arvadosclient.MakeArvadosClient()
453 err = fmt.Errorf("error creating Arvados object: %s", err)
457 kc, err := keepclient.MakeKeepClient(arv)
459 err = fmt.Errorf("error creating Keep object: %s", err)
464 ac := arvados.NewClientFromEnv()
466 cost := make(map[string]float64)
467 for _, uuid := range uuids {
468 if strings.Contains(uuid, "-j7d0g-") {
469 // This is a project (group)
470 cost, err = handleProject(logger, uuid, arv, ac, kc, resultsDir, cache)
475 for k, v := range cost {
478 } else if strings.Contains(uuid, "-xvhdp-") {
479 // This is a container request
480 var crCsv map[string]float64
481 crCsv, err = generateCrCsv(logger, uuid, arv, ac, kc, resultsDir, cache)
483 err = fmt.Errorf("Error generating container_request CSV for uuid %s: %s", uuid, err.Error())
487 for k, v := range crCsv {
490 } else if strings.Contains(uuid, "-tpzed-") {
491 // This is a user. The "Home" project for a user is not a real project.
492 // It is identified by the user uuid. As such, cost analysis for the
493 // "Home" project is not supported by this program. Skip this uuid, but
495 logger.Errorf("Cost analysis is not supported for the 'Home' project: %s", uuid)
500 logger.Info("Nothing to do!\n")
506 csv = "# Aggregate cost accounting for uuids:\n"
507 for _, uuid := range uuids {
508 csv += "# " + uuid + "\n"
512 for k, v := range cost {
513 csv += k + "," + strconv.FormatFloat(v, 'f', 8, 64) + "\n"
517 csv += "TOTAL," + strconv.FormatFloat(total, 'f', 8, 64) + "\n"
519 // Write the resulting CSV file
520 aFile := resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
521 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
523 err = fmt.Errorf("Error writing file with path %s: %s", aFile, err.Error())
527 logger.Infof("Aggregate cost accounting for all supplied uuids in %s\n", aFile)