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 resultsDir = *flags.String("output", "results", "output `directory` for the CSV reports")
95 flags.Var(&uuids, "uuid", "Toplevel `project or container request` uuid. May be specified more than once.")
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 lvl, err := logrus.ParseLevel(*loglevel)
123 func ensureDirectory(logger *logrus.Logger, dir string) (err error) {
124 statData, err := os.Stat(dir)
125 if os.IsNotExist(err) {
126 err = os.MkdirAll(dir, 0700)
128 return fmt.Errorf("error creating directory %s: %s", dir, err.Error())
131 if !statData.IsDir() {
132 return fmt.Errorf("the path %s is not a directory", dir)
138 func addContainerLine(logger *logrus.Logger, node nodeInfo, cr arvados.ContainerRequest, container arvados.Container) (csv string, cost float64) {
141 csv += container.UUID + ","
142 csv += string(container.State) + ","
143 if container.StartedAt != nil {
144 csv += container.StartedAt.String() + ","
149 var delta time.Duration
150 if container.FinishedAt != nil {
151 csv += container.FinishedAt.String() + ","
152 delta = container.FinishedAt.Sub(*container.StartedAt)
153 csv += strconv.FormatFloat(delta.Seconds(), 'f', 0, 64) + ","
159 if node.Properties.CloudNode.Price != 0 {
160 price = node.Properties.CloudNode.Price
161 size = node.Properties.CloudNode.Size
164 size = node.ProviderType
166 cost = delta.Seconds() / 3600 * price
167 csv += size + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(cost, 'f', 8, 64) + "\n"
171 func loadCachedObject(logger *logrus.Logger, file string, uuid string, object interface{}) (reload bool) {
173 // See if we have a cached copy of this object
174 _, err := os.Stat(file)
178 data, err := ioutil.ReadFile(file)
180 logger.Errorf("error reading %q: %s", file, err)
183 err = json.Unmarshal(data, &object)
185 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
189 // See if it is in a final state, if that makes sense
190 switch v := object.(type) {
192 // Projects (j7d0g) do not have state so they should always be reloaded
193 case arvados.Container:
194 if v.State == arvados.ContainerStateComplete || v.State == arvados.ContainerStateCancelled {
196 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
198 case arvados.ContainerRequest:
199 if v.State == arvados.ContainerRequestStateFinal {
201 logger.Debugf("Loaded object %s from local cache (%s)\n", uuid, file)
207 // Load an Arvados object.
208 func loadObject(logger *logrus.Logger, ac *arvados.Client, path string, uuid string, cache bool, object interface{}) (err error) {
209 err = ensureDirectory(logger, path)
214 file := path + "/" + uuid + ".json"
220 reload = loadCachedObject(logger, file, uuid, &object)
226 if strings.Contains(uuid, "-j7d0g-") {
227 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/groups/"+uuid, nil, nil)
228 } else if strings.Contains(uuid, "-xvhdp-") {
229 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/container_requests/"+uuid, nil, nil)
230 } else if strings.Contains(uuid, "-dz642-") {
231 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/containers/"+uuid, nil, nil)
233 err = fmt.Errorf("unsupported object type with UUID %q:\n %s", uuid, err)
237 err = fmt.Errorf("error loading object with UUID %q:\n %s", uuid, err)
240 encoded, err := json.MarshalIndent(object, "", " ")
242 err = fmt.Errorf("error marshaling object with UUID %q:\n %s", uuid, err)
245 err = ioutil.WriteFile(file, encoded, 0644)
247 err = fmt.Errorf("error writing file %s:\n %s", file, err)
253 func getNode(arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, cr arvados.ContainerRequest) (node nodeInfo, err error) {
254 if cr.LogUUID == "" {
255 err = errors.New("No log collection")
259 var collection arvados.Collection
260 err = ac.RequestAndDecode(&collection, "GET", "arvados/v1/collections/"+cr.LogUUID, nil, nil)
262 err = fmt.Errorf("error getting collection: %s", err)
266 var fs arvados.CollectionFileSystem
267 fs, err = collection.FileSystem(ac, kc)
269 err = fmt.Errorf("error opening collection as filesystem: %s", err)
273 f, err = fs.Open("node.json")
275 err = fmt.Errorf("error opening file 'node.json' in collection %s: %s", cr.LogUUID, err)
279 err = json.NewDecoder(f).Decode(&node)
281 err = fmt.Errorf("error reading file 'node.json' in collection %s: %s", cr.LogUUID, err)
287 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) {
289 cost = make(map[string]float64)
291 var project arvados.Group
292 err = loadObject(logger, ac, resultsDir+"/"+uuid, uuid, cache, &project)
294 return nil, fmt.Errorf("error loading object %s: %s", uuid, err.Error())
297 var childCrs map[string]interface{}
298 filterset := []arvados.Filter{
302 Operand: project.UUID,
305 Attr: "requesting_container_uuid",
310 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
311 "filters": filterset,
315 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
317 if value, ok := childCrs["items"]; ok {
318 logger.Infof("Collecting top level container requests in project %s\n", uuid)
319 items := value.([]interface{})
320 for _, item := range items {
321 itemMap := item.(map[string]interface{})
322 crCsv, err := generateCrCsv(logger, itemMap["uuid"].(string), arv, ac, kc, resultsDir, cache)
324 return nil, fmt.Errorf("error generating container_request CSV: %s", err.Error())
326 for k, v := range crCsv {
331 logger.Infof("No top level container requests found in project %s\n", uuid)
336 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) {
338 cost = make(map[string]float64)
340 csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Hourly node cost,Total cost\n"
342 var tmpTotalCost float64
343 var totalCost float64
345 // This is a container request, find the container
346 var cr arvados.ContainerRequest
347 err = loadObject(logger, ac, resultsDir+"/"+uuid, uuid, cache, &cr)
349 return nil, fmt.Errorf("error loading cr object %s: %s", uuid, err)
351 fmt.Printf("cr: %+v\n", cr)
352 var container arvados.Container
353 err = loadObject(logger, ac, resultsDir+"/"+uuid, cr.ContainerUUID, cache, &container)
355 return nil, fmt.Errorf("error loading container object %s: %s", cr.ContainerUUID, err)
358 topNode, err := getNode(arv, ac, kc, cr)
360 return nil, fmt.Errorf("error getting node %s: %s", cr.UUID, err)
362 tmpCsv, totalCost = addContainerLine(logger, topNode, cr, container)
364 totalCost += tmpTotalCost
365 cost[container.UUID] = totalCost
367 // Find all container requests that have the container we found above as requesting_container_uuid
368 var childCrs arvados.ContainerRequestList
369 filterset := []arvados.Filter{
371 Attr: "requesting_container_uuid",
373 Operand: container.UUID,
375 err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
376 "filters": filterset,
380 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
382 logger.Infof("Collecting child containers for container request %s", uuid)
383 for _, cr2 := range childCrs.Items {
385 node, err := getNode(arv, ac, kc, cr2)
387 return nil, fmt.Errorf("error getting node %s: %s", cr2.UUID, err)
389 logger.Debug("\nChild container: " + cr2.ContainerUUID + "\n")
390 var c2 arvados.Container
391 err = loadObject(logger, ac, resultsDir+"/"+uuid, cr2.ContainerUUID, cache, &c2)
393 return nil, fmt.Errorf("error loading object %s: %s", cr2.ContainerUUID, err)
395 tmpCsv, tmpTotalCost = addContainerLine(logger, node, cr2, c2)
396 cost[cr2.ContainerUUID] = tmpTotalCost
398 totalCost += tmpTotalCost
400 logger.Info(" done\n")
402 csv += "TOTAL,,,,,,,,," + strconv.FormatFloat(totalCost, 'f', 8, 64) + "\n"
404 // Write the resulting CSV file
405 fName := resultsDir + "/" + uuid + ".csv"
406 err = ioutil.WriteFile(fName, []byte(csv), 0644)
408 return nil, fmt.Errorf("error writing file with path %s: %s", fName, err.Error())
414 func costanalyzer(prog string, args []string, loader *config.Loader, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int, err error) {
415 exitcode, uuids, resultsDir, cache, err := parseFlags(prog, args, loader, logger, stderr)
419 err = ensureDirectory(logger, resultsDir)
425 // Arvados Client setup
426 arv, err := arvadosclient.MakeArvadosClient()
428 err = fmt.Errorf("error creating Arvados object: %s", err)
432 kc, err := keepclient.MakeKeepClient(arv)
434 err = fmt.Errorf("error creating Keep object: %s", err)
439 ac := arvados.NewClientFromEnv()
441 cost := make(map[string]float64)
442 for _, uuid := range uuids {
443 if strings.Contains(uuid, "-j7d0g-") {
444 // This is a project (group)
445 cost, err = handleProject(logger, uuid, arv, ac, kc, resultsDir, cache)
450 for k, v := range cost {
453 } else if strings.Contains(uuid, "-xvhdp-") {
454 // This is a container request
455 var crCsv map[string]float64
456 crCsv, err = generateCrCsv(logger, uuid, arv, ac, kc, resultsDir, cache)
458 err = fmt.Errorf("Error generating container_request CSV for uuid %s: %s", uuid, err.Error())
462 for k, v := range crCsv {
465 } else if strings.Contains(uuid, "-tpzed-") {
466 // This is a user. The "Home" project for a user is not a real project.
467 // It is identified by the user uuid. As such, cost analysis for the
468 // "Home" project is not supported by this program. Skip this uuid, but
470 logger.Errorf("Cost analysis is not supported for the 'Home' project: %s", uuid)
475 for k := range cost {
476 logger.Infof("Uuid report in %s/%s.csv\n", resultsDir, k)
480 logger.Info("Nothing to do!\n")
486 csv = "# Aggregate cost accounting for uuids:\n"
487 for _, uuid := range uuids {
488 csv += "# " + uuid + "\n"
492 for k, v := range cost {
493 csv += k + "," + strconv.FormatFloat(v, 'f', 8, 64) + "\n"
497 csv += "TOTAL," + strconv.FormatFloat(total, 'f', 8, 64) + "\n"
499 // Write the resulting CSV file
500 aFile := resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
501 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
503 err = fmt.Errorf("Error writing file with path %s: %s", aFile, err.Error())
507 logger.Infof("\nAggregate cost accounting for all supplied uuids in %s\n", aFile)