Revision 69e5fefc
b/.gitignore | ||
---|---|---|
95 | 95 |
/tools/kvm-ifup |
96 | 96 |
/tools/ensure-dirs |
97 | 97 |
/tools/vcluster-setup |
98 |
/tools/node-daemon-setup |
|
98 | 99 |
/tools/prepare-node-join |
99 | 100 |
|
100 | 101 |
# scripts |
b/Makefile.am | ||
---|---|---|
332 | 332 |
pytools_PYTHON = \ |
333 | 333 |
lib/tools/__init__.py \ |
334 | 334 |
lib/tools/ensure_dirs.py \ |
335 |
lib/tools/node_daemon_setup.py \ |
|
335 | 336 |
lib/tools/prepare_node_join.py |
336 | 337 |
|
337 | 338 |
utils_PYTHON = \ |
... | ... | |
616 | 617 |
PYTHON_BOOTSTRAP = \ |
617 | 618 |
$(PYTHON_BOOTSTRAP_SBIN) \ |
618 | 619 |
tools/ensure-dirs \ |
620 |
tools/node-daemon-setup \ |
|
619 | 621 |
tools/prepare-node-join |
620 | 622 |
|
621 | 623 |
qa_scripts = \ |
... | ... | |
727 | 729 |
|
728 | 730 |
nodist_pkglib_python_scripts = \ |
729 | 731 |
tools/ensure-dirs \ |
732 |
tools/node-daemon-setup \ |
|
730 | 733 |
tools/prepare-node-join |
731 | 734 |
|
732 | 735 |
myexeclib_SCRIPTS = \ |
... | ... | |
970 | 973 |
test/ganeti.ssh_unittest.py \ |
971 | 974 |
test/ganeti.storage_unittest.py \ |
972 | 975 |
test/ganeti.tools.ensure_dirs_unittest.py \ |
976 |
test/ganeti.tools.node_daemon_setup_unittest.py \ |
|
973 | 977 |
test/ganeti.tools.prepare_node_join_unittest.py \ |
974 | 978 |
test/ganeti.uidpool_unittest.py \ |
975 | 979 |
test/ganeti.utils.algo_unittest.py \ |
... | ... | |
1373 | 1377 |
daemons/ganeti-watcher: MODULE = ganeti.watcher |
1374 | 1378 |
scripts/%: MODULE = ganeti.client.$(subst -,_,$(notdir $@)) |
1375 | 1379 |
tools/ensure-dirs: MODULE = ganeti.tools.ensure_dirs |
1380 |
tools/node-daemon-setup: MODULE = ganeti.tools.node_daemon_setup |
|
1376 | 1381 |
tools/prepare-node-join: MODULE = ganeti.tools.prepare_node_join |
1377 | 1382 |
$(HS_BUILT_TEST_HELPERS): TESTROLE = $(patsubst htest/%,%,$@) |
1378 | 1383 |
|
b/lib/constants.py | ||
---|---|---|
2100 | 2100 |
SSHK_DSA: (pathutils.SSH_HOST_DSA_PRIV, pathutils.SSH_HOST_DSA_PUB), |
2101 | 2101 |
} |
2102 | 2102 |
|
2103 |
# Node daemon setup |
|
2104 |
NDS_CLUSTER_NAME = "cluster_name" |
|
2105 |
NDS_NODE_DAEMON_CERTIFICATE = "node_daemon_certificate" |
|
2106 |
NDS_SSCONF = "ssconf" |
|
2107 |
NDS_START_NODE_DAEMON = "start_node_daemon" |
|
2108 |
|
|
2103 | 2109 |
# Do not re-export imported modules |
2104 | 2110 |
del re, _vcsversion, _autoconf, socket, pathutils |
b/lib/tools/node_daemon_setup.py | ||
---|---|---|
1 |
# |
|
2 |
# |
|
3 |
|
|
4 |
# Copyright (C) 2012 Google Inc. |
|
5 |
# |
|
6 |
# This program is free software; you can redistribute it and/or modify |
|
7 |
# it under the terms of the GNU General Public License as published by |
|
8 |
# the Free Software Foundation; either version 2 of the License, or |
|
9 |
# (at your option) any later version. |
|
10 |
# |
|
11 |
# This program is distributed in the hope that it will be useful, but |
|
12 |
# WITHOUT ANY WARRANTY; without even the implied warranty of |
|
13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
|
14 |
# General Public License for more details. |
|
15 |
# |
|
16 |
# You should have received a copy of the GNU General Public License |
|
17 |
# along with this program; if not, write to the Free Software |
|
18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA |
|
19 |
# 02110-1301, USA. |
|
20 |
|
|
21 |
"""Script to configure the node daemon. |
|
22 |
|
|
23 |
""" |
|
24 |
|
|
25 |
import os |
|
26 |
import os.path |
|
27 |
import optparse |
|
28 |
import sys |
|
29 |
import logging |
|
30 |
import OpenSSL |
|
31 |
from cStringIO import StringIO |
|
32 |
|
|
33 |
from ganeti import cli |
|
34 |
from ganeti import constants |
|
35 |
from ganeti import errors |
|
36 |
from ganeti import pathutils |
|
37 |
from ganeti import utils |
|
38 |
from ganeti import serializer |
|
39 |
from ganeti import runtime |
|
40 |
from ganeti import ht |
|
41 |
from ganeti import ssconf |
|
42 |
|
|
43 |
|
|
44 |
_DATA_CHECK = ht.TStrictDict(False, True, { |
|
45 |
constants.NDS_CLUSTER_NAME: ht.TNonEmptyString, |
|
46 |
constants.NDS_NODE_DAEMON_CERTIFICATE: ht.TNonEmptyString, |
|
47 |
constants.NDS_SSCONF: ht.TDictOf(ht.TNonEmptyString, ht.TString), |
|
48 |
constants.NDS_START_NODE_DAEMON: ht.TBool, |
|
49 |
}) |
|
50 |
|
|
51 |
|
|
52 |
class SetupError(errors.GenericError): |
|
53 |
"""Local class for reporting errors. |
|
54 |
|
|
55 |
""" |
|
56 |
|
|
57 |
|
|
58 |
def ParseOptions(): |
|
59 |
"""Parses the options passed to the program. |
|
60 |
|
|
61 |
@return: Options and arguments |
|
62 |
|
|
63 |
""" |
|
64 |
parser = optparse.OptionParser(usage="%prog [--dry-run]", |
|
65 |
prog=os.path.basename(sys.argv[0])) |
|
66 |
parser.add_option(cli.DEBUG_OPT) |
|
67 |
parser.add_option(cli.VERBOSE_OPT) |
|
68 |
parser.add_option(cli.DRY_RUN_OPT) |
|
69 |
|
|
70 |
(opts, args) = parser.parse_args() |
|
71 |
|
|
72 |
return VerifyOptions(parser, opts, args) |
|
73 |
|
|
74 |
|
|
75 |
def VerifyOptions(parser, opts, args): |
|
76 |
"""Verifies options and arguments for correctness. |
|
77 |
|
|
78 |
""" |
|
79 |
if args: |
|
80 |
parser.error("No arguments are expected") |
|
81 |
|
|
82 |
return opts |
|
83 |
|
|
84 |
|
|
85 |
def _VerifyCertificate(cert_pem, _check_fn=utils.CheckNodeCertificate): |
|
86 |
"""Verifies a certificate against the local node daemon certificate. |
|
87 |
|
|
88 |
@type cert_pem: string |
|
89 |
@param cert_pem: Certificate and key in PEM format |
|
90 |
@rtype: string |
|
91 |
@return: Formatted key and certificate |
|
92 |
|
|
93 |
""" |
|
94 |
try: |
|
95 |
cert = \ |
|
96 |
OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert_pem) |
|
97 |
except Exception, err: |
|
98 |
raise errors.X509CertError("(stdin)", |
|
99 |
"Unable to load certificate: %s" % err) |
|
100 |
|
|
101 |
try: |
|
102 |
key = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, cert_pem) |
|
103 |
except OpenSSL.crypto.Error, err: |
|
104 |
raise errors.X509CertError("(stdin)", |
|
105 |
"Unable to load private key: %s" % err) |
|
106 |
|
|
107 |
# Check certificate with given key; this detects cases where the key given on |
|
108 |
# stdin doesn't match the certificate also given on stdin |
|
109 |
x509_check_fn = utils.PrepareX509CertKeyCheck(cert, key) |
|
110 |
try: |
|
111 |
x509_check_fn() |
|
112 |
except OpenSSL.SSL.Error: |
|
113 |
raise errors.X509CertError("(stdin)", |
|
114 |
"Certificate is not signed with given key") |
|
115 |
|
|
116 |
# Standard checks, including check against an existing local certificate |
|
117 |
# (no-op if that doesn't exist) |
|
118 |
_check_fn(cert) |
|
119 |
|
|
120 |
# Format for storing on disk |
|
121 |
buf = StringIO() |
|
122 |
buf.write(OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key)) |
|
123 |
buf.write(OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)) |
|
124 |
return buf.getvalue() |
|
125 |
|
|
126 |
|
|
127 |
def VerifyCertificate(data, _verify_fn=_VerifyCertificate): |
|
128 |
"""Verifies cluster certificate. |
|
129 |
|
|
130 |
@type data: dict |
|
131 |
@rtype: string |
|
132 |
@return: Formatted key and certificate |
|
133 |
|
|
134 |
""" |
|
135 |
cert = data.get(constants.NDS_NODE_DAEMON_CERTIFICATE) |
|
136 |
if not cert: |
|
137 |
raise SetupError("Node daemon certificate must be specified") |
|
138 |
|
|
139 |
return _verify_fn(cert) |
|
140 |
|
|
141 |
|
|
142 |
def VerifyClusterName(data, _verify_fn=ssconf.VerifyClusterName): |
|
143 |
"""Verifies cluster name. |
|
144 |
|
|
145 |
@type data: dict |
|
146 |
@rtype: string |
|
147 |
@return: Cluster name |
|
148 |
|
|
149 |
""" |
|
150 |
name = data.get(constants.NDS_CLUSTER_NAME) |
|
151 |
if not name: |
|
152 |
raise SetupError("Cluster name must be specified") |
|
153 |
|
|
154 |
_verify_fn(name) |
|
155 |
|
|
156 |
return name |
|
157 |
|
|
158 |
|
|
159 |
def VerifySsconf(data, cluster_name, _verify_fn=ssconf.VerifyKeys): |
|
160 |
"""Verifies ssconf names. |
|
161 |
|
|
162 |
@type data: dict |
|
163 |
|
|
164 |
""" |
|
165 |
items = data.get(constants.NDS_SSCONF) |
|
166 |
|
|
167 |
if not items: |
|
168 |
raise SetupError("Ssconf values must be specified") |
|
169 |
|
|
170 |
# TODO: Should all keys be required? Right now any subset of valid keys is |
|
171 |
# accepted. |
|
172 |
_verify_fn(items.keys()) |
|
173 |
|
|
174 |
if items.get(constants.SS_CLUSTER_NAME) != cluster_name: |
|
175 |
raise SetupError("Cluster name in ssconf does not match") |
|
176 |
|
|
177 |
return items |
|
178 |
|
|
179 |
|
|
180 |
def LoadData(raw): |
|
181 |
"""Parses and verifies input data. |
|
182 |
|
|
183 |
@rtype: dict |
|
184 |
|
|
185 |
""" |
|
186 |
return serializer.LoadAndVerifyJson(raw, _DATA_CHECK) |
|
187 |
|
|
188 |
|
|
189 |
def Main(): |
|
190 |
"""Main routine. |
|
191 |
|
|
192 |
""" |
|
193 |
opts = ParseOptions() |
|
194 |
|
|
195 |
utils.SetupToolLogging(opts.debug, opts.verbose) |
|
196 |
|
|
197 |
try: |
|
198 |
getent = runtime.GetEnts() |
|
199 |
|
|
200 |
data = LoadData(sys.stdin.read()) |
|
201 |
|
|
202 |
cluster_name = VerifyClusterName(data) |
|
203 |
cert_pem = VerifyCertificate(data) |
|
204 |
ssdata = VerifySsconf(data, cluster_name) |
|
205 |
|
|
206 |
logging.info("Writing ssconf files ...") |
|
207 |
ssconf.WriteSsconfFiles(ssdata, dry_run=opts.dry_run) |
|
208 |
|
|
209 |
logging.info("Writing node daemon certificate ...") |
|
210 |
utils.WriteFile(pathutils.NODED_CERT_FILE, data=cert_pem, |
|
211 |
mode=pathutils.NODED_CERT_MODE, |
|
212 |
uid=getent.masterd_uid, gid=getent.masterd_gid, |
|
213 |
dry_run=opts.dry_run) |
|
214 |
|
|
215 |
if (data.get(constants.NDS_START_NODE_DAEMON) and # pylint: disable=E1103 |
|
216 |
not opts.dry_run): |
|
217 |
logging.info("Restarting node daemon ...") |
|
218 |
|
|
219 |
cmd = ("%s stop-all; %s start %s" % |
|
220 |
(pathutils.DAEMON_UTIL, pathutils.DAEMON_UTIL, constants.NODED)) |
|
221 |
|
|
222 |
result = utils.RunCmd(cmd, interactive=True) |
|
223 |
if result.failed: |
|
224 |
raise SetupError("Could not start the node daemon, command '%s'" |
|
225 |
" failed: %s" % (result.cmd, result.fail_reason)) |
|
226 |
|
|
227 |
logging.info("Node daemon successfully configured") |
|
228 |
except Exception, err: # pylint: disable=W0703 |
|
229 |
logging.debug("Caught unhandled exception", exc_info=True) |
|
230 |
|
|
231 |
(retcode, message) = cli.FormatError(err) |
|
232 |
logging.error(message) |
|
233 |
|
|
234 |
return retcode |
|
235 |
else: |
|
236 |
return constants.EXIT_SUCCESS |
b/test/ganeti.tools.node_daemon_setup_unittest.py | ||
---|---|---|
1 |
#!/usr/bin/python |
|
2 |
# |
|
3 |
|
|
4 |
# Copyright (C) 2012 Google Inc. |
|
5 |
# |
|
6 |
# This program is free software; you can redistribute it and/or modify |
|
7 |
# it under the terms of the GNU General Public License as published by |
|
8 |
# the Free Software Foundation; either version 2 of the License, or |
|
9 |
# (at your option) any later version. |
|
10 |
# |
|
11 |
# This program is distributed in the hope that it will be useful, but |
|
12 |
# WITHOUT ANY WARRANTY; without even the implied warranty of |
|
13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
|
14 |
# General Public License for more details. |
|
15 |
# |
|
16 |
# You should have received a copy of the GNU General Public License |
|
17 |
# along with this program; if not, write to the Free Software |
|
18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA |
|
19 |
# 02110-1301, USA. |
|
20 |
|
|
21 |
|
|
22 |
"""Script for testing ganeti.tools.node_daemon_setup""" |
|
23 |
|
|
24 |
import unittest |
|
25 |
import shutil |
|
26 |
import tempfile |
|
27 |
import os.path |
|
28 |
import OpenSSL |
|
29 |
|
|
30 |
from ganeti import errors |
|
31 |
from ganeti import constants |
|
32 |
from ganeti import serializer |
|
33 |
from ganeti import pathutils |
|
34 |
from ganeti import compat |
|
35 |
from ganeti import utils |
|
36 |
from ganeti.tools import node_daemon_setup |
|
37 |
|
|
38 |
import testutils |
|
39 |
|
|
40 |
|
|
41 |
_SetupError = node_daemon_setup.SetupError |
|
42 |
|
|
43 |
|
|
44 |
class TestLoadData(unittest.TestCase): |
|
45 |
def testNoJson(self): |
|
46 |
for data in ["", "{", "}"]: |
|
47 |
self.assertRaises(errors.ParseError, node_daemon_setup.LoadData, data) |
|
48 |
|
|
49 |
def testInvalidDataStructure(self): |
|
50 |
raw = serializer.DumpJson({ |
|
51 |
"some other thing": False, |
|
52 |
}) |
|
53 |
self.assertRaises(errors.ParseError, node_daemon_setup.LoadData, raw) |
|
54 |
|
|
55 |
raw = serializer.DumpJson([]) |
|
56 |
self.assertRaises(errors.ParseError, node_daemon_setup.LoadData, raw) |
|
57 |
|
|
58 |
def testValidData(self): |
|
59 |
raw = serializer.DumpJson({}) |
|
60 |
self.assertEqual(node_daemon_setup.LoadData(raw), {}) |
|
61 |
|
|
62 |
|
|
63 |
class TestVerifyCertificate(testutils.GanetiTestCase): |
|
64 |
def setUp(self): |
|
65 |
testutils.GanetiTestCase.setUp(self) |
|
66 |
self.tmpdir = tempfile.mkdtemp() |
|
67 |
|
|
68 |
def tearDown(self): |
|
69 |
testutils.GanetiTestCase.tearDown(self) |
|
70 |
shutil.rmtree(self.tmpdir) |
|
71 |
|
|
72 |
def testNoCert(self): |
|
73 |
self.assertRaises(_SetupError, node_daemon_setup.VerifyCertificate, |
|
74 |
{}, _verify_fn=NotImplemented) |
|
75 |
|
|
76 |
def testVerificationSuccessWithCert(self): |
|
77 |
node_daemon_setup.VerifyCertificate({ |
|
78 |
constants.NDS_NODE_DAEMON_CERTIFICATE: "something", |
|
79 |
}, _verify_fn=lambda _: None) |
|
80 |
|
|
81 |
def testNoPrivateKey(self): |
|
82 |
cert_filename = self._TestDataFilename("cert1.pem") |
|
83 |
cert_pem = utils.ReadFile(cert_filename) |
|
84 |
|
|
85 |
self.assertRaises(errors.X509CertError, |
|
86 |
node_daemon_setup._VerifyCertificate, |
|
87 |
cert_pem, _check_fn=NotImplemented) |
|
88 |
|
|
89 |
def testInvalidCertificate(self): |
|
90 |
self.assertRaises(errors.X509CertError, |
|
91 |
node_daemon_setup._VerifyCertificate, |
|
92 |
"Something that's not a certificate", |
|
93 |
_check_fn=NotImplemented) |
|
94 |
|
|
95 |
@staticmethod |
|
96 |
def _Check(cert): |
|
97 |
assert cert.get_subject() |
|
98 |
|
|
99 |
def testSuccessfulCheck(self): |
|
100 |
cert_filename = self._TestDataFilename("cert2.pem") |
|
101 |
cert_pem = utils.ReadFile(cert_filename) |
|
102 |
result = \ |
|
103 |
node_daemon_setup._VerifyCertificate(cert_pem, _check_fn=self._Check) |
|
104 |
self.assertTrue("-----BEGIN PRIVATE KEY-----" in result) |
|
105 |
self.assertTrue("-----BEGIN CERTIFICATE-----" in result) |
|
106 |
|
|
107 |
def testMismatchingKey(self): |
|
108 |
cert1_path = self._TestDataFilename("cert1.pem") |
|
109 |
cert2_path = self._TestDataFilename("cert2.pem") |
|
110 |
|
|
111 |
# Extract certificate |
|
112 |
cert1 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, |
|
113 |
utils.ReadFile(cert1_path)) |
|
114 |
cert1_pem = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, |
|
115 |
cert1) |
|
116 |
|
|
117 |
# Extract mismatching key |
|
118 |
key2 = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, |
|
119 |
utils.ReadFile(cert2_path)) |
|
120 |
key2_pem = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, |
|
121 |
key2) |
|
122 |
|
|
123 |
try: |
|
124 |
node_daemon_setup._VerifyCertificate(cert1_pem + key2_pem, |
|
125 |
_check_fn=NotImplemented) |
|
126 |
except errors.X509CertError, err: |
|
127 |
self.assertEqual(err.args, |
|
128 |
("(stdin)", "Certificate is not signed with given key")) |
|
129 |
else: |
|
130 |
self.fail("Exception was not raised") |
|
131 |
|
|
132 |
|
|
133 |
class TestVerifyClusterName(unittest.TestCase): |
|
134 |
def setUp(self): |
|
135 |
unittest.TestCase.setUp(self) |
|
136 |
self.tmpdir = tempfile.mkdtemp() |
|
137 |
|
|
138 |
def tearDown(self): |
|
139 |
unittest.TestCase.tearDown(self) |
|
140 |
shutil.rmtree(self.tmpdir) |
|
141 |
|
|
142 |
def testNoName(self): |
|
143 |
self.assertRaises(_SetupError, node_daemon_setup.VerifyClusterName, |
|
144 |
{}, _verify_fn=NotImplemented) |
|
145 |
|
|
146 |
@staticmethod |
|
147 |
def _FailingVerify(name): |
|
148 |
assert name == "somecluster.example.com" |
|
149 |
raise errors.GenericError() |
|
150 |
|
|
151 |
def testFailingVerification(self): |
|
152 |
data = { |
|
153 |
constants.NDS_CLUSTER_NAME: "somecluster.example.com", |
|
154 |
} |
|
155 |
|
|
156 |
self.assertRaises(errors.GenericError, node_daemon_setup.VerifyClusterName, |
|
157 |
data, _verify_fn=self._FailingVerify) |
|
158 |
|
|
159 |
def testSuccess(self): |
|
160 |
data = { |
|
161 |
constants.NDS_CLUSTER_NAME: "cluster.example.com", |
|
162 |
} |
|
163 |
|
|
164 |
result = \ |
|
165 |
node_daemon_setup.VerifyClusterName(data, _verify_fn=lambda _: None) |
|
166 |
|
|
167 |
self.assertEqual(result, "cluster.example.com") |
|
168 |
|
|
169 |
|
|
170 |
class TestVerifySsconf(unittest.TestCase): |
|
171 |
def testNoSsconf(self): |
|
172 |
self.assertRaises(_SetupError, node_daemon_setup.VerifySsconf, |
|
173 |
{}, NotImplemented, _verify_fn=NotImplemented) |
|
174 |
|
|
175 |
for items in [None, {}]: |
|
176 |
self.assertRaises(_SetupError, node_daemon_setup.VerifySsconf, { |
|
177 |
constants.NDS_SSCONF: items, |
|
178 |
}, NotImplemented, _verify_fn=NotImplemented) |
|
179 |
|
|
180 |
def _Check(self, names): |
|
181 |
self.assertEqual(frozenset(names), frozenset([ |
|
182 |
constants.SS_CLUSTER_NAME, |
|
183 |
constants.SS_INSTANCE_LIST, |
|
184 |
])) |
|
185 |
|
|
186 |
def testSuccess(self): |
|
187 |
ssdata = { |
|
188 |
constants.SS_CLUSTER_NAME: "cluster.example.com", |
|
189 |
constants.SS_INSTANCE_LIST: [], |
|
190 |
} |
|
191 |
|
|
192 |
result = node_daemon_setup.VerifySsconf({ |
|
193 |
constants.NDS_SSCONF: ssdata, |
|
194 |
}, "cluster.example.com", _verify_fn=self._Check) |
|
195 |
|
|
196 |
self.assertEqual(result, ssdata) |
|
197 |
|
|
198 |
self.assertRaises(_SetupError, node_daemon_setup.VerifySsconf, { |
|
199 |
constants.NDS_SSCONF: ssdata, |
|
200 |
}, "wrong.example.com", _verify_fn=self._Check) |
|
201 |
|
|
202 |
def testInvalidKey(self): |
|
203 |
self.assertRaises(errors.GenericError, node_daemon_setup.VerifySsconf, { |
|
204 |
constants.NDS_SSCONF: { |
|
205 |
"no-valid-ssconf-key": "value", |
|
206 |
}, |
|
207 |
}, NotImplemented) |
|
208 |
|
|
209 |
|
|
210 |
if __name__ == "__main__": |
|
211 |
testutils.GanetiTestProgram() |
Also available in: Unified diff