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. When suplied with a
66 project uuid or when supplied with multiple container request uuids, it will
67 create a CSV report for each supplied uuid, as well as a CSV file with
68 aggregate cost accounting for all supplied uuids. The aggregate cost report
69 takes container reuse into account: if a container was reused between several
70 container requests, its cost will only be counted once.
72 To get the node costs, the progam queries the Arvados API for current cost
73 data for each node type used. This means that the reported cost always
74 reflects the cost data as currently defined in the Arvados API configuration
78 - the Arvados API configuration cost data may be out of sync with the cloud
80 - when generating reports for older container requests, the cost data in the
81 Arvados API configuration file may have changed since the container request
82 was fulfilled. This program uses the cost data stored at the time of the
83 execution of the container, stored in the 'node.json' file in its log
86 In order to get the data for the uuids supplied, the ARVADOS_API_HOST and
87 ARVADOS_API_TOKEN environment variables must be set.
93 loglevel := flags.String("log-level", "info", "logging `level` (debug, info, ...)")
94 flags.StringVar(&resultsDir, "output", "", "output `directory` for the CSV reports (required)")
95 flags.Var(&uuids, "uuid", "Toplevel `project or container request` uuid. May be specified more than once. (required)")
96 flags.BoolVar(&cache, "cache", true, "create and use a local disk cache of Arvados objects")
97 err = flags.Parse(args)
98 if err == flag.ErrHelp {
102 } else if err != nil {
109 err = fmt.Errorf("Error: no uuid(s) provided")
114 if resultsDir == "" {
116 err = fmt.Errorf("Error: output directory must be specified")
121 lvl, err := logrus.ParseLevel(*loglevel)
128 logger.Debug("Caching disabled\n")
133 func ensureDirectory(logger *logrus.Logger, dir string) (err error) {
134 statData, err := os.Stat(dir)
135 if os.IsNotExist(err) {
136 err = os.MkdirAll(dir, 0700)
138 return fmt.Errorf("error creating directory %s: %s", dir, err.Error())
141 if !statData.IsDir() {
142 return fmt.Errorf("the path %s is not a directory", dir)
148 func addContainerLine(logger *logrus.Logger, node nodeInfo, cr arvados.ContainerRequest, container arvados.Container) (csv string, cost float64) {
151 csv += container.UUID + ","
152 csv += string(container.State) + ","
153 if container.StartedAt != nil {
154 csv += container.StartedAt.String() + ","
159 var delta time.Duration
160 if container.FinishedAt != nil {
161 csv += container.FinishedAt.String() + ","
162 delta = container.FinishedAt.Sub(*container.StartedAt)
163 csv += strconv.FormatFloat(delta.Seconds(), 'f', 0, 64) + ","
169 if node.Properties.CloudNode.Price != 0 {
170 price = node.Properties.CloudNode.Price
171 size = node.Properties.CloudNode.Size
174 size = node.ProviderType
176 cost = delta.Seconds() / 3600 * price
177 csv += size + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(cost, 'f', 8, 64) + "\n"
181 func loadCachedObject(logger *logrus.Logger, file string, uuid string, object interface{}) (reload bool) {
183 if strings.Contains(uuid, "-j7d0g-") {
184 // We do not cache projects, they have no final state
187 // See if we have a cached copy of this object
188 _, err := os.Stat(file)
192 data, err := ioutil.ReadFile(file)
194 logger.Errorf("error reading %q: %s", file, err)
197 err = json.Unmarshal(data, &object)
199 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
203 // See if it is in a final state, if that makes sense
204 switch v := object.(type) {
205 case *arvados.ContainerRequest:
206 if v.State == arvados.ContainerRequestStateFinal {
208 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
210 case *arvados.Container:
211 if v.State == arvados.ContainerStateComplete || v.State == arvados.ContainerStateCancelled {
213 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
219 // Load an Arvados object.
220 func loadObject(logger *logrus.Logger, ac *arvados.Client, path string, uuid string, cache bool, object interface{}) (err error) {
221 file := uuid + ".json"
229 homeDir, err := os.UserHomeDir()
232 logger.Info("Unable to determine current user home directory, not using cache")
234 cacheDir = homeDir + "/.cache/arvados/costanalyzer/"
235 err = ensureDirectory(logger, cacheDir)
238 logger.Infof("Unable to create cache directory at %s, not using cache: %s", cacheDir, err.Error())
240 reload = loadCachedObject(logger, cacheDir+file, uuid, object)
248 if strings.Contains(uuid, "-j7d0g-") {
249 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/groups/"+uuid, nil, nil)
250 } else if strings.Contains(uuid, "-xvhdp-") {
251 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/container_requests/"+uuid, nil, nil)
252 } else if strings.Contains(uuid, "-dz642-") {
253 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/containers/"+uuid, nil, nil)
255 err = fmt.Errorf("unsupported object type with UUID %q:\n %s", uuid, err)
259 err = fmt.Errorf("error loading object with UUID %q:\n %s", uuid, err)
262 encoded, err := json.MarshalIndent(object, "", " ")
264 err = fmt.Errorf("error marshaling object with UUID %q:\n %s", uuid, err)
268 err = ioutil.WriteFile(cacheDir+file, encoded, 0644)
270 err = fmt.Errorf("error writing file %s:\n %s", file, err)
277 func getNode(arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, cr arvados.ContainerRequest) (node nodeInfo, err error) {
278 if cr.LogUUID == "" {
279 err = errors.New("No log collection")
283 var collection arvados.Collection
284 err = ac.RequestAndDecode(&collection, "GET", "arvados/v1/collections/"+cr.LogUUID, nil, nil)
286 err = fmt.Errorf("error getting collection: %s", err)
290 var fs arvados.CollectionFileSystem
291 fs, err = collection.FileSystem(ac, kc)
293 err = fmt.Errorf("error opening collection as filesystem: %s", err)
297 f, err = fs.Open("node.json")
299 err = fmt.Errorf("error opening file 'node.json' in collection %s: %s", cr.LogUUID, err)
303 err = json.NewDecoder(f).Decode(&node)
305 err = fmt.Errorf("error reading file 'node.json' in collection %s: %s", cr.LogUUID, err)
311 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) {
313 cost = make(map[string]float64)
315 var project arvados.Group
316 err = loadObject(logger, ac, uuid, uuid, cache, &project)
318 return nil, fmt.Errorf("error loading object %s: %s", uuid, err.Error())
321 var childCrs map[string]interface{}
322 filterset := []arvados.Filter{
326 Operand: project.UUID,
329 Attr: "requesting_container_uuid",
334 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
335 "filters": filterset,
339 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
341 if value, ok := childCrs["items"]; ok {
342 logger.Infof("Collecting top level container requests in project %s\n", uuid)
343 items := value.([]interface{})
344 for _, item := range items {
345 itemMap := item.(map[string]interface{})
346 crCsv, err := generateCrCsv(logger, itemMap["uuid"].(string), arv, ac, kc, resultsDir, cache)
348 return nil, fmt.Errorf("error generating container_request CSV: %s", err.Error())
350 for k, v := range crCsv {
355 logger.Infof("No top level container requests found in project %s\n", uuid)
360 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) {
362 cost = make(map[string]float64)
364 csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Hourly node cost,Total cost\n"
366 var tmpTotalCost float64
367 var totalCost float64
369 // This is a container request, find the container
370 var cr arvados.ContainerRequest
371 err = loadObject(logger, ac, uuid, uuid, cache, &cr)
373 return nil, fmt.Errorf("error loading cr object %s: %s", uuid, err)
375 var container arvados.Container
376 err = loadObject(logger, ac, uuid, cr.ContainerUUID, cache, &container)
378 return nil, fmt.Errorf("error loading container object %s: %s", cr.ContainerUUID, err)
381 topNode, err := getNode(arv, ac, kc, cr)
383 return nil, fmt.Errorf("error getting node %s: %s", cr.UUID, err)
385 tmpCsv, totalCost = addContainerLine(logger, topNode, cr, container)
387 totalCost += tmpTotalCost
388 cost[container.UUID] = totalCost
390 // Find all container requests that have the container we found above as requesting_container_uuid
391 var childCrs arvados.ContainerRequestList
392 filterset := []arvados.Filter{
394 Attr: "requesting_container_uuid",
396 Operand: container.UUID,
398 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
399 "filters": filterset,
403 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
405 logger.Infof("Collecting child containers for container request %s", uuid)
406 for _, cr2 := range childCrs.Items {
408 node, err := getNode(arv, ac, kc, cr2)
410 return nil, fmt.Errorf("error getting node %s: %s", cr2.UUID, err)
412 logger.Debug("\nChild container: " + cr2.ContainerUUID + "\n")
413 var c2 arvados.Container
414 err = loadObject(logger, ac, uuid, cr2.ContainerUUID, cache, &c2)
416 return nil, fmt.Errorf("error loading object %s: %s", cr2.ContainerUUID, err)
418 tmpCsv, tmpTotalCost = addContainerLine(logger, node, cr2, c2)
419 cost[cr2.ContainerUUID] = tmpTotalCost
421 totalCost += tmpTotalCost
423 logger.Info(" done\n")
425 csv += "TOTAL,,,,,,,,," + strconv.FormatFloat(totalCost, 'f', 8, 64) + "\n"
427 // Write the resulting CSV file
428 fName := resultsDir + "/" + uuid + ".csv"
429 err = ioutil.WriteFile(fName, []byte(csv), 0644)
431 return nil, fmt.Errorf("error writing file with path %s: %s", fName, err.Error())
433 logger.Infof("\nUUID report in %s\n\n", fName)
438 func costanalyzer(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int, err error) {
439 exitcode, uuids, resultsDir, cache, err := parseFlags(prog, args, loader, logger, stderr)
443 err = ensureDirectory(logger, resultsDir)
449 // Arvados Client setup
450 arv, err := arvadosclient.MakeArvadosClient()
452 err = fmt.Errorf("error creating Arvados object: %s", err)
456 kc, err := keepclient.MakeKeepClient(arv)
458 err = fmt.Errorf("error creating Keep object: %s", err)
463 ac := arvados.NewClientFromEnv()
465 cost := make(map[string]float64)
466 for _, uuid := range uuids {
467 if strings.Contains(uuid, "-j7d0g-") {
468 // This is a project (group)
469 cost, err = handleProject(logger, uuid, arv, ac, kc, resultsDir, cache)
474 for k, v := range cost {
477 } else if strings.Contains(uuid, "-xvhdp-") {
478 // This is a container request
479 var crCsv map[string]float64
480 crCsv, err = generateCrCsv(logger, uuid, arv, ac, kc, resultsDir, cache)
482 err = fmt.Errorf("Error generating container_request CSV for uuid %s: %s", uuid, err.Error())
486 for k, v := range crCsv {
489 } else if strings.Contains(uuid, "-tpzed-") {
490 // This is a user. The "Home" project for a user is not a real project.
491 // It is identified by the user uuid. As such, cost analysis for the
492 // "Home" project is not supported by this program. Skip this uuid, but
494 logger.Errorf("Cost analysis is not supported for the 'Home' project: %s", uuid)
499 logger.Info("Nothing to do!\n")
505 csv = "# Aggregate cost accounting for uuids:\n"
506 for _, uuid := range uuids {
507 csv += "# " + uuid + "\n"
511 for k, v := range cost {
512 csv += k + "," + strconv.FormatFloat(v, 'f', 8, 64) + "\n"
516 csv += "TOTAL," + strconv.FormatFloat(total, 'f', 8, 64) + "\n"
518 // Write the resulting CSV file
519 aFile := resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
520 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
522 err = fmt.Errorf("Error writing file with path %s: %s", aFile, err.Error())
526 logger.Infof("Aggregate cost accounting for all supplied uuids in %s\n", aFile)