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