p2v_transfer.py is run from the transfer OS to connect to the instance
and perform the transfer. It takes three arguments:
* Device that stores the root filesystem of the source OS
* Name or address of instance to connect to
* Path of a private key matching the public key installed on the
  instance.

For the time being, the administrator needs to generate the keys and
provide the private key to the user manually.

Signed-off-by: Ben Lipton <[email protected]>
---
 p2v-transfer/p2v_transfer.py           |  315 ++++++++++++++++++++++++++++++++
 p2v-transfer/test/p2v_transfer_test.py |  227 +++++++++++++++++++++++
 2 files changed, 542 insertions(+), 0 deletions(-)
 create mode 100755 p2v-transfer/p2v_transfer.py
 create mode 100755 p2v-transfer/test/p2v_transfer_test.py

diff --git a/p2v-transfer/p2v_transfer.py b/p2v-transfer/p2v_transfer.py
new file mode 100755
index 0000000..97eaa0c
--- /dev/null
+++ b/p2v-transfer/p2v_transfer.py
@@ -0,0 +1,315 @@
+#!/usr/bin/python
+#
+# Copyright 2011 Google Inc. All Rights Reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301, USA.
+
+
+"""Performs a physical to virtual transfer.
+
+This script is run from the transfer OS to establish an SSH connection
+with the bootstrap OS, mount the source filesystem(s), and copy
+the data over to the target. It will prompt the user for credentials as
+necessary to gain access to the bootstrap OS.
+
+"""
+
+
+import sys
+import optparse
+import os
+import paramiko
+import subprocess
+import time
+
+
+TARGET_MOUNT = "/target"
+SOURCE_MOUNT = "/source"
+
+
+class P2VError(Exception):
+  """Generic error class for problems with the transfer."""
+  pass
+
+
+def ParseOptions(argv):
+  usage = "Usage: %prog [options] root_dev target_host private_key"
+
+  parser = optparse.OptionParser(usage=usage)
+
+  options, args = parser.parse_args(argv[1:])
+
+  if len(args) != 3:
+    parser.print_help()
+    sys.exit(1)
+
+  # TODO(benlipton): scrub args
+
+  return options, args
+
+
+def LoadSSHKey(keyfile):
+  """Loads private key into paramiko.
+
+  @type keyfile: str
+  @param keyfile: Filename of private key to load.
+  @rtype: paramiko.PKey
+  @returns: Paramiko object representing the private key.
+  @raise P2VError: Keyfile is missing, invalid, or encrypted.
+
+  """
+  try:
+    key = paramiko.DSSKey.from_private_key_file(keyfile)
+    return key
+  except paramiko.PasswordRequiredException:
+    raise P2VError("Why is the private key file encrypted?")
+  except (IOError, paramiko.SSHException):
+    raise P2VError("Key file is missing or invalid")
+
+
+def EstablishConnection(user, host, key):
+  """Creates a connection to the specified host.
+
+  Uses a private key to establish an SSH connection to the bootstrap OS, and
+  return an SSHClient instance.
+
+  @type user: str
+  @param user: Username to use for connection.
+  @type host: str
+  @param host: Hostname of machine to connect to.
+  @type key: paramiko.PKey
+  @param key: Private key to use for authentication.
+
+  @rtype: paramiko.SSHClient
+  @returns: SSHClient object connected to a root shell on the target instance.
+  """
+
+  client = paramiko.SSHClient()
+  client.set_missing_host_key_policy(paramiko.WarningPolicy())
+  client.load_system_host_keys()
+  client.connect(host, username=user, pkey=key,
+                 allow_agent=False, look_for_keys=False)
+  return client
+
+
+def VerifyKernelMatches(client):
+  """Make sure the bootstrap kernel is installed on the source OS.
+
+  In order for the source OS to boot when transferred to the instance, it must
+  have kernel modules to support the kernel that ganeti will use to boot it.
+  For the time being, we ensure this by enforcing that the kernel running on
+  the bootstrap OS is also installed on the source OS before the transfer. We
+  check that the bootstrap OS's 'uname -r' is the name of a directory in
+  /source/lib/modules.
+
+  @type client: paramiko.SSHClient
+  @param client: SSH client object used to connect to the instance.
+  @rtype: bool
+  @returns: True if the proper kernel is installed, else False.
+
+  """
+  stdin, stdout, stderr = client.exec_command("uname -r")
+  kernel = stdout.read().strip()
+  return os.path.exists(os.path.join(SOURCE_MOUNT, "lib", "modules", kernel))
+
+
+def MountSourceFilesystems(root_dev):
+  """Mounts the filesystems of the source (physical) machine in /source.
+
+  Reads /etc/fstab and mounts all of the real filesystems it can, so
+  that the contents can be transferred to the target machine with one
+  rsync command.  Creates the /source dir if necessary, and checks to
+  make sure it"s empty (though it really should be, since we"re probably
+  running off LiveCD/PXE)
+
+  @type root_dev: str
+  @param root_dev: Name of the device holding the root filesystem of the
+    source OS
+
+  """
+  if not os.path.isdir(SOURCE_MOUNT):
+    os.mkdir(SOURCE_MOUNT)
+  try:
+    subprocess.check_call(["mount", root_dev, SOURCE_MOUNT])
+    # TODO(benlipton): mount other filesystems, if any
+  except (subprocess.CalledProcessError, OSError):
+    print "Error mounting %s" % root_dev
+    sys.exit(1)
+
+
+def ShutDownTarget(client):
+  """Shut down the target instance.
+
+  Sends an ssh command to shut down the instance.
+
+  @type client: paramiko.SSHClient
+  @param client: SSH client object used to connect to the instance.
+
+  """
+  _RunCommandAndWait(client, "poweroff")
+
+
+def _RunCommandAndWait(client, command):
+  """Send an SSH command and wait until it completes.
+
+  @type client: paramiko.SSHClient
+  @param client: SSH client object used to connect to the instance.
+  @type command: str
+  @param command: Command to send to the instance.
+  @raises P2VError: remote command returned nonzero exit status
+
+  """
+  stdin, stdout, stderr = client.exec_command(command)
+  if stdout.channel.recv_exit_status() != 0:
+    raise P2VError("Remote command returned nonzero exit status: %s" % command)
+
+
+def _WaitForCompletion(channel):
+  """Wait for a remote command to complete.
+
+  Helper function that sleeps until the last command run by the channel has
+  completed.
+
+  @type channel: paramiko.Channel
+  @param channel: The channel to wait for. If the command was run with
+    stdin, stdout, stderr = exec_command(), use stdout.channel.
+
+  """
+  while not channel.exit_status_ready():
+    time.sleep(.01)
+
+
+def PartitionTargetDisks(client, swap_cyls):
+  """Partition and format the disks on the target machine.
+
+  Sends commands over the SSH connection to partition and format the
+  disk of the target instance.
+
+  @type client: paramiko.SSHClient
+  @param client: SSH client object used to connect to the instance.
+  @type swap_cyls: int
+  @param swap_cyls: Desired size of swap space, in cylinders
+
+  """
+  # Find out how many cylinders are available on target
+  total_cyls = 0
+  stdin, stdout, stderr = client.exec_command("sfdisk -l /dev/xvda")
+  for line in stdout:
+    if line.startswith("Disk /dev/xvda:"):
+      words = line.split()
+      total_cyls = int(words[2])
+      break
+  stdout.close()
+
+  nonswap_cyls = total_cyls - swap_cyls
+  sfdisk_command = """sfdisk /dev/xvda <<EOF
+0,%d,83
+,,82
+EOF
+""" % nonswap_cyls
+
+  _RunCommandAndWait(client, sfdisk_command)
+
+  other_commands = [
+    "mkfs.ext3 /dev/xvda1",
+    "mkswap /dev/xvda2",
+    "mkdir /target",
+    "mount /dev/xvda1 /target",
+    ]
+
+  _RunCommandAndWait(client, " && ".join(other_commands))
+
+def TransferFiles(user, host, keyfile):
+  """Transfer files to the bootstrap OS.
+
+  Runs rsync to copy all files from the source filesystem to the target
+  filesystem.
+
+  @type user: str
+  @param user: Username to use for connection.
+  @type host: str
+  @param host: Hostname of instance to connect to.
+
+  """
+  try:
+    subprocess.check_call(["rsync", "-aHAXz", "-e", "ssh -i %s" % keyfile,
+                           "%s/" % SOURCE_MOUNT,
+                           "%s@%s:%s" % (user, host, TARGET_MOUNT)])
+  except subprocess.CalledProcessError:
+    print "Error using rsync to transfer files"
+    sys.exit(1)
+
+
+def RunFixScripts(client):
+  """Runs the post-transfer scripts on the bootstrap OS.
+
+  Sends a command to the instance to run the post-transfer scripts appropriate
+  to the target OS.
+
+  @type client: paramiko.SSHClient
+  @param client: SSH client object used to connect to the instance.
+
+  """
+  #commands = fix_scripts.GetCommandsForOS("debian")
+  #commands.run(client)
+  _RunCommandAndWait(client, "/usr/lib/ganeti/fixes/run_fixes.py")
+
+
+def UnmountSourceFilesystems():
+  """Undo mounts performed by MountSourceFilesystems.
+
+  Unmounts all filesystems mounted by MountSourceFilesystems. Currently, since
+  only the root filesystem ever gets mounted, this only unmounts the root
+  filesystem.
+
+  """
+  try:
+    subprocess.check_call(["umount", SOURCE_MOUNT])
+  except (subprocess.CalledProcessError, OSError):
+    print "Error unmounting %s" % SOURCE_MOUNT
+    sys.exit(1)
+
+
+def main(argv):
+  options, args = ParseOptions(argv)
+
+  user = "root"
+  root_dev, host, keyfile = args
+
+  try:
+    uid = os.getuid()
+    if uid != 0:
+      raise P2VError("Must be run as root")
+    key = LoadSSHKey(keyfile)
+    client = EstablishConnection(user, host, key)
+    MountSourceFilesystems(root_dev)
+    if VerifyKernelMatches(client):
+      PartitionTargetDisks(client, 10)
+      TransferFiles(user, host, keyfile)
+      RunFixScripts(client)
+      ShutDownTarget(client)
+    else:
+      raise P2VError("Instance kernel not present on source OS")
+  except P2VError, e:
+    print e
+    sys.exit(1)
+  finally:
+    if uid == 0:
+      UnmountSourceFilesystems()
+
+
+if __name__ == "__main__":
+  main(sys.argv)
diff --git a/p2v-transfer/test/p2v_transfer_test.py 
b/p2v-transfer/test/p2v_transfer_test.py
new file mode 100755
index 0000000..b1f64ab
--- /dev/null
+++ b/p2v-transfer/test/p2v_transfer_test.py
@@ -0,0 +1,227 @@
+#!/usr/bin/python
+#
+# Copyright 2011 Google Inc. All Rights Reserved.
+
+"""Tests for p2v_transfer."""
+
+
+import mox
+import paramiko
+import unittest
+
+import p2v_transfer
+
+
+class _MockChannelFile:
+  def __init__(self, mox_obj):
+    self.mox = mox_obj
+    self.channel = self.mox.CreateMock(paramiko.Channel)
+
+  def _SetOutput(self, outlist):
+    if isinstance(outlist, str):
+      outlist = [outlist]
+    self.outlist = outlist
+
+  def __getitem__(self, idx):
+    return self.outlist[idx]
+
+  def close(self):
+    pass
+
+
+class P2vtransferTest(unittest.TestCase):
+  def setUp(self):
+    self.mox = mox.Mox()
+
+    self.client = self.mox.CreateMock(paramiko.SSHClient)
+    self.module = p2v_transfer
+
+    self.root_dev = "/dev/sda1"
+    self.host = "testmachine"
+    self.pkeyfile = "/home/testuser/.ssh/id_dsa"
+    self.pkey = "thepkey"
+    self.test_argv = [
+      "p2v_transfer.py",
+      self.root_dev,
+      self.host,
+      self.pkeyfile,
+      ]
+
+  def _MockRunCommandAndWait(self, command, exit_status=0):
+    stdin = _MockChannelFile(self.mox)
+    stdout = _MockChannelFile(self.mox)
+    stderr = _MockChannelFile(self.mox)
+    self.client.exec_command(command).AndReturn((stdin, stdout, stderr))
+
+    stdout.channel.recv_exit_status().AndReturn(exit_status)
+
+    # return stdout in case we want to do something else with it
+    return stdout
+
+  def _MockSubprocessCallSuccess(self, command_list):
+    self.mox.StubOutWithMock(self.module.subprocess, "check_call")
+    self.module.subprocess.check_call(command_list).AndReturn(0)
+
+  def _MockSubprocessCallFailure(self, command_list):
+    subprocess = self.module.subprocess
+    self.mox.StubOutWithMock(subprocess, "check_call")
+    call = subprocess.check_call(command_list)
+    call.AndRaise(subprocess.CalledProcessError(1, " ".join(command_list)))
+
+  def _StubOutAllModuleFunctions(self):
+    self.module_functions = [
+      "EstablishConnection",
+      "PartitionTargetDisks",
+      "MountSourceFilesystems",
+      "TransferFiles",
+      "UnmountSourceFilesystems",
+      "RunFixScripts",
+      "ShutDownTarget",
+      "VerifyKernelMatches",
+      "LoadSSHKey",
+      ]
+    for func in self.module_functions:
+      self.mox.StubOutWithMock(self.module, func)
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testShutDownTargetSendsPoweroff(self):
+    self._MockRunCommandAndWait("poweroff")
+    self.mox.ReplayAll()
+    self.module.ShutDownTarget(self.client)
+    self.mox.VerifyAll()
+
+  def testPartitionTargetDisksSendsCommands(self):
+    swap_cyls = 10
+    tot_cyls = 600
+
+    sfdisk_output = "Disk /dev/xvda: %d cylinders, 255 heads, etc." % tot_cyls
+    stdout = _MockChannelFile(self.mox)
+    stdout._SetOutput(sfdisk_output)
+
+    self.client.exec_command("sfdisk -l /dev/xvda").AndReturn((None, stdout,
+                                                               None))
+
+    sfdisk_command = """sfdisk /dev/xvda <<EOF
+0,%d,83
+,,82
+EOF
+""" % (tot_cyls - swap_cyls)
+
+    self._MockRunCommandAndWait(sfdisk_command)
+
+    commands = ("mkfs.ext3 /dev/xvda1"
+                " && mkswap /dev/xvda2"
+                " && mkdir %s"
+                " && mount /dev/xvda1 %s") % (self.module.TARGET_MOUNT,
+                                              self.module.TARGET_MOUNT)
+    self._MockRunCommandAndWait(commands)
+
+    self.mox.ReplayAll()
+    self.module.PartitionTargetDisks(self.client, swap_cyls)
+    self.mox.VerifyAll()
+
+  def testTransferFilesExitsOnError(self):
+    user = "root"
+    host = "instance"
+    pkey = "keyfile"
+    command_list = ["rsync", "-aHAXz", "-e", "ssh -i %s" % pkey,
+                    "%s/" % self.module.SOURCE_MOUNT,
+                    "%s@%s:%s" % (user, host, self.module.TARGET_MOUNT)]
+    self._MockSubprocessCallFailure(command_list)
+    self.mox.ReplayAll()
+    self.assertRaises(SystemExit, self.module.TransferFiles, user, host, pkey)
+    self.mox.VerifyAll()
+
+  def testTransferFilesCallsRsync(self):
+    user = "root"
+    host = "instance"
+    pkey = "keyfile"
+    command_list = ["rsync", "-aHAXz", "-e", "ssh -i %s" % pkey,
+                    "%s/" % self.module.SOURCE_MOUNT,
+                    "%s@%s:%s" % (user, host, self.module.TARGET_MOUNT)]
+    self._MockSubprocessCallSuccess(command_list)
+    self.mox.ReplayAll()
+    self.module.TransferFiles(user, host, pkey)
+    self.mox.VerifyAll()
+
+  def testUnmountSourceFilesystemsExitsOnError(self):
+    command_list = ["umount", self.module.SOURCE_MOUNT]
+    self._MockSubprocessCallFailure(command_list)
+    self.mox.ReplayAll()
+    self.assertRaises(SystemExit, self.module.UnmountSourceFilesystems)
+    self.mox.VerifyAll()
+
+  def testUnmountSourceFilesystemsCallsUmount(self):
+    command_list = ["umount", self.module.SOURCE_MOUNT]
+    self._MockSubprocessCallSuccess(command_list)
+    self.mox.ReplayAll()
+    self.module.UnmountSourceFilesystems()
+    self.mox.VerifyAll()
+
+  def testMainRunsAllFunctions(self):
+    self.mox.StubOutWithMock(self.module.os, "getuid")
+    self._StubOutAllModuleFunctions()
+
+    self.module.os.getuid().AndReturn(0)  # Wants to run as root
+    self.module.LoadSSHKey(self.pkeyfile).AndReturn(self.pkey)
+    self.module.EstablishConnection("root",
+                                    self.host,
+                                    self.pkey).AndReturn(self.client)
+    self.module.MountSourceFilesystems(self.root_dev)
+    self.module.VerifyKernelMatches(self.client).AndReturn(True)
+    self.module.PartitionTargetDisks(self.client, 10)
+    self.module.TransferFiles("root", self.host, self.pkeyfile)
+    self.module.RunFixScripts(self.client)
+    self.module.ShutDownTarget(self.client)
+    self.module.UnmountSourceFilesystems()
+
+    self.mox.ReplayAll()
+    self.module.main(self.test_argv)
+    self.mox.VerifyAll()
+
+  def testMainQuitsIfNotRunAsRoot(self):
+    self.mox.StubOutWithMock(self.module.os, "getuid")
+    self._StubOutAllModuleFunctions()
+
+    self.module.os.getuid().AndReturn(500)
+
+    self.mox.ReplayAll()
+    self.assertRaises(SystemExit, self.module.main, self.test_argv)
+    self.mox.VerifyAll()
+
+  def testRunFixScriptsReportsFailure(self):
+    # Run the command, but have it exit with error
+    self._MockRunCommandAndWait("/usr/lib/ganeti/fixes/run_fixes.py", 1)
+
+    self.mox.ReplayAll()
+    self.assertRaises(self.module.P2VError,
+                      self.module.RunFixScripts, self.client)
+    self.mox.VerifyAll()
+
+  def testMainUnmountsSourceOnTransferFailure(self):
+    """Even if some part of the transfer fails, /source should be unmounted."""
+    self.mox.StubOutWithMock(self.module.os, "getuid")
+    self._StubOutAllModuleFunctions()
+
+    self.module.os.getuid().AndReturn(0)  # Wants to run as root
+    self.module.LoadSSHKey(self.pkeyfile).AndReturn(self.pkey)
+    self.module.EstablishConnection("root",
+                                    self.host,
+                                    self.pkey).AndReturn(self.client)
+    self.module.MountSourceFilesystems(self.root_dev)
+    self.module.VerifyKernelMatches(self.client).AndReturn(True)
+    call = self.module.PartitionTargetDisks(self.client, 10)
+    call.AndRaise(self.module.P2VError("meep"))
+    # Transfer is cancelled because of the error, but still we have:
+    self.module.UnmountSourceFilesystems()
+
+    self.mox.ReplayAll()
+    self.assertRaises(SystemExit, self.module.main, self.test_argv)
+    self.mox.VerifyAll()
+
+
+if __name__ == "__main__":
+  unittest.main()
-- 
1.7.3.1

Reply via email to