17778: Merge branch 'master' into 17778-doc-update
[arvados.git] / lib / costanalyzer / costanalyzer.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package costanalyzer
6
7 import (
8         "encoding/json"
9         "errors"
10         "flag"
11         "fmt"
12         "io"
13         "io/ioutil"
14         "net/http"
15         "os"
16         "strconv"
17         "strings"
18         "time"
19
20         "git.arvados.org/arvados.git/sdk/go/arvados"
21         "git.arvados.org/arvados.git/sdk/go/arvadosclient"
22         "git.arvados.org/arvados.git/sdk/go/keepclient"
23         "github.com/sirupsen/logrus"
24 )
25
26 const timestampFormat = "2006-01-02T15:04:05"
27
28 type nodeInfo struct {
29         // Legacy (records created by Arvados Node Manager with Arvados <= 1.4.3)
30         Properties struct {
31                 CloudNode struct {
32                         Price float64
33                         Size  string
34                 } `json:"cloud_node"`
35         }
36         // Modern
37         ProviderType string
38         Price        float64
39         Preemptible  bool
40 }
41
42 type arrayFlags []string
43
44 func (i *arrayFlags) String() string {
45         return ""
46 }
47
48 func (i *arrayFlags) Set(value string) error {
49         for _, s := range strings.Split(value, ",") {
50                 *i = append(*i, s)
51         }
52         return nil
53 }
54
55 func (c *command) parseFlags(prog string, args []string, logger *logrus.Logger, stderr io.Writer) (exitCode int, err error) {
56         var beginStr, endStr string
57         flags := flag.NewFlagSet("", flag.ContinueOnError)
58         flags.SetOutput(stderr)
59         flags.Usage = func() {
60                 fmt.Fprintf(flags.Output(), `
61 Usage:
62   %s [options ...] [UUID ...]
63
64         This program analyzes the cost of Arvados container requests and calculates
65         the total cost across all requests. At least one UUID or a timestamp range
66         must be specified.
67
68         When the '-output' option is specified, a set of CSV files with cost details
69         will be written to the provided directory. Each file is a CSV report that lists
70         all the containers used to fulfill the container request, together with the
71         machine type and cost of each container.
72
73         When supplied with the UUID of a container request, it will calculate the
74         cost of that container request and all its children.
75
76         When supplied with the UUID of a collection, it will see if there is a
77         container_request UUID in the properties of the collection, and if so, it
78         will calculate the cost of that container request and all its children.
79
80         When supplied with a project UUID or when supplied with multiple container
81         request or collection UUIDs, it will calculate the total cost for all
82         supplied UUIDs.
83
84         When supplied with a 'begin' and 'end' timestamp (format:
85         %s), it will calculate the cost for all top-level container
86         requests whose containers finished during the specified interval.
87
88         The total cost calculation takes container reuse into account: if a container
89         was reused between several container requests, its cost will only be counted
90         once.
91
92         Caveats:
93
94         - This program uses the cost data from config.yml at the time of the
95         execution of the container, stored in the 'node.json' file in its log
96         collection. If the cost data was not correctly configured at the time the
97         container was executed, the output from this program will be incorrect.
98
99         - If a container was run on a preemptible ("spot") instance, the cost data
100         reported by this program may be wildly inaccurate, because it does not have
101         access to the spot pricing in effect for the node then the container ran. The
102         UUID report file that is generated when the '-output' option is specified has
103         a column that indicates the preemptible state of the instance that ran the
104         container.
105
106         - This program does not take into account overhead costs like the time spent
107         starting and stopping compute nodes that run containers, the cost of the
108         permanent cloud nodes that provide the Arvados services, the cost of data
109         stored in Arvados, etc.
110
111         - When provided with a project UUID, subprojects will not be considered.
112
113         In order to get the data for the UUIDs supplied, the ARVADOS_API_HOST and
114         ARVADOS_API_TOKEN environment variables must be set.
115
116         This program prints the total dollar amount from the aggregate cost
117         accounting across all provided UUIDs on stdout.
118
119 Options:
120 `, prog, timestampFormat)
121                 flags.PrintDefaults()
122         }
123         loglevel := flags.String("log-level", "info", "logging `level` (debug, info, ...)")
124         flags.StringVar(&c.resultsDir, "output", "", "output `directory` for the CSV reports")
125         flags.StringVar(&beginStr, "begin", "", fmt.Sprintf("timestamp `begin` for date range operation (format: %s)", timestampFormat))
126         flags.StringVar(&endStr, "end", "", fmt.Sprintf("timestamp `end` for date range operation (format: %s)", timestampFormat))
127         flags.BoolVar(&c.cache, "cache", true, "create and use a local disk cache of Arvados objects")
128         err = flags.Parse(args)
129         if err == flag.ErrHelp {
130                 err = nil
131                 exitCode = 1
132                 return
133         } else if err != nil {
134                 exitCode = 2
135                 return
136         }
137         c.uuids = flags.Args()
138
139         if (len(beginStr) != 0 && len(endStr) == 0) || (len(beginStr) == 0 && len(endStr) != 0) {
140                 flags.Usage()
141                 err = fmt.Errorf("When specifying a date range, both begin and end must be specified")
142                 exitCode = 2
143                 return
144         }
145
146         if len(beginStr) != 0 {
147                 var errB, errE error
148                 c.begin, errB = time.Parse(timestampFormat, beginStr)
149                 c.end, errE = time.Parse(timestampFormat, endStr)
150                 if (errB != nil) || (errE != nil) {
151                         flags.Usage()
152                         err = fmt.Errorf("When specifying a date range, both begin and end must be of the format %s %+v, %+v", timestampFormat, errB, errE)
153                         exitCode = 2
154                         return
155                 }
156         }
157
158         if (len(c.uuids) < 1) && (len(beginStr) == 0) {
159                 flags.Usage()
160                 err = fmt.Errorf("error: no uuid(s) provided")
161                 exitCode = 2
162                 return
163         }
164
165         lvl, err := logrus.ParseLevel(*loglevel)
166         if err != nil {
167                 exitCode = 2
168                 return
169         }
170         logger.SetLevel(lvl)
171         if !c.cache {
172                 logger.Debug("Caching disabled")
173         }
174         return
175 }
176
177 func ensureDirectory(logger *logrus.Logger, dir string) (err error) {
178         statData, err := os.Stat(dir)
179         if os.IsNotExist(err) {
180                 err = os.MkdirAll(dir, 0700)
181                 if err != nil {
182                         return fmt.Errorf("error creating directory %s: %s", dir, err.Error())
183                 }
184         } else {
185                 if !statData.IsDir() {
186                         return fmt.Errorf("the path %s is not a directory", dir)
187                 }
188         }
189         return
190 }
191
192 func addContainerLine(logger *logrus.Logger, node nodeInfo, cr arvados.ContainerRequest, container arvados.Container) (csv string, cost float64) {
193         csv = cr.UUID + ","
194         csv += cr.Name + ","
195         csv += container.UUID + ","
196         csv += string(container.State) + ","
197         if container.StartedAt != nil {
198                 csv += container.StartedAt.String() + ","
199         } else {
200                 csv += ","
201         }
202
203         var delta time.Duration
204         if container.FinishedAt != nil {
205                 csv += container.FinishedAt.String() + ","
206                 delta = container.FinishedAt.Sub(*container.StartedAt)
207                 csv += strconv.FormatFloat(delta.Seconds(), 'f', 0, 64) + ","
208         } else {
209                 csv += ",,"
210         }
211         var price float64
212         var size string
213         if node.Properties.CloudNode.Price != 0 {
214                 price = node.Properties.CloudNode.Price
215                 size = node.Properties.CloudNode.Size
216         } else {
217                 price = node.Price
218                 size = node.ProviderType
219         }
220         cost = delta.Seconds() / 3600 * price
221         csv += size + "," + fmt.Sprintf("%+v", node.Preemptible) + "," + strconv.FormatFloat(price, 'f', 8, 64) + "," + strconv.FormatFloat(cost, 'f', 8, 64) + "\n"
222         return
223 }
224
225 func loadCachedObject(logger *logrus.Logger, file string, uuid string, object interface{}) (reload bool) {
226         reload = true
227         if strings.Contains(uuid, "-j7d0g-") || strings.Contains(uuid, "-4zz18-") {
228                 // We do not cache projects or collections, they have no final state
229                 return
230         }
231         // See if we have a cached copy of this object
232         _, err := os.Stat(file)
233         if err != nil {
234                 return
235         }
236         data, err := ioutil.ReadFile(file)
237         if err != nil {
238                 logger.Errorf("error reading %q: %s", file, err)
239                 return
240         }
241         err = json.Unmarshal(data, &object)
242         if err != nil {
243                 logger.Errorf("failed to unmarshal json: %s: %s", data, err)
244                 return
245         }
246
247         // See if it is in a final state, if that makes sense
248         switch v := object.(type) {
249         case *arvados.ContainerRequest:
250                 if v.State == arvados.ContainerRequestStateFinal {
251                         reload = false
252                         logger.Debugf("Loaded object %s from local cache (%s)", uuid, file)
253                 }
254         case *arvados.Container:
255                 if v.State == arvados.ContainerStateComplete || v.State == arvados.ContainerStateCancelled {
256                         reload = false
257                         logger.Debugf("Loaded object %s from local cache (%s)", uuid, file)
258                 }
259         }
260         return
261 }
262
263 // Load an Arvados object.
264 func loadObject(logger *logrus.Logger, ac *arvados.Client, path string, uuid string, cache bool, object interface{}) (err error) {
265         file := uuid + ".json"
266
267         var reload bool
268         var cacheDir string
269
270         if !cache {
271                 reload = true
272         } else {
273                 homeDir, err := os.UserHomeDir()
274                 if err != nil {
275                         reload = true
276                         logger.Info("Unable to determine current user home directory, not using cache")
277                 } else {
278                         cacheDir = homeDir + "/.cache/arvados/costanalyzer/"
279                         err = ensureDirectory(logger, cacheDir)
280                         if err != nil {
281                                 reload = true
282                                 logger.Infof("Unable to create cache directory at %s, not using cache: %s", cacheDir, err.Error())
283                         } else {
284                                 reload = loadCachedObject(logger, cacheDir+file, uuid, object)
285                         }
286                 }
287         }
288         if !reload {
289                 return
290         }
291
292         if strings.Contains(uuid, "-j7d0g-") {
293                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/groups/"+uuid, nil, nil)
294         } else if strings.Contains(uuid, "-xvhdp-") {
295                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/container_requests/"+uuid, nil, nil)
296         } else if strings.Contains(uuid, "-dz642-") {
297                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/containers/"+uuid, nil, nil)
298         } else if strings.Contains(uuid, "-4zz18-") {
299                 err = ac.RequestAndDecode(&object, "GET", "arvados/v1/collections/"+uuid, nil, nil)
300         } else {
301                 err = fmt.Errorf("unsupported object type with UUID %q:\n  %s", uuid, err)
302                 return
303         }
304         if err != nil {
305                 err = fmt.Errorf("error loading object with UUID %q:\n  %s", uuid, err)
306                 return
307         }
308         encoded, err := json.MarshalIndent(object, "", " ")
309         if err != nil {
310                 err = fmt.Errorf("error marshaling object with UUID %q:\n  %s", uuid, err)
311                 return
312         }
313         if cacheDir != "" {
314                 err = ioutil.WriteFile(cacheDir+file, encoded, 0644)
315                 if err != nil {
316                         err = fmt.Errorf("error writing file %s:\n  %s", file, err)
317                         return
318                 }
319         }
320         return
321 }
322
323 func getNode(arv *arvadosclient.ArvadosClient, ac *arvados.Client, kc *keepclient.KeepClient, cr arvados.ContainerRequest) (node nodeInfo, err error) {
324         if cr.LogUUID == "" {
325                 err = errors.New("no log collection")
326                 return
327         }
328
329         var collection arvados.Collection
330         err = ac.RequestAndDecode(&collection, "GET", "arvados/v1/collections/"+cr.LogUUID, nil, nil)
331         if err != nil {
332                 err = fmt.Errorf("error getting collection: %s", err)
333                 return
334         }
335
336         var fs arvados.CollectionFileSystem
337         fs, err = collection.FileSystem(ac, kc)
338         if err != nil {
339                 err = fmt.Errorf("error opening collection as filesystem: %s", err)
340                 return
341         }
342         var f http.File
343         f, err = fs.Open("node.json")
344         if err != nil {
345                 err = fmt.Errorf("error opening file 'node.json' in collection %s: %s", cr.LogUUID, err)
346                 return
347         }
348
349         err = json.NewDecoder(f).Decode(&node)
350         if err != nil {
351                 err = fmt.Errorf("error reading file 'node.json' in collection %s: %s", cr.LogUUID, err)
352                 return
353         }
354         return
355 }
356
357 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) {
358         cost = make(map[string]float64)
359
360         var project arvados.Group
361         err = loadObject(logger, ac, uuid, uuid, cache, &project)
362         if err != nil {
363                 return nil, fmt.Errorf("error loading object %s: %s", uuid, err.Error())
364         }
365
366         var childCrs map[string]interface{}
367         filterset := []arvados.Filter{
368                 {
369                         Attr:     "owner_uuid",
370                         Operator: "=",
371                         Operand:  project.UUID,
372                 },
373                 {
374                         Attr:     "requesting_container_uuid",
375                         Operator: "=",
376                         Operand:  nil,
377                 },
378         }
379         err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
380                 "filters": filterset,
381                 "limit":   10000,
382         })
383         if err != nil {
384                 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
385         }
386         if value, ok := childCrs["items"]; ok {
387                 logger.Infof("Collecting top level container requests in project %s", uuid)
388                 items := value.([]interface{})
389                 for _, item := range items {
390                         itemMap := item.(map[string]interface{})
391                         crCsv, err := generateCrCsv(logger, itemMap["uuid"].(string), arv, ac, kc, resultsDir, cache)
392                         if err != nil {
393                                 return nil, fmt.Errorf("error generating container_request CSV: %s", err.Error())
394                         }
395                         for k, v := range crCsv {
396                                 cost[k] = v
397                         }
398                 }
399         } else {
400                 logger.Infof("No top level container requests found in project %s", uuid)
401         }
402         return
403 }
404
405 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) {
406
407         cost = make(map[string]float64)
408
409         csv := "CR UUID,CR name,Container UUID,State,Started At,Finished At,Duration in seconds,Compute node type,Preemptible,Hourly node cost,Total cost\n"
410         var tmpCsv string
411         var tmpTotalCost float64
412         var totalCost float64
413         logger.Debugf("Processing %s", uuid)
414
415         var crUUID = uuid
416         if strings.Contains(uuid, "-4zz18-") {
417                 // This is a collection, find the associated container request (if any)
418                 var c arvados.Collection
419                 err = loadObject(logger, ac, uuid, uuid, cache, &c)
420                 if err != nil {
421                         return nil, fmt.Errorf("error loading collection object %s: %s", uuid, err)
422                 }
423                 value, ok := c.Properties["container_request"]
424                 if !ok {
425                         return nil, fmt.Errorf("error: collection %s does not have a 'container_request' property", uuid)
426                 }
427                 crUUID, ok = value.(string)
428                 if !ok {
429                         return nil, fmt.Errorf("error: collection %s does not have a 'container_request' property of the string type", uuid)
430                 }
431         }
432
433         // This is a container request, find the container
434         var cr arvados.ContainerRequest
435         err = loadObject(logger, ac, crUUID, crUUID, cache, &cr)
436         if err != nil {
437                 return nil, fmt.Errorf("error loading cr object %s: %s", uuid, err)
438         }
439         if len(cr.ContainerUUID) == 0 {
440                 // Nothing to do! E.g. a CR in 'Uncommitted' state.
441                 logger.Infof("No container associated with container request %s, skipping", crUUID)
442                 return nil, nil
443         }
444         var container arvados.Container
445         err = loadObject(logger, ac, crUUID, cr.ContainerUUID, cache, &container)
446         if err != nil {
447                 return nil, fmt.Errorf("error loading container object %s: %s", cr.ContainerUUID, err)
448         }
449
450         topNode, err := getNode(arv, ac, kc, cr)
451         if err != nil {
452                 logger.Errorf("Skipping container request %s: error getting node %s: %s", cr.UUID, cr.UUID, err)
453                 return nil, nil
454         }
455         tmpCsv, totalCost = addContainerLine(logger, topNode, cr, container)
456         csv += tmpCsv
457         totalCost += tmpTotalCost
458         cost[container.UUID] = totalCost
459
460         // Find all container requests that have the container we found above as requesting_container_uuid
461         var childCrs arvados.ContainerRequestList
462         filterset := []arvados.Filter{
463                 {
464                         Attr:     "requesting_container_uuid",
465                         Operator: "=",
466                         Operand:  container.UUID,
467                 }}
468         err = ac.RequestAndDecode(&childCrs, "GET", "arvados/v1/container_requests", nil, map[string]interface{}{
469                 "filters": filterset,
470                 "limit":   10000,
471         })
472         if err != nil {
473                 return nil, fmt.Errorf("error querying container_requests: %s", err.Error())
474         }
475         logger.Infof("Collecting child containers for container request %s (%s)", crUUID, container.FinishedAt)
476         progressTicker := time.NewTicker(5 * time.Second)
477         defer progressTicker.Stop()
478         for i, cr2 := range childCrs.Items {
479                 select {
480                 case <-progressTicker.C:
481                         logger.Infof("... %d of %d", i+1, len(childCrs.Items))
482                 default:
483                 }
484                 node, err := getNode(arv, ac, kc, cr2)
485                 if err != nil {
486                         logger.Errorf("Skipping container request %s: error getting node %s: %s", cr2.UUID, cr2.UUID, err)
487                         continue
488                 }
489                 logger.Debug("Child container: " + cr2.ContainerUUID)
490                 var c2 arvados.Container
491                 err = loadObject(logger, ac, cr.UUID, cr2.ContainerUUID, cache, &c2)
492                 if err != nil {
493                         return nil, fmt.Errorf("error loading object %s: %s", cr2.ContainerUUID, err)
494                 }
495                 tmpCsv, tmpTotalCost = addContainerLine(logger, node, cr2, c2)
496                 cost[cr2.ContainerUUID] = tmpTotalCost
497                 csv += tmpCsv
498                 totalCost += tmpTotalCost
499         }
500         logger.Debug("Done collecting child containers")
501
502         csv += "TOTAL,,,,,,,,," + strconv.FormatFloat(totalCost, 'f', 8, 64) + "\n"
503
504         if resultsDir != "" {
505                 // Write the resulting CSV file
506                 fName := resultsDir + "/" + crUUID + ".csv"
507                 err = ioutil.WriteFile(fName, []byte(csv), 0644)
508                 if err != nil {
509                         return nil, fmt.Errorf("error writing file with path %s: %s", fName, err.Error())
510                 }
511                 logger.Infof("\nUUID report in %s", fName)
512         }
513
514         return
515 }
516
517 func (c *command) costAnalyzer(prog string, args []string, logger *logrus.Logger, stdout, stderr io.Writer) (exitcode int, err error) {
518         exitcode, err = c.parseFlags(prog, args, logger, stderr)
519
520         if exitcode != 0 {
521                 return
522         }
523         if c.resultsDir != "" {
524                 err = ensureDirectory(logger, c.resultsDir)
525                 if err != nil {
526                         exitcode = 3
527                         return
528                 }
529         }
530
531         uuidChannel := make(chan string)
532
533         // Arvados Client setup
534         arv, err := arvadosclient.MakeArvadosClient()
535         if err != nil {
536                 err = fmt.Errorf("error creating Arvados object: %s", err)
537                 exitcode = 1
538                 return
539         }
540         kc, err := keepclient.MakeKeepClient(arv)
541         if err != nil {
542                 err = fmt.Errorf("error creating Keep object: %s", err)
543                 exitcode = 1
544                 return
545         }
546
547         ac := arvados.NewClientFromEnv()
548
549         // Populate uuidChannel with the requested uuid list
550         go func() {
551                 defer close(uuidChannel)
552                 for _, uuid := range c.uuids {
553                         uuidChannel <- uuid
554                 }
555
556                 if !c.begin.IsZero() {
557                         initialParams := arvados.ResourceListParams{
558                                 Filters: []arvados.Filter{{"container.finished_at", ">=", c.begin}, {"container.finished_at", "<", c.end}, {"requesting_container_uuid", "=", nil}},
559                                 Order:   "created_at",
560                         }
561                         params := initialParams
562                         for {
563                                 // This list variable must be a new one declared
564                                 // inside the loop: otherwise, items in the API
565                                 // response would get deep-merged into the items
566                                 // loaded in previous iterations.
567                                 var list arvados.ContainerRequestList
568
569                                 err := ac.RequestAndDecode(&list, "GET", "arvados/v1/container_requests", nil, params)
570                                 if err != nil {
571                                         logger.Errorf("Error getting container request list from Arvados API: %s", err)
572                                         break
573                                 }
574                                 if len(list.Items) == 0 {
575                                         break
576                                 }
577
578                                 for _, i := range list.Items {
579                                         uuidChannel <- i.UUID
580                                 }
581                                 params.Offset += len(list.Items)
582                         }
583
584                 }
585         }()
586
587         cost := make(map[string]float64)
588
589         for uuid := range uuidChannel {
590                 logger.Debugf("Considering %s", uuid)
591                 if strings.Contains(uuid, "-j7d0g-") {
592                         // This is a project (group)
593                         cost, err = handleProject(logger, uuid, arv, ac, kc, c.resultsDir, c.cache)
594                         if err != nil {
595                                 exitcode = 1
596                                 return
597                         }
598                         for k, v := range cost {
599                                 cost[k] = v
600                         }
601                 } else if strings.Contains(uuid, "-xvhdp-") || strings.Contains(uuid, "-4zz18-") {
602                         // This is a container request
603                         var crCsv map[string]float64
604                         crCsv, err = generateCrCsv(logger, uuid, arv, ac, kc, c.resultsDir, c.cache)
605                         if err != nil {
606                                 err = fmt.Errorf("error generating CSV for uuid %s: %s", uuid, err.Error())
607                                 exitcode = 2
608                                 return
609                         }
610                         for k, v := range crCsv {
611                                 cost[k] = v
612                         }
613                 } else if strings.Contains(uuid, "-tpzed-") {
614                         // This is a user. The "Home" project for a user is not a real project.
615                         // It is identified by the user uuid. As such, cost analysis for the
616                         // "Home" project is not supported by this program. Skip this uuid, but
617                         // keep going.
618                         logger.Errorf("cost analysis is not supported for the 'Home' project: %s", uuid)
619                 } else {
620                         logger.Errorf("this argument does not look like a uuid: %s", uuid)
621                         exitcode = 3
622                         return
623                 }
624         }
625
626         if len(cost) == 0 {
627                 logger.Info("Nothing to do!")
628                 return
629         }
630
631         var csv string
632
633         csv = "# Aggregate cost accounting for uuids:\n"
634         for _, uuid := range c.uuids {
635                 csv += "# " + uuid + "\n"
636         }
637
638         var total float64
639         for k, v := range cost {
640                 csv += k + "," + strconv.FormatFloat(v, 'f', 8, 64) + "\n"
641                 total += v
642         }
643
644         csv += "TOTAL," + strconv.FormatFloat(total, 'f', 8, 64) + "\n"
645
646         if c.resultsDir != "" {
647                 // Write the resulting CSV file
648                 aFile := c.resultsDir + "/" + time.Now().Format("2006-01-02-15-04-05") + "-aggregate-costaccounting.csv"
649                 err = ioutil.WriteFile(aFile, []byte(csv), 0644)
650                 if err != nil {
651                         err = fmt.Errorf("error writing file with path %s: %s", aFile, err.Error())
652                         exitcode = 1
653                         return
654                 }
655                 logger.Infof("Aggregate cost accounting for all supplied uuids in %s", aFile)
656         }
657
658         // Output the total dollar amount on stdout
659         fmt.Fprintf(stdout, "%s\n", strconv.FormatFloat(total, 'f', 8, 64))
660
661         return
662 }