Statistics
| Branch: | Tag: | Revision:

mininet / util / vm / build.py @ 803a1a54

History | View | Annotate | Download (18 KB)

1
#!/usr/bin/python
2

    
3
"""
4
build.py: build a Mininet VM
5

6
Basic idea:
7

8
    prepare
9
    -> create base install image if it's missing
10
        - download iso if it's missing
11
        - install from iso onto image
12

13
    build
14
    -> create cow disk for new VM, based on base image
15
    -> boot it in qemu/kvm with text /serial console
16
    -> install Mininet
17

18
    test
19
    -> sudo mn --test pingall
20
    -> make test
21

22
    release
23
    -> shut down VM
24
    -> shrink-wrap VM
25
    -> upload to storage
26

27
"""
28

    
29
import os
30
from os import stat, path
31
from stat import ST_MODE
32
from os.path import abspath
33
from sys import exit, argv
34
from glob import glob
35
from subprocess import check_output, call, Popen
36
from tempfile import mkdtemp
37
from time import time, strftime, localtime
38
import argparse
39

    
40
pexpect = None  # For code check - imported dynamically
41

    
42
# boot can be slooooow!!!! need to debug/optimize somehow
43
TIMEOUT=600
44

    
45
VMImageDir = os.environ[ 'HOME' ] + '/vm-images'
46

    
47
isoURLs = {
48
    'precise32server':
49
    'http://mirrors.kernel.org/ubuntu-releases/12.04/'
50
    'ubuntu-12.04.3-server-i386.iso',
51
    'precise64server':
52
    'http://mirrors.kernel.org/ubuntu-releases/12.04/'
53
    'ubuntu-12.04.3-server-amd64.iso',
54
    'quantal32server':
55
    'http://mirrors.kernel.org/ubuntu-releases/12.10/'
56
    'ubuntu-12.10-server-i386.iso',
57
    'quantal64server':
58
    'http://mirrors.kernel.org/ubuntu-releases/12.10/'
59
    'ubuntu-12.10-server-amd64.iso',
60
    'raring32server':
61
    'http://mirrors.kernel.org/ubuntu-releases/13.04/'
62
    'ubuntu-13.04-server-i386.iso',
63
    'raring64server':
64
    'http://mirrors.kernel.org/ubuntu-releases/13.04/'
65
    'ubuntu-13.04-server-amd64.iso',
66
    'saucy32server':
67
    'http://mirrors.kernel.org/ubuntu-releases/13.10/'
68
    'ubuntu-13.10-server-i386.iso',
69
    'saucy64server':
70
    'http://mirrors.kernel.org/ubuntu-releases/13.10/'
71
    'ubuntu-13.10-server-amd64.iso',
72
}
73

    
74
LogStartTime = time()
75
LogFile = None
76

    
77
def log( *args, **kwargs ):
78
    """Simple log function: log( message along with local and elapsed time
79
       cr: False/0 for no CR"""
80
    cr = kwargs.get( 'cr', True )
81
    elapsed = time() - LogStartTime
82
    clocktime = strftime( '%H:%M:%S', localtime() )
83
    msg = ' '.join( str( arg ) for arg in args )
84
    output = '%s [ %.3f ] %s' % ( clocktime, elapsed, msg )
85
    if cr:
86
        print output
87
    else:
88
        print output,
89
    # Optionally mirror to LogFile
90
    if type( LogFile ) is file:
91
        if cr:
92
            output += '\n'
93
        LogFile.write( output )
94

    
95

    
96
def run( cmd, **kwargs ):
97
    "Convenient interface to check_output"
98
    log( '-', cmd )
99
    cmd = cmd.split()
100
    return check_output( cmd, **kwargs )
101

    
102

    
103
def srun( cmd, **kwargs ):
104
    "Run + sudo"
105
    return run( 'sudo ' + cmd, **kwargs )
106

    
107

    
108
def depend():
109
    "Install package dependencies"
110
    log( '* Installing package dependencies' )
111
    run( 'sudo apt-get -y update' )
112
    run( 'sudo apt-get install -y'
113
         ' kvm cloud-utils genisoimage qemu-kvm qemu-utils'
114
         ' e2fsprogs '
115
         ' landscape-client'
116
         ' python-setuptools' )
117
    run( 'sudo easy_install pexpect' )
118

    
119

    
120
def popen( cmd ):
121
    "Convenient interface to popen"
122
    log( cmd )
123
    cmd = cmd.split()
124
    return Popen( cmd )
125

    
126

    
127
def remove( fname ):
128
    "rm -f fname"
129
    return run( 'rm -f %s' % fname )
130

    
131

    
132
def findiso( flavor ):
133
    "Find iso, fetching it if it's not there already"
134
    url = isoURLs[ flavor ]
135
    name = path.basename( url )
136
    iso = path.join( VMImageDir, name )
137
    if not path.exists( iso ) or ( stat( iso )[ ST_MODE ] & 0777 != 0444 ):
138
        log( '* Retrieving', url )
139
        run( 'curl -C - -o %s %s' % ( iso, url ) )
140
        if 'ISO' not in run( 'file ' + iso ):
141
            os.remove( iso )
142
            raise Exception( 'findiso: could not download iso from ' + url )
143
        # Write-protect iso, signaling it is complete
144
        log( '* Write-protecting iso', iso)
145
        os.chmod( iso, 0444 )
146
    log( '* Using iso', iso )
147
    return iso
148

    
149

    
150
def attachNBD( cow, flags='' ):
151
    """Attempt to attach a COW disk image and return its nbd device
152
        flags: additional flags for qemu-nbd (e.g. -r for readonly)"""
153
    # qemu-nbd requires an absolute path
154
    cow = abspath( cow )
155
    log( '* Checking for unused /dev/nbdX device ' )
156
    for i in range ( 0, 63 ):
157
        nbd = '/dev/nbd%d' % i
158
        # Check whether someone's already messing with that device
159
        if call( [ 'pgrep', '-f', nbd ] ) == 0:
160
            continue
161
        srun( 'modprobe nbd max-part=64' )
162
        srun( 'qemu-nbd %s -c %s %s' % ( flags, nbd, cow ) )
163
        print
164
        return nbd
165
    raise Exception( "Error: could not find unused /dev/nbdX device" )
166

    
167

    
168
def detachNBD( nbd ):
169
    "Detatch an nbd device"
170
    srun( 'qemu-nbd -d ' + nbd )
171

    
172

    
173
def extractKernel( image, flavor ):
174
    "Extract kernel and initrd from base image"
175
    kernel = path.join( VMImageDir, flavor + '-vmlinuz' )
176
    initrd = path.join( VMImageDir, flavor + '-initrd' )
177
    if path.exists( kernel ) and ( stat( image )[ ST_MODE ] & 0777 ) == 0444:
178
        # If kernel is there, then initrd should also be there
179
        return kernel, initrd
180
    log( '* Extracting kernel to', kernel )
181
    nbd = attachNBD( image, flags='-r' )
182
    print srun( 'partx ' + nbd )
183
    # Assume kernel is in partition 1/boot/vmlinuz*generic for now
184
    part = nbd + 'p1'
185
    mnt = mkdtemp()
186
    srun( 'mount -o ro %s %s' % ( part, mnt  ) )
187
    kernsrc = glob( '%s/boot/vmlinuz*generic' % mnt )[ 0 ]
188
    initrdsrc = glob( '%s/boot/initrd*generic' % mnt )[ 0 ]
189
    srun( 'cp %s %s' % ( initrdsrc, initrd ) )
190
    srun( 'chmod 0444 ' + initrd )
191
    srun( 'cp %s %s' % ( kernsrc, kernel ) )
192
    srun( 'chmod 0444 ' + kernel )
193
    srun( 'umount ' + mnt )
194
    run( 'rmdir ' + mnt )
195
    detachNBD( nbd )
196
    return kernel, initrd
197

    
198

    
199
def findBaseImage( flavor, size='8G' ):
200
    "Return base VM image and kernel, creating them if needed"
201
    image = path.join( VMImageDir, flavor + '-base.qcow2' )
202
    if path.exists( image ):
203
        # Detect race condition with multiple builds
204
        perms = stat( image )[ ST_MODE ] & 0777
205
        if perms != 0444:
206
            raise Exception( 'Error - %s is writable ' % image +
207
                            '; are multiple builds running?' )
208
    else:
209
        # We create VMImageDir here since we are called first
210
        run( 'mkdir -p %s' % VMImageDir )
211
        iso = findiso( flavor )
212
        log( '* Creating image file', image )
213
        run( 'qemu-img create -f qcow2 %s %s' % ( image, size ) )
214
        installUbuntu( iso, image )
215
        # Write-protect image, also signaling it is complete
216
        log( '* Write-protecting image', image)
217
        os.chmod( image, 0444 )
218
    kernel, initrd = extractKernel( image, flavor )
219
    log( '* Using base image', image, 'and kernel', kernel )
220
    return image, kernel, initrd
221

    
222

    
223
# Kickstart and Preseed files for Ubuntu/Debian installer
224
#
225
# Comments: this is really clunky and painful. If Ubuntu
226
# gets their act together and supports kickstart a bit better
227
# then we can get rid of preseed and even use this as a
228
# Fedora installer as well.
229
#
230
# Another annoying thing about Ubuntu is that it can't just
231
# install a normal system from the iso - it has to download
232
# junk from the internet, making this house of cards even
233
# more precarious.
234

    
235
KickstartText ="""
236
#Generated by Kickstart Configurator
237
#platform=x86
238

239
#System language
240
lang en_US
241
#Language modules to install
242
langsupport en_US
243
#System keyboard
244
keyboard us
245
#System mouse
246
mouse
247
#System timezone
248
timezone America/Los_Angeles
249
#Root password
250
rootpw --disabled
251
#Initial user
252
user mininet --fullname "mininet" --password "mininet"
253
#Use text mode install
254
text
255
#Install OS instead of upgrade
256
install
257
#Use CDROM installation media
258
cdrom
259
#System bootloader configuration
260
bootloader --location=mbr
261
#Clear the Master Boot Record
262
zerombr yes
263
#Partition clearing information
264
clearpart --all --initlabel
265
#Automatic partitioning
266
autopart
267
#System authorization infomation
268
auth  --useshadow  --enablemd5
269
#Firewall configuration
270
firewall --disabled
271
#Do not configure the X Window System
272
skipx
273
"""
274

    
275
# Tell the Ubuntu/Debian installer to stop asking stupid questions
276

    
277
PreseedText = """
278
d-i mirror/country string manual
279
d-i mirror/http/hostname string mirrors.kernel.org
280
d-i mirror/http/directory string /ubuntu
281
d-i mirror/http/proxy string
282
d-i partman/confirm_write_new_label boolean true
283
d-i partman/choose_partition select finish
284
d-i partman/confirm boolean true
285
d-i partman/confirm_nooverwrite boolean true
286
d-i user-setup/allow-password-weak boolean true
287
d-i finish-install/reboot_in_progress note
288
d-i debian-installer/exit/poweroff boolean true
289
"""
290

    
291
def makeKickstartFloppy():
292
    "Create and return kickstart floppy, kickstart, preseed"
293
    kickstart = 'ks.cfg'
294
    with open( kickstart, 'w' ) as f:
295
        f.write( KickstartText )
296
    preseed = 'ks.preseed'
297
    with open( preseed, 'w' ) as f:
298
        f.write( PreseedText )
299
    # Create floppy and copy files to it
300
    floppy = 'ksfloppy.img'
301
    run( 'qemu-img create %s 1440k' % floppy )
302
    run( 'mkfs -t msdos ' + floppy )
303
    run( 'mcopy -i %s %s ::/' % ( floppy, kickstart ) )
304
    run( 'mcopy -i %s %s ::/' % ( floppy, preseed ) )
305
    return floppy, kickstart, preseed
306

    
307

    
308
def kvmFor( filepath ):
309
    "Guess kvm version for file path"
310
    name = path.basename( filepath )
311
    if '64' in name:
312
        kvm = 'qemu-system-x86_64'
313
    elif 'i386' in name or '32' in name:
314
        kvm = 'qemu-system-i386'
315
    else:
316
        log( "Error: can't discern CPU for file name", name )
317
        exit( 1 )
318
    return kvm
319

    
320

    
321
def installUbuntu( iso, image, logfilename='install.log' ):
322
    "Install Ubuntu from iso onto image"
323
    kvm = kvmFor( iso )
324
    floppy, kickstart, preseed = makeKickstartFloppy()
325
    # Mount iso so we can use its kernel
326
    mnt = mkdtemp()
327
    srun( 'mount %s %s' % ( iso, mnt ) )
328
    kernel = path.join( mnt, 'install/vmlinuz' )
329
    initrd = path.join( mnt, 'install/initrd.gz' )
330
    cmd = [ 'sudo', kvm,
331
           '-machine', 'accel=kvm',
332
           '-nographic',
333
           '-netdev', 'user,id=mnbuild',
334
           '-device', 'virtio-net,netdev=mnbuild',
335
           '-m', '1024',
336
           '-k', 'en-us',
337
           '-fda', floppy,
338
           '-drive', 'file=%s,if=virtio' % image,
339
           '-cdrom', iso,
340
           '-kernel', kernel,
341
           '-initrd', initrd,
342
           '-append',
343
           ' ks=floppy:/' + kickstart +
344
           ' preseed/file=floppy://' + preseed +
345
           ' console=ttyS0' ]
346
    ubuntuStart = time()
347
    log( '* INSTALLING UBUNTU FROM', iso, 'ONTO', image )
348
    log( ' '.join( cmd ) )
349
    log( '* logging to', abspath( logfilename ) )
350
    logfile = open( logfilename, 'w' )
351
    vm = Popen( cmd, stdout=logfile, stderr=logfile )
352
    log( '* Waiting for installation to complete')
353
    vm.wait()
354
    logfile.close()
355
    elapsed = time() - ubuntuStart
356
    # Unmount iso and clean up
357
    srun( 'umount ' + mnt )
358
    run( 'rmdir ' + mnt )
359
    if vm.returncode != 0:
360
        raise Exception( 'Ubuntu installation returned error %d' %
361
                          vm.returncode )
362
    log( '* UBUNTU INSTALLATION COMPLETED FOR', image )
363
    log( '* Ubuntu installation completed in %.2f seconds ' % elapsed )
364

    
365

    
366
def boot( cow, kernel, initrd, logfile ):
367
    """Boot qemu/kvm with a COW disk and local/user data store
368
       cow: COW disk path
369
       kernel: kernel path
370
       logfile: log file for pexpect object
371
       returns: pexpect object to qemu process"""
372
    # pexpect might not be installed until after depend() is called
373
    global pexpect
374
    import pexpect
375
    kvm = kvmFor( kernel )
376
    cmd = [ 'sudo', kvm,
377
            '-machine accel=kvm',
378
            '-nographic',
379
            '-netdev user,id=mnbuild',
380
            '-device virtio-net,netdev=mnbuild',
381
            '-m 1024',
382
            '-k en-us',
383
            '-kernel', kernel,
384
            '-initrd', initrd,
385
            '-drive file=%s,if=virtio' % cow,
386
            '-append "root=/dev/vda1 init=/sbin/init console=ttyS0" ' ]
387
    cmd = ' '.join( cmd )
388
    log( '* BOOTING VM FROM', cow )
389
    log( cmd )
390
    vm = pexpect.spawn( cmd, timeout=TIMEOUT, logfile=logfile )
391
    return vm
392

    
393

    
394
def interact( vm ):
395
    "Interact with vm, which is a pexpect object"
396
    prompt = '\$ '
397
    log( '* Waiting for login prompt' )
398
    vm.expect( 'login: ' )
399
    log( '* Logging in' )
400
    vm.sendline( 'mininet' )
401
    log( '* Waiting for password prompt' )
402
    vm.expect( 'Password: ' )
403
    log( '* Sending password' )
404
    vm.sendline( 'mininet' )
405
    log( '* Waiting for login...' )
406
    vm.expect( prompt )
407
    log( '* Sending hostname command' )
408
    vm.sendline( 'hostname' )
409
    log( '* Waiting for output' )
410
    vm.expect( prompt )
411
    log( '* Fetching Mininet VM install script' )
412
    vm.sendline( 'wget '
413
                 'https://raw.github.com/mininet/mininet/master/util/vm/'
414
                 'install-mininet-vm.sh' )
415
    vm.expect( prompt )
416
    log( '* Running VM install script' )
417
    vm.sendline( 'bash install-mininet-vm.sh' )
418
    vm.expect ( 'password for mininet: ' )
419
    vm.sendline( 'mininet' )
420
    log( '* Waiting for script to complete... ' )
421
    # Gigantic timeout for now ;-(
422
    vm.expect( 'Done preparing Mininet', timeout=3600 )
423
    log( '* Completed successfully' )
424
    vm.expect( prompt )
425
    log( '* Testing Mininet' )
426
    vm.sendline( 'sudo mn --test pingall' )
427
    if vm.expect( [ ' 0% dropped', pexpect.TIMEOUT ], timeout=45 ) == 0:
428
        log( '* Sanity check OK' )
429
    else:
430
        log( '* Sanity check FAILED' )
431
    vm.expect( prompt )
432
    log( '* Making sure cgroups are mounted' )
433
    vm.sendline( 'sudo service cgroup-lite restart' )
434
    vm.expect( prompt )
435
    vm.sendline( 'sudo cgroups-mount' )
436
    vm.expect( prompt )
437
    log( '* Running make test' )
438
    vm.sendline( 'cd ~/mininet; sudo make test' )
439
    # We should change "make test" to report the number of
440
    # successful and failed tests. For now, we have to
441
    # know the time for each test, which means that this
442
    # script will have to change as we add more tests.
443
    for test in range( 0, 2 ):
444
        if vm.expect( [ 'OK', 'FAILED', pexpect.TIMEOUT ], timeout=180 ) == 0:
445
            log( '* Test', test, 'OK' )
446
        else:
447
            log( '* Test', test, 'FAILED' )
448
    vm.expect( prompt )
449
    log( '* Shutting down' )
450
    vm.sendline( 'sync; sudo shutdown -h now' )
451
    log( '* Waiting for EOF/shutdown' )
452
    vm.read()
453
    log( '* Interaction complete' )
454

    
455

    
456
def cleanup():
457
    "Clean up leftover qemu-nbd processes and other junk"
458
    call( [ 'sudo', 'pkill', '-9', 'qemu-nbd' ] )
459

    
460

    
461
def convert( cow, basename ):
462
    """Convert a qcow2 disk to a vmdk and put it a new directory
463
       basename: base name for output vmdk file"""
464
    vmdk = basename + '.vmdk'
465
    log( '* Converting qcow2 to vmdk' )
466
    run( 'qemu-img convert -f qcow2 -O vmdk %s %s' % ( cow, vmdk ) )
467
    return vmdk
468

    
469

    
470
# Template for virt-image(5) file
471

    
472
VirtImageXML = """
473
<?xml version="1.0" encoding="UTF-8"?>
474
<image>
475
    <name>%s</name>
476
    <domain>
477
        <boot type="hvm">
478
            <guest>
479
                <arch>%s/arch>
480
            </guest>
481
            <os>
482
                <loader dev="hd"/>
483
            </os>
484
            <drive disk="root.raw" target="hda"/>
485
        </boot>
486
        <devices>
487
            <vcpu>1</vcpu>
488
            <memory>%s</memory>
489
            <interface/>
490
            <graphics/>
491
        </devices>
492
    </domain>
493
        <storage>
494
            <disk file="%s" size="%s" format="vmdk"/>
495
        </storage>
496
</image>
497
"""
498

    
499
def genVirtImage( name, mem, diskname, disksize ):
500
    "Generate and return virt-image file name.xml"
501
    # Our strategy is going to be: create a
502
    # virt-image file and then use virt-convert to convert
503
    # it to an .ovf file
504
    xmlfile = name + '.xml'
505
    xmltext = VirtImageXML % ( name, mem, diskname, disksize )
506
    with open( xmlfile, 'w+' ) as f:
507
        f.write( xmltext )
508
    return xmlfile
509

    
510

    
511
def build( flavor='raring32server' ):
512
    "Build a Mininet VM"
513
    global LogFile
514
    start = time()
515
    date = strftime( '%y%m%d-%H-%M-%S', localtime())
516
    dir = 'mn-%s-%s' % ( flavor, date )
517
    try:
518
        os.mkdir( dir )
519
    except:
520
        raise Exception( "Failed to create build directory %s" % dir )
521
    os.chdir( dir )
522
    LogFile = open( 'build.log', 'w' )
523
    log( '* Logging to ', abspath( LogFile.name ) )
524
    log( '* Created working directory', dir )
525
    image, kernel, initrd = findBaseImage( flavor )
526
    volume = flavor + '.qcow2'
527
    run( 'qemu-img create -f qcow2 -b %s %s' % ( image, volume ) )
528
    log( '* VM image for', flavor, 'created as', volume )
529
    logfile = open( flavor + '.log', 'w+' )
530
    log( '* Logging results to', abspath( logfile.name ) )
531
    vm = boot( volume, kernel, initrd, logfile )
532
    interact( vm )
533
    vmdk = convert( volume, basename=flavor )
534
    log( '* Removing qcow2 volume', volume )
535
    os.remove( volume )
536
    log( '* Converted VM image stored as', abspath( vmdk ) )
537
    end = time()
538
    elapsed = end - start
539
    log( '* Results logged to', abspath( logfile.name ) )
540
    log( '* Completed in %.2f seconds' % elapsed )
541
    log( '* %s VM build DONE!!!!! :D' % flavor )
542
    os.chdir( '..' )
543

    
544

    
545
def listFlavors():
546
    "List valid build flavors"
547
    print '\nvalid build flavors:', ' '.join( isoURLs ), '\n'
548

    
549

    
550
def parseArgs():
551
    "Parse command line arguments and run"
552
    parser = argparse.ArgumentParser( description='Mininet VM build script' )
553
    parser.add_argument( '--depend', action='store_true',
554
                         help='install dependencies for this script' )
555
    parser.add_argument( '--list', action='store_true',
556
                         help='list valid build flavors' )
557
    parser.add_argument( '--clean', action='store_true',
558
                         help='clean up leftover build junk (e.g. qemu-nbd)' )
559
    parser.add_argument( 'flavor', nargs='*',
560
                         help='VM flavor to build (e.g. raring32server)' )
561
    args = parser.parse_args( argv )
562
    if args.depend:
563
        depend()
564
    if args.list:
565
        listFlavors()
566
    if args.clean:
567
        cleanup()
568
    flavors = args.flavor[ 1: ]
569
    for flavor in flavors:
570
        if flavor not in isoURLs:
571
            parser.print_help()
572
            listFlavors()
573
            break
574
        # try:
575
        build( flavor )
576
        # except Exception as e:
577
        # log( '* BUILD FAILED with exception: ', e )
578
        # exit( 1 )
579
    if not ( args.depend or args.list or args.clean or flavors ):
580
        parser.print_help()
581
        listFlavors()
582

    
583
if __name__ == '__main__':
584
    parseArgs()