diff --git a/.gitignore b/.gitignore index a71747c801ac4b08988af303054c5f7b2ea9599e..3a655148c92f0bca3e724fc01904af716685e1db 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,7 @@ /tools/kvm-ifup /tools/ensure-dirs /tools/vcluster-setup +/tools/node-daemon-setup /tools/prepare-node-join # scripts diff --git a/Makefile.am b/Makefile.am index 24f6f22ec5a7db4a0dda9999eaf3b854ecc1f639..67f469fd41774ccd016e4d7620a19608ff23c6d8 100644 --- a/Makefile.am +++ b/Makefile.am @@ -332,6 +332,7 @@ server_PYTHON = \ pytools_PYTHON = \ lib/tools/__init__.py \ lib/tools/ensure_dirs.py \ + lib/tools/node_daemon_setup.py \ lib/tools/prepare_node_join.py utils_PYTHON = \ @@ -616,6 +617,7 @@ PYTHON_BOOTSTRAP_SBIN = \ PYTHON_BOOTSTRAP = \ $(PYTHON_BOOTSTRAP_SBIN) \ tools/ensure-dirs \ + tools/node-daemon-setup \ tools/prepare-node-join qa_scripts = \ @@ -727,6 +729,7 @@ pkglib_python_scripts = \ nodist_pkglib_python_scripts = \ tools/ensure-dirs \ + tools/node-daemon-setup \ tools/prepare-node-join myexeclib_SCRIPTS = \ @@ -970,6 +973,7 @@ python_tests = \ test/ganeti.ssh_unittest.py \ test/ganeti.storage_unittest.py \ test/ganeti.tools.ensure_dirs_unittest.py \ + test/ganeti.tools.node_daemon_setup_unittest.py \ test/ganeti.tools.prepare_node_join_unittest.py \ test/ganeti.uidpool_unittest.py \ test/ganeti.utils.algo_unittest.py \ @@ -1373,6 +1377,7 @@ daemons/ganeti-%: MODULE = ganeti.server.$(patsubst ganeti-%,%,$(notdir $@)) daemons/ganeti-watcher: MODULE = ganeti.watcher scripts/%: MODULE = ganeti.client.$(subst -,_,$(notdir $@)) tools/ensure-dirs: MODULE = ganeti.tools.ensure_dirs +tools/node-daemon-setup: MODULE = ganeti.tools.node_daemon_setup tools/prepare-node-join: MODULE = ganeti.tools.prepare_node_join $(HS_BUILT_TEST_HELPERS): TESTROLE = $(patsubst htest/%,%,$@) diff --git a/lib/constants.py b/lib/constants.py index 180247f1d5422af95ffdddd7d5e0db200ed933cc..ccfa24a78a5104d638f1cca024c1ac9d6b19f7ca 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -2100,5 +2100,11 @@ SSH_DAEMON_KEYFILES = { SSHK_DSA: (pathutils.SSH_HOST_DSA_PRIV, pathutils.SSH_HOST_DSA_PUB), } +# Node daemon setup +NDS_CLUSTER_NAME = "cluster_name" +NDS_NODE_DAEMON_CERTIFICATE = "node_daemon_certificate" +NDS_SSCONF = "ssconf" +NDS_START_NODE_DAEMON = "start_node_daemon" + # Do not re-export imported modules del re, _vcsversion, _autoconf, socket, pathutils diff --git a/lib/tools/node_daemon_setup.py b/lib/tools/node_daemon_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..28bcba0de4d567ec2f899ae0079ce6940b072c6e --- /dev/null +++ b/lib/tools/node_daemon_setup.py @@ -0,0 +1,236 @@ +# +# + +# Copyright (C) 2012 Google Inc. +# +# 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. + +"""Script to configure the node daemon. + +""" + +import os +import os.path +import optparse +import sys +import logging +import OpenSSL +from cStringIO import StringIO + +from ganeti import cli +from ganeti import constants +from ganeti import errors +from ganeti import pathutils +from ganeti import utils +from ganeti import serializer +from ganeti import runtime +from ganeti import ht +from ganeti import ssconf + + +_DATA_CHECK = ht.TStrictDict(False, True, { + constants.NDS_CLUSTER_NAME: ht.TNonEmptyString, + constants.NDS_NODE_DAEMON_CERTIFICATE: ht.TNonEmptyString, + constants.NDS_SSCONF: ht.TDictOf(ht.TNonEmptyString, ht.TString), + constants.NDS_START_NODE_DAEMON: ht.TBool, + }) + + +class SetupError(errors.GenericError): + """Local class for reporting errors. + + """ + + +def ParseOptions(): + """Parses the options passed to the program. + + @return: Options and arguments + + """ + parser = optparse.OptionParser(usage="%prog [--dry-run]", + prog=os.path.basename(sys.argv[0])) + parser.add_option(cli.DEBUG_OPT) + parser.add_option(cli.VERBOSE_OPT) + parser.add_option(cli.DRY_RUN_OPT) + + (opts, args) = parser.parse_args() + + return VerifyOptions(parser, opts, args) + + +def VerifyOptions(parser, opts, args): + """Verifies options and arguments for correctness. + + """ + if args: + parser.error("No arguments are expected") + + return opts + + +def _VerifyCertificate(cert_pem, _check_fn=utils.CheckNodeCertificate): + """Verifies a certificate against the local node daemon certificate. + + @type cert_pem: string + @param cert_pem: Certificate and key in PEM format + @rtype: string + @return: Formatted key and certificate + + """ + try: + cert = \ + OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert_pem) + except Exception, err: + raise errors.X509CertError("(stdin)", + "Unable to load certificate: %s" % err) + + try: + key = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, cert_pem) + except OpenSSL.crypto.Error, err: + raise errors.X509CertError("(stdin)", + "Unable to load private key: %s" % err) + + # Check certificate with given key; this detects cases where the key given on + # stdin doesn't match the certificate also given on stdin + x509_check_fn = utils.PrepareX509CertKeyCheck(cert, key) + try: + x509_check_fn() + except OpenSSL.SSL.Error: + raise errors.X509CertError("(stdin)", + "Certificate is not signed with given key") + + # Standard checks, including check against an existing local certificate + # (no-op if that doesn't exist) + _check_fn(cert) + + # Format for storing on disk + buf = StringIO() + buf.write(OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key)) + buf.write(OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)) + return buf.getvalue() + + +def VerifyCertificate(data, _verify_fn=_VerifyCertificate): + """Verifies cluster certificate. + + @type data: dict + @rtype: string + @return: Formatted key and certificate + + """ + cert = data.get(constants.NDS_NODE_DAEMON_CERTIFICATE) + if not cert: + raise SetupError("Node daemon certificate must be specified") + + return _verify_fn(cert) + + +def VerifyClusterName(data, _verify_fn=ssconf.VerifyClusterName): + """Verifies cluster name. + + @type data: dict + @rtype: string + @return: Cluster name + + """ + name = data.get(constants.NDS_CLUSTER_NAME) + if not name: + raise SetupError("Cluster name must be specified") + + _verify_fn(name) + + return name + + +def VerifySsconf(data, cluster_name, _verify_fn=ssconf.VerifyKeys): + """Verifies ssconf names. + + @type data: dict + + """ + items = data.get(constants.NDS_SSCONF) + + if not items: + raise SetupError("Ssconf values must be specified") + + # TODO: Should all keys be required? Right now any subset of valid keys is + # accepted. + _verify_fn(items.keys()) + + if items.get(constants.SS_CLUSTER_NAME) != cluster_name: + raise SetupError("Cluster name in ssconf does not match") + + return items + + +def LoadData(raw): + """Parses and verifies input data. + + @rtype: dict + + """ + return serializer.LoadAndVerifyJson(raw, _DATA_CHECK) + + +def Main(): + """Main routine. + + """ + opts = ParseOptions() + + utils.SetupToolLogging(opts.debug, opts.verbose) + + try: + getent = runtime.GetEnts() + + data = LoadData(sys.stdin.read()) + + cluster_name = VerifyClusterName(data) + cert_pem = VerifyCertificate(data) + ssdata = VerifySsconf(data, cluster_name) + + logging.info("Writing ssconf files ...") + ssconf.WriteSsconfFiles(ssdata, dry_run=opts.dry_run) + + logging.info("Writing node daemon certificate ...") + utils.WriteFile(pathutils.NODED_CERT_FILE, data=cert_pem, + mode=pathutils.NODED_CERT_MODE, + uid=getent.masterd_uid, gid=getent.masterd_gid, + dry_run=opts.dry_run) + + if (data.get(constants.NDS_START_NODE_DAEMON) and # pylint: disable=E1103 + not opts.dry_run): + logging.info("Restarting node daemon ...") + + cmd = ("%s stop-all; %s start %s" % + (pathutils.DAEMON_UTIL, pathutils.DAEMON_UTIL, constants.NODED)) + + result = utils.RunCmd(cmd, interactive=True) + if result.failed: + raise SetupError("Could not start the node daemon, command '%s'" + " failed: %s" % (result.cmd, result.fail_reason)) + + logging.info("Node daemon successfully configured") + except Exception, err: # pylint: disable=W0703 + logging.debug("Caught unhandled exception", exc_info=True) + + (retcode, message) = cli.FormatError(err) + logging.error(message) + + return retcode + else: + return constants.EXIT_SUCCESS diff --git a/test/ganeti.tools.node_daemon_setup_unittest.py b/test/ganeti.tools.node_daemon_setup_unittest.py new file mode 100755 index 0000000000000000000000000000000000000000..dc05fe045c320fd63a5a3bc046deefd1c333f5a1 --- /dev/null +++ b/test/ganeti.tools.node_daemon_setup_unittest.py @@ -0,0 +1,211 @@ +#!/usr/bin/python +# + +# Copyright (C) 2012 Google Inc. +# +# 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. + + +"""Script for testing ganeti.tools.node_daemon_setup""" + +import unittest +import shutil +import tempfile +import os.path +import OpenSSL + +from ganeti import errors +from ganeti import constants +from ganeti import serializer +from ganeti import pathutils +from ganeti import compat +from ganeti import utils +from ganeti.tools import node_daemon_setup + +import testutils + + +_SetupError = node_daemon_setup.SetupError + + +class TestLoadData(unittest.TestCase): + def testNoJson(self): + for data in ["", "{", "}"]: + self.assertRaises(errors.ParseError, node_daemon_setup.LoadData, data) + + def testInvalidDataStructure(self): + raw = serializer.DumpJson({ + "some other thing": False, + }) + self.assertRaises(errors.ParseError, node_daemon_setup.LoadData, raw) + + raw = serializer.DumpJson([]) + self.assertRaises(errors.ParseError, node_daemon_setup.LoadData, raw) + + def testValidData(self): + raw = serializer.DumpJson({}) + self.assertEqual(node_daemon_setup.LoadData(raw), {}) + + +class TestVerifyCertificate(testutils.GanetiTestCase): + def setUp(self): + testutils.GanetiTestCase.setUp(self) + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + testutils.GanetiTestCase.tearDown(self) + shutil.rmtree(self.tmpdir) + + def testNoCert(self): + self.assertRaises(_SetupError, node_daemon_setup.VerifyCertificate, + {}, _verify_fn=NotImplemented) + + def testVerificationSuccessWithCert(self): + node_daemon_setup.VerifyCertificate({ + constants.NDS_NODE_DAEMON_CERTIFICATE: "something", + }, _verify_fn=lambda _: None) + + def testNoPrivateKey(self): + cert_filename = self._TestDataFilename("cert1.pem") + cert_pem = utils.ReadFile(cert_filename) + + self.assertRaises(errors.X509CertError, + node_daemon_setup._VerifyCertificate, + cert_pem, _check_fn=NotImplemented) + + def testInvalidCertificate(self): + self.assertRaises(errors.X509CertError, + node_daemon_setup._VerifyCertificate, + "Something that's not a certificate", + _check_fn=NotImplemented) + + @staticmethod + def _Check(cert): + assert cert.get_subject() + + def testSuccessfulCheck(self): + cert_filename = self._TestDataFilename("cert2.pem") + cert_pem = utils.ReadFile(cert_filename) + result = \ + node_daemon_setup._VerifyCertificate(cert_pem, _check_fn=self._Check) + self.assertTrue("-----BEGIN PRIVATE KEY-----" in result) + self.assertTrue("-----BEGIN CERTIFICATE-----" in result) + + def testMismatchingKey(self): + cert1_path = self._TestDataFilename("cert1.pem") + cert2_path = self._TestDataFilename("cert2.pem") + + # Extract certificate + cert1 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + utils.ReadFile(cert1_path)) + cert1_pem = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, + cert1) + + # Extract mismatching key + key2 = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, + utils.ReadFile(cert2_path)) + key2_pem = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, + key2) + + try: + node_daemon_setup._VerifyCertificate(cert1_pem + key2_pem, + _check_fn=NotImplemented) + except errors.X509CertError, err: + self.assertEqual(err.args, + ("(stdin)", "Certificate is not signed with given key")) + else: + self.fail("Exception was not raised") + + +class TestVerifyClusterName(unittest.TestCase): + def setUp(self): + unittest.TestCase.setUp(self) + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + unittest.TestCase.tearDown(self) + shutil.rmtree(self.tmpdir) + + def testNoName(self): + self.assertRaises(_SetupError, node_daemon_setup.VerifyClusterName, + {}, _verify_fn=NotImplemented) + + @staticmethod + def _FailingVerify(name): + assert name == "somecluster.example.com" + raise errors.GenericError() + + def testFailingVerification(self): + data = { + constants.NDS_CLUSTER_NAME: "somecluster.example.com", + } + + self.assertRaises(errors.GenericError, node_daemon_setup.VerifyClusterName, + data, _verify_fn=self._FailingVerify) + + def testSuccess(self): + data = { + constants.NDS_CLUSTER_NAME: "cluster.example.com", + } + + result = \ + node_daemon_setup.VerifyClusterName(data, _verify_fn=lambda _: None) + + self.assertEqual(result, "cluster.example.com") + + +class TestVerifySsconf(unittest.TestCase): + def testNoSsconf(self): + self.assertRaises(_SetupError, node_daemon_setup.VerifySsconf, + {}, NotImplemented, _verify_fn=NotImplemented) + + for items in [None, {}]: + self.assertRaises(_SetupError, node_daemon_setup.VerifySsconf, { + constants.NDS_SSCONF: items, + }, NotImplemented, _verify_fn=NotImplemented) + + def _Check(self, names): + self.assertEqual(frozenset(names), frozenset([ + constants.SS_CLUSTER_NAME, + constants.SS_INSTANCE_LIST, + ])) + + def testSuccess(self): + ssdata = { + constants.SS_CLUSTER_NAME: "cluster.example.com", + constants.SS_INSTANCE_LIST: [], + } + + result = node_daemon_setup.VerifySsconf({ + constants.NDS_SSCONF: ssdata, + }, "cluster.example.com", _verify_fn=self._Check) + + self.assertEqual(result, ssdata) + + self.assertRaises(_SetupError, node_daemon_setup.VerifySsconf, { + constants.NDS_SSCONF: ssdata, + }, "wrong.example.com", _verify_fn=self._Check) + + def testInvalidKey(self): + self.assertRaises(errors.GenericError, node_daemon_setup.VerifySsconf, { + constants.NDS_SSCONF: { + "no-valid-ssconf-key": "value", + }, + }, NotImplemented) + + +if __name__ == "__main__": + testutils.GanetiTestProgram()