]> git.uio.no Git - u/mrichter/AliRoot.git/blame - PWGPP/benchmark/alirelval
RelVal: support different workdirs on the same VM
[u/mrichter/AliRoot.git] / PWGPP / benchmark / alirelval
CommitLineData
50881e44 1#!/bin/bash
2
3#
4# launch-relval.sh -- by Dario Berzano <dario.berzano@cern.ch>
5#
6# Controls the release validation submission by managing the validation virtual
7# cluster.
8#
9
10#
11# Variables
12#
13
14# error codes
15errCfg=1
16errMissingCmd=2
17errEc2Auth=3
18errInvalidOpt=4
19errSessionDir=5
20errCreateKey=6
21errRunVm=7
22errLaunchValidation=8
23errSshNotReady=9
24errStatusUnavailable=10
25errPickSession=11
26errCopyKey=12
27errAttachScreen=13
28
29# error codes not treated as errors (100 to 140)
30errStatusRunning=100
31errStatusNotRunning=101
32errStatusDoneOk=102
33errStatusDoneFail=103
34
35# thresholds
36maxVmLaunchAttempts=10
37maxSshConnectAttempts=400
38maxVmAddressWait=120
39
40# working directory prefix
1211d5b0 41sessionPrefix="$HOME/.alice-release-validation"
50881e44 42
43# screen name for the validation
44screenName='AliceReleaseValidation'
45
46# program name
47Prog=$(basename "$0")
48
49#
50# Functions
51#
52
53# Pretty print
54function pr() {
55 local nl
56 if [ "$1" == '-n' ] ; then
57 nl="-n"
58 shift
59 fi
60 echo $nl -e "\033[1m$@\033[m" >&2
61}
62
63# Nice date in UTC
64function ndate() {
65 date -u +%Y%m%d-%H%M%S-utc
66}
67
68# Temporary file
69function tmpf() {
70 mktemp /tmp/alirelval-XXXX
71}
72
73# Swallow output. Show only if something goes wrong
74function swallow() {
75 local tout ret
76 tout=$(tmpf)
77 "$@" > "$tout" 2>&1
78 ret=$?
79 if [ $ret != 0 ] ; then
80 pr "Command failed (exit status: $ret): $@"
81 cat "$tout" >&2
82 fi
83 rm -f "$tout"
84 return $ret
85}
86
87# Launch a VM. Create the keypair if the given keyfile does not exist. Syntax:
88#
89# RunVM <image_id> <profile> <user_data> <key_name> <key_file>
90#
91# Returns 0 on success, nonzero on failure. IP address is returned on stdout.
92function RunVM() {
93 local imageId profile userData keyName
94 imageId="$1"
95 profile="$2"
96 userData="$3"
97 keyName="$4"
98 keyFile="$5"
8ca5b365 99 local raw iip iid ret attempt createdKeypair error
50881e44 100
101 # keypair part: if file does not exist, create keypair
102 if [ ! -e "$keyFile" ] ; then
103 pr "Creating a new keypair: $keyName (private key: $keyFile)"
104 swallow euca-create-keypair -f "$keyFile" "$keyName"
105 if [ $? != 0 ] ; then
106 pr 'Problems creating the keypair'
107 return $errCreateKey
108 fi
109 createdKeypair=1
110 fi
111
112 attempt=0
113 pr 'Attempting to run virtual machine'
114
115 # resubmit loop
116 while true ; do
117
118 if [ $((++attempt)) -gt $maxVmLaunchAttempts ] ; then
119 pr " * Reached maximum number of attempts, giving up"
120 if [ "$createdKeypair" == 1 ] ; then
121 ( euca-delete-keypair "$keyName" ; rm -f "$keyFile" ) > /dev/null 2>&1
122 fi
123 return $errRunVm
124 fi
125
126 pr -n " * Launching VM (attempt #$attempt/$maxVmLaunchAttempts)..."
8ca5b365 127 error=0
50881e44 128
129 raw=$( euca-run-instances "$imageId" -t "$profile" -d "$userData" -k "$keyName" 2>&1 )
130 ret=$?
131 iid=$( echo "$raw" | egrep '^INSTANCE' | head -n1 | awk '{ print $2 }' )
132 if [ $ret != 0 ] || [ "$iid" == '' ] ; then
133 # 'hard' error, but can be temporary
134 pr 'error: message follows'
135 echo "$raw" >&2
136 sleep 1
137 continue
138 else
139 pr 'ok'
140 fi
141
142 pr " * VM has instance ID $iid"
143 pr -n " * Waiting for IP address..."
144
145 # wait for address loop
146 iip=''
147 for ((i=0; i<$maxVmAddressWait; i++)) ; do
148 sleep 1
149 raw=$( euca-describe-instances 2>&1 | grep -E '^INSTANCE' | grep "$iid" | head -n1 )
150
151 # error state?
152 echo "$raw" | grep -i error -q
153 if [ $? == 0 ] ; then
8ca5b365 154 pr ; pr " * VM went to error state"
155 error=1
50881e44 156 break
157 fi
158
159 # no error: try to parse address (NOTE: only IPv4 for the moment)
160 iip=$( echo "$raw" | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' )
161 if [ "$iip" != '' ] ; then
162 pr
163 break
164 fi
165
166 # no address
167 pr -n '.'
168
169 done
170
171 # do we have address?
172 if [ "$iip" != '' ] ; then
173 pr " * VM has address $iip"
174 break
175 fi
176
177 # we don't: terminate (timeout)
8ca5b365 178 [ "$error" != 1 ] && pr 'timeout'
179 pr " * Terminating instance $iid"
50881e44 180 euca-terminate-instances "$iid" > /dev/null 2>&1
181
182 done
183
184 # success
185 [ "$createdKeypair" == 1 ] && euca-delete-keypair "$keyName" > /dev/null 2>&1
186 echo "$iid $iip" # must be parsed
187 return 0
188
189}
190
191# Prepare the validation session directory. Syntax:
192#
193# PrepareSession <aliroot_tag>
194#
195# Returns 0 on success, nonzero on failure. Session tag returned on stdout.
196function PrepareSession() {
197 local aliRootTag sessionTag sessionDir
198 aliRootTag="$1"
4a94e8bb 199 shift
50881e44 200 sessionTag="${aliRootTag}_$(ndate)"
201 sessionDir="$sessionPrefix/$sessionTag"
202
203 # session directory already exists? abort
204 if [ -d "$sessionDir" ] ; then
205 pr "Session directory already exists, aborting"
206 return $errSessionDir
207 fi
208
209 # create working directory
210 mkdir -p "$sessionDir"
211 if [ $? != 0 ] ; then
212 pr "Fatal: cannot create session directory $sessionDir"
213 return $errSessionDir
214 fi
215
216 # aliroot version written to a file
217 echo "$aliRootTag" > "$sessionDir/aliroot-version.txt"
218
219 # benchmark script, configuration and file list
220 cp benchmark.sh benchmark.config files.list "$sessionDir/"
221 if [ $? != 0 ] ; then
222 pr "Cannot copy benchmark configuration and script to $sessionDir"
223 return $errSessionDir
224 fi
225
226 # append local files to the configuration
227 for f in benchmark.config.d/*.config ; do
228 [ ! -e "$f" ] && continue
229 ( echo ''
230 echo "### from $f ###"
231 cat $f
232 echo ''
233 ) >> "$sessionDir/benchmark.config"
234 done
235
4a94e8bb 236 # command-line options override the configuration
237 if [ $# != 0 ] ; then
238 pr "Note: the following command-line options will override the corresponding ones in the config files:"
239 ( echo ''
240 echo "### from the command line ###"
241 while [ $# -gt 0 ] ; do
242 extraName="${1%%=*}"
243 extraVal="${1#*=}"
244 if [ "$extraName" != "$1" ] ; then
245 pr " * $extraName = $extraVal"
246 echo "$1"
247 fi
248 shift
249 done
250 echo ''
251 ) >> "$sessionDir/benchmark.config"
252 fi
253
50881e44 254 # success: return the session tag and move to the session directory
255 pr "*** Creating new working session: $sessionTag ***"
256 pr "*** Use this name for future session operations ***"
257 echo "$sessionTag"
258 return 0
259}
260
261# Undo the previous action
262function PrepareSession_Undo() {
263 rm -rf "$sessionPrefix/$1"
264}
265
266# Move into the session tag directory. Usage:
267#
268# MoveToSessionDir <session_tag>
269#
270# Returns 0 on success, nonzero on error.
271function MoveToSessionDir() {
272 originalWorkDir="$PWD"
273 cd "$sessionPrefix/$sessionTag" || return $errSessionDir
274 return 0
275}
276
277# Undo the previous action
278function MoveToSessionDir_Undo() {
279 cd "$originalWorkDir"
280}
281
282# Load the benchmark configuration
283function LoadConfig() {
284 source benchmark.config > /dev/null 2>&1
285 if [ $? != 0 ] ; then
286 pr "Cannot load benchmark configuration"
287 return $errCfg
288 fi
289 return 0
290}
291
292# Instantiate the validation VM
293function InstantiateValidationVM() {
294 local sessionTag instanceId instanceIp ret raw
295 sessionTag="$1"
296
297 # check if we already have a vm
298 instanceId="$(cat instance-id.txt 2> /dev/null)"
299 if [ "$instanceId" != '' ] ; then
300 pr "Virtual machine $instanceId is already running"
301 return 0 # consider it a success
302 else
303 rm -f instance-id.txt instance-address.txt
304 fi
305
306 # do we need to create a keypair?
307 if [ "$cloudKeyName" == '' ] ; then
308 pr "Note: temporary SSH keys will be created for this VM"
309 cloudKeyName="$sessionTag"
310 cloudKeyFile="$PWD/key.pem"
311 rm -f "$cloudKeyFile"
312 elif [ -e "$cloudKeyFile" ] ; then
313 # copy key to session dir
314 pr -n "Copying private key $cloudKeyFile to session directory..."
315 cp "$cloudKeyFile" 'key.pem' 2> /dev/null
316 if [ $? != 0 ] ; then
317 pr 'error'
318 return $errCopyKey
319 else
320 pr 'ok'
321 fi
322 cloudKeyFile="$PWD/key.pem"
323 else
324 pr "Cannot find private key to access virtual machines: $cloudKeyFile"
325 return $errCopyKey
326 fi
327
328 # launch virtual machine and get its address
329 raw=$( RunVM "$cloudImageId" "$cloudProfile" "$cloudUserData" "$cloudKeyName" "$cloudKeyFile" )
330 ret=$?
331
332 if [ $ret == 0 ] ; then
333 instanceId=$( echo $raw | cut -d' ' -f1 )
334 instanceIp=$( echo $raw | cut -d' ' -f2 )
335
336 # write both parameters to files
337 echo $instanceId > 'instance-id.txt'
338 echo $instanceIp > 'instance-address.txt'
339 fi
340
341 return $ret
342}
343
344# Undo the previous action
345function InstantiateValidationVM_Undo() {
346 local sessionTag
347 sessionTag="$1"
348 if [ -e 'instance-id.txt' ] ; then
349 swallow euca-terminate-instances $(cat instance-id.txt)
350 if [ $? == 0 ] ; then
351 rm -f instance-id.txt instance-address.txt key.pem
352 fi
353 fi
354}
355
356# Generic SSH function to the VM
357function VMSSH() {
358 local instanceIp sshParams ret
359 instanceIp=$(cat instance-address.txt 2> /dev/null)
360 sshParams="-oUserKnownHostsFile=/dev/null -oStrictHostKeyChecking=no -oPasswordAuthentication=no -i $PWD/key.pem"
361
362 if [ "$1" == '--rsync-cmd' ] ; then
363 shift
364 echo ssh $sshParams "$@"
365 ret=0
366 else
367 ssh $sshParams "$cloudUserName"@"$instanceIp" "$@"
368 ret=$?
369 fi
370 return $ret
371}
372
373# Opens a shell to the remote VM
374function Shell() {
375 local sessionTag
376 sessionTag="$1"
377 VMSSH
378}
379
380# Checks status of the validation
381function Status() {
382 local raw ret screen exitcode
8c3e0568 383 raw=$( VMSSH -t "screen -ls 2> /dev/null | grep -q .$screenName && echo -n 'screen_yes ' || echo -n 'screen_no ' ; cat $sessionTag/validation.done 2> /dev/null || echo 'not_done' ; true" 2> /dev/null )
50881e44 384 raw=$( echo "$raw" | tr -cd '[:alnum:]_ ' ) # garbage removal
385 ret=$?
386
387 if [ "$ret" != 0 ] ; then
388 pr "Cannot get status"
389 return $errStatusUnavailable
390 fi
391
392 screen="${raw%% *}"
393 exitcode="${raw#* }"
394
395 if [ "$screen" == 'screen_yes' ] ; then
396 pr 'Status: validation still running'
397 return $errStatusRunning
398 else
399 if [ "$exitcode" == 'not_done' ] ; then
400 pr 'Status: validation not running'
401 return $errStatusNotRunning
402 elif [ "$exitcode" == 0 ] ; then
403 pr 'Status: validation completed successfully'
404 return $errStatusDoneOk
405 else
406 pr "Status: validation finished with errors (exitcode: $exitcode)"
407 return $errStatusDoneFail
408 fi
409 fi
410
411}
412
413# Wait for host to be ready
414function WaitSsh() {
415 local attempt error
416 attempt=0
417 pr -n 'Waiting for the VM to accept SSH connections...'
418
419 while ! VMSSH -Tq true > /dev/null 2>&1 ; do
420 if [ $((++attempt)) -gt $maxSshConnectAttempts ] ; then
421 pr 'timeout'
422 error=1
423 break
424 fi
425 pr -n '.'
426 sleep 3
427 done
428
429 [ "$error" == 1 ] && return $errSshNotReady
430 pr 'ok'
431 return 0
432}
433
434# Run the validation
435function Validate() {
436 local instanceIp sshParams sessionTag
437 sessionTag="$1"
438 instanceIp=$(cat instance-address.txt 2> /dev/null)
439 sshParams="-oUserKnownHostsFile=/dev/null -oStrictHostKeyChecking=no -oPasswordAuthentication=no -i $PWD/key.pem"
440
441 # create helper script to launch benchmark
442 cat > run-benchmark.sh <<_EoF_
443#!/bin/bash
444cd \$(dirname "\$0")
445v=validation.done
446rm -f \$v
447env ALIROOT_VERSION=$(cat aliroot-version.txt) ./benchmark.sh run $sessionTag files.list benchmark.config
448#sleep 1000
449ret=\$?
450echo \$ret > \$v
451echo ; echo ; echo
452echo "*** Validation finished with exitcode \$ret ***"
453echo ; echo ; echo
454read -p 'Press ENTER to dismiss: automatic dismiss in 60 seconds...' -t 60
455_EoF_
456 chmod +x run-benchmark.sh
457
458 # transfer files
459 pr 'Transferring files to the VM'
8c3e0568 460 rsync -av -e "$(VMSSH --rsync-cmd)" $PWD/ $cloudUserName@$instanceIp:$sessionTag/ || return $errLaunchValidation
50881e44 461
462 # open a screen that does something; note that the command is not executed if
463 # the screen already exists, which is what we want
464 # note: sleep necessary to avoid "dead" screens
8c3e0568 465 VMSSH -t "screen -wipe > /dev/null 2>&1 ; if screen -ls | grep -q $screenName ; then ret=42 ; else screen -dmS AliceReleaseValidation $sessionTag/run-benchmark.sh ; ret=0 ; sleep 3 ; fi ; exit \$ret"
50881e44 466 ret=$?
467
468 # message
469 if [ $ret == 42 ] ; then
470 pr 'Validation already running inside a screen.'
471 else
472 pr 'Validation launched inside a screen.'
473 fi
474
475 pr
476 pr 'Check the progress status with:'
477 pr " $Prog --session $sessionTag --status"
478 pr 'Attach to the screen for debug:'
479 pr " $Prog --session $sessionTag --attach"
480 pr 'Open a shell to the virtual machine:'
481 pr " $Prog --session $sessionTag --shell"
482 pr
483
484 # ignore ssh errors
485 return 0
486}
487
488# Attach current validation screen, if possible
489function Attach() {
490 local sessionTag
491 sessionTag="$1"
492
493 VMSSH -t "( screen -wipe ; screen -rx $screenName ) > /dev/null 2>&1"
494
495 if [ $? != 0 ] ; then
496 pr "Cannot attach screen: check if validation is running with:"
497 pr " $Prog --session $sessionTag --status"
498 pr "or connect manually to the VM for debug:"
499 pr " $Prog --session $sessionTag --attach"
500 return $errAttachScreen
501 fi
502
503 return 0
504}
505
506# Pick session interactively
507function PickSession() {
508 local sessionTag sess listSessions
509 listSessions=()
510 mkdir -p "$sessionPrefix"
511
512 while read sess ; do
513 [ ! -d "$sessionPrefix/$sess" ] && continue
514 listSessions+=( $sess )
515 done < <( cd $sessionPrefix ; ls -1t )
516
517 if [ ${#listSessions[@]} == 0 ] ; then
518 pr "No session available in session directory $sessionPrefix"
519 return $errPickSession
520 fi
521
522 pr 'Available sessions (most recent first):'
523 for ((i=0; i<${#listSessions[@]}; i++)) ; do
524 pr "$( printf " % 2d. ${listSessions[$i]}" $((i+1)) )"
525 done
526 pr -n 'Pick one: '
527 read i
528
529 let i--
530 if [ "$i" -lt 0 ] || [ "${listSessions[$i]}" == '' ] ; then
531 pr 'Invalid session'
532 return $errPickSession
533 fi
534
535 sess="${listSessions[$i]}"
536 pr "You chose session $sess"
537 echo $sess
538 return 0
539}
540
541# Run an action
542function RunAction() {
543 local ret
544 type "$1" > /dev/null 2>&1
545 if [ $? == 0 ] ; then
546 #pr "--> $1 (wd: $PWD)"
547 eval "$@"
548 ret=$?
549 #pr "<-- $1 (ret: $ret, wd: $PWD)"
550 return $ret
551 fi
552 return 0
553}
554
555# Print help screen
556function Help() {
557 pr "$Prog -- by Dario Berzano <dario.berzano@cern.ch>"
558 pr 'Controls the Release Validation workflow on the cloud for AliRoot.'
559 pr
4a94e8bb 560 pr "Usage 1: $Prog [--prepare|--launch] --aliroot <aliroot_tag> [-- arbitraryOpt1=value [arbitraryOpt2=value2...]]"
50881e44 561 pr
562 pr 'A new session is created to validate the specified AliRoot tag.'
563 pr
564 pr ' --prepare : prepares the session directory containing the files needed'
565 pr ' for the validation'
566 pr ' --launch : launches the full validation process: prepares session,'
567 pr ' runs the virtual machine, launches the validation program'
568 pr ' --aliroot : the AliRoot tag to validate, in the form "vAN-20140610"'
4a94e8bb 569 pr
570 pr 'Arbitrary options (in the form variable=value) can be specified after the'
571 pr 'double dash and will override the corresponding options in any of the'
572 pr 'configuration files.'
50881e44 573 pr ; pr
574 pr "Usage 2: $Prog [--runvm|--validate|--shell|--status] --session <session_tag>"
575 pr
576 pr 'Runs the validation step by step after a session is created with'
577 pr '--prepare, and runs other actions on a certain session.'
578 pr
579 pr ' --session : session identifier, e.g. vAN-20140610_20140612-123047-utc:'
580 pr ' if no session is specified an interactive prompt is'
581 pr ' presented'
582 pr ' --runvm : instantiates the head node of the validation cluster on'
583 pr ' the cloud'
584 pr ' --validate : runs the validation script on the head node for the'
585 pr ' current session. Head node must be already up, or it'
586 pr ' should be created with --runvm. If validation is running'
587 pr ' already, connects to the existing validation shell'
588 pr ' --attach : attach a currently running validation screen; remember to'
589 pr ' detach with Ctrl+A+D (and *not* Ctrl-C)'
590 pr ' --shell : does SSH on the head node'
591 pr ' --status : returns the status of the validation'
592 pr ; pr
593 pr 'Example 1: run the validation of AliRoot tag vAN-20140610:'
594 pr
595 pr " $Prog --aliroot vAN-20140610 --launch"
596 pr
597 pr 'Example 2: do the same thing step-by-step:'
598 pr
599 pr " $Prog --aliroot vAN-20140610 --prepare"
600 pr " $Prog --runvm"
601 pr " $Prog --validate"
602 pr
603}
604
605# The main function
606function Main() {
607
608 # local variables
609 local Args aliRootTag EnterShell Actions sessionTag
610 Actions=()
611
612 # parse command line options
613 while [ $# -gt 0 ] ; do
614 case "$1" in
615
616 # options
617 --aliroot|-a)
618 aliRootTag="$2"
619 shift 2
620 ;;
621 --session)
622 sessionTag="$2"
623 shift 2
624 ;;
625
626 # actions
627 --launch)
628 # all actions
629 Actions=( PrepareSession MoveToSessionDir LoadConfig InstantiateValidationVM WaitSsh Validate )
630 shift
631 ;;
632 --prepare)
633 Actions=( PrepareSession MoveToSessionDir )
634 shift
635 ;;
636 --runvm)
637 Actions=( MoveToSessionDir LoadConfig InstantiateValidationVM )
638 shift
639 ;;
640 --validate)
641 Actions=( MoveToSessionDir LoadConfig WaitSsh Validate )
642 shift
643 ;;
644 --attach)
645 Actions=( MoveToSessionDir LoadConfig WaitSsh Attach )
646 shift
647 ;;
648
649 # extra actions
650 --shell)
651 Actions=( MoveToSessionDir LoadConfig WaitSsh Shell )
652 shift
653 ;;
654 --status)
655 Actions=( MoveToSessionDir LoadConfig WaitSsh Status )
656 shift
657 ;;
658 --help)
659 Help
660 exit 0
661 ;;
662
4a94e8bb 663 # end of options
664 --)
665 shift
666 break
667 ;;
668
50881e44 669 *)
670 pr "Invalid option: $1. Use --help for assistance."
671 return $errInvalidOpt
672 ;;
673 esac
674 done
50881e44 675
676 # check for the presence of the required tools in the $PATH
677 for T in euca-describe-instances euca-describe-regions euca-run-instances euca-create-keypair euca-delete-keypair rsync ; do
678 which "$T" > /dev/null 2>&1
679 if [ $? != 0 ] ; then
680 pr "Cannot find one of the required commands: $T"
681 return $errMissingCmd
682 fi
683 done
684
685 # test EC2 credentials
686 # euca-describe-regions > /dev/null 2>&1
687 # if [ $? != 0 ] ; then
688 # pr 'Cannot authenticate to EC2.'
689 # pr 'Note: you must have at least the following variables properly set in your environment:'
690 # pr " * EC2_URL (current value: ${EC2_URL-<not set>})"
691 # pr " * EC2_ACCESS_KEY (current value: ${EC2_ACCESS_KEY-<not set>})"
692 # pr " * EC2_SECRET_KEY (current value: ${EC2_SECRET_KEY-<not set>})"
693 # return $errEc2Auth
694 # fi
695
696 # what to do?
697 if [ ${#Actions[@]} == 0 ] ; then
698 pr 'Nothing to do. Use --help for assistance.'
699 return $errInvalidOpt
700 fi
701
702 # run actions
703 for ((i=0; i<${#Actions[@]}; i++)) ; do
704
705 A=${Actions[$i]}
706
707 if [ "$A" == 'PrepareSession' ] ; then
708 # special action returning the session tag
709 if [ "$aliRootTag" == '' ] ; then
710 pr 'Specify an AliRoot version with --aliroot <tag>'
711 return $errInvalidOpt
712 fi
713 if [ "$sessionTag" != '' ] ; then
714 pr 'Cannot use --session with --prepare. Use --help for assistance.'
715 return $errInvalidOpt
716 fi
4a94e8bb 717 sessionTag=$( RunAction "$A" "$aliRootTag" "$@" )
50881e44 718 ret=$?
719 else
720 if [ "$sessionTag" == '' ] ; then
721 sessionTag=$( PickSession )
722 ret=$?
723 [ $ret != 0 ] && return $ret
724 fi
725 RunAction "$A" "$sessionTag"
726 ret=$?
727 fi
728
729 # 100 to 140 --> not errors
730 ( [ $ret != 0 ] && ( [ $ret -ge 100 ] || [ $ret -le 140 ] ) ) && break
731
732 done
733
734 # undo actions
735 let i--
736 if [ $ret != 0 ] && ( [ $ret -ge 100 ] || [ $ret -le 140 ] ) ; then
737 for ((; i>=0; i--)) ; do
738 RunAction "${Actions[$i]}_Undo" "$sessionTag"
739 done
740 fi
741
742 # return last value
743 return $ret
744
745}
746
747#
748# Entry point
749#
750
751Main "$@" || exit $?