Merge ~chad.smith/cloud-init:feature/command-cloud-id into cloud-init:master
- Git
- lp:~chad.smith/cloud-init
- feature/command-cloud-id
- Merge into master
Status: | Merged |
---|---|
Approved by: | Chad Smith |
Approved revision: | 5066f4117c680c7d824731226958f8007cd2a0ea |
Merge reported by: | Server Team CI bot |
Merged at revision: | not available |
Proposed branch: | ~chad.smith/cloud-init:feature/command-cloud-id |
Merge into: | cloud-init:master |
Prerequisite: | ~chad.smith/cloud-init:cleanup/metadata-cloud-platform |
Diff against target: |
378 lines (+319/-2) 5 files modified
cloudinit/cmd/cloud_id.py (+90/-0) cloudinit/cmd/tests/test_cloud_id.py (+127/-0) cloudinit/sources/__init__.py (+27/-0) cloudinit/sources/tests/test_init.py (+73/-1) setup.py (+2/-1) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Server Team CI bot | continuous-integration | Approve | |
cloud-init Commiters | Pending | ||
Review via email: mp+356365@code.launchpad.net |
Commit message
tools: Add cloud-id command line utility
Add a quick cloud lookup utility in order to more easily determine
the cloud on which an instance is running.
The utility parses standardized attributes from
/run/cloud-
for the instance. It uses known region maps if necessary to determine
on which specific cloud the instance is running.
Examples:
aws, aws-gov, aws-china, rackspace, azure-china, lxd, openstack, unknown
Description of the change
Server Team CI bot (server-team-bot) wrote : | # |
- 03cbba6... by Chad Smith
-
drop rackspace canonical_cloud_id rules fixup unit tests
- a71d78e... by Chad Smith
-
pycodestyle
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:a71d78e776e
https:/
Executed test runs:
SUCCESS: Checkout
FAILED: Unit & Style Tests
Click here to trigger a rebuild:
https:/
- 420a47f... by Chad Smith
-
fix rackspace unit test in cloud_id. Catch invalid json
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:420a47f551c
https:/
Executed test runs:
SUCCESS: Checkout
FAILED: Unit & Style Tests
Click here to trigger a rebuild:
https:/
- 5066f41... by Chad Smith
-
style and lints
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:5066f4117c6
https:/
Executed test runs:
SUCCESS: Checkout
SUCCESS: Unit & Style Tests
SUCCESS: Ubuntu LTS: Build
SUCCESS: Ubuntu LTS: Integration
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild:
https:/
Preview Diff
1 | diff --git a/cloudinit/cmd/cloud_id.py b/cloudinit/cmd/cloud_id.py |
2 | new file mode 100755 |
3 | index 0000000..9760892 |
4 | --- /dev/null |
5 | +++ b/cloudinit/cmd/cloud_id.py |
6 | @@ -0,0 +1,90 @@ |
7 | +# This file is part of cloud-init. See LICENSE file for license information. |
8 | + |
9 | +"""Commandline utility to list the canonical cloud-id for an instance.""" |
10 | + |
11 | +import argparse |
12 | +import json |
13 | +import sys |
14 | + |
15 | +from cloudinit.sources import ( |
16 | + INSTANCE_JSON_FILE, METADATA_UNKNOWN, canonical_cloud_id) |
17 | + |
18 | +DEFAULT_INSTANCE_JSON = '/run/cloud-init/%s' % INSTANCE_JSON_FILE |
19 | + |
20 | +NAME = 'cloud-id' |
21 | + |
22 | + |
23 | +def get_parser(parser=None): |
24 | + """Build or extend an arg parser for the cloud-id utility. |
25 | + |
26 | + @param parser: Optional existing ArgumentParser instance representing the |
27 | + query subcommand which will be extended to support the args of |
28 | + this utility. |
29 | + |
30 | + @returns: ArgumentParser with proper argument configuration. |
31 | + """ |
32 | + if not parser: |
33 | + parser = argparse.ArgumentParser( |
34 | + prog=NAME, |
35 | + description='Report the canonical cloud-id for this instance') |
36 | + parser.add_argument( |
37 | + '-j', '--json', action='store_true', default=False, |
38 | + help='Report all standardized cloud-id information as json.') |
39 | + parser.add_argument( |
40 | + '-l', '--long', action='store_true', default=False, |
41 | + help='Report extended cloud-id information as tab-delimited string.') |
42 | + parser.add_argument( |
43 | + '-i', '--instance-data', type=str, default=DEFAULT_INSTANCE_JSON, |
44 | + help=('Path to instance-data.json file. Default is %s' % |
45 | + DEFAULT_INSTANCE_JSON)) |
46 | + return parser |
47 | + |
48 | + |
49 | +def error(msg): |
50 | + sys.stderr.write('ERROR: %s\n' % msg) |
51 | + return 1 |
52 | + |
53 | + |
54 | +def handle_args(name, args): |
55 | + """Handle calls to 'cloud-id' cli. |
56 | + |
57 | + Print the canonical cloud-id on which the instance is running. |
58 | + |
59 | + @return: 0 on success, 1 otherwise. |
60 | + """ |
61 | + try: |
62 | + instance_data = json.load(open(args.instance_data)) |
63 | + except IOError: |
64 | + return error( |
65 | + "File not found '%s'. Provide a path to instance data json file" |
66 | + ' using --instance-data' % args.instance_data) |
67 | + except ValueError as e: |
68 | + return error( |
69 | + "File '%s' is not valid json. %s" % (args.instance_data, e)) |
70 | + v1 = instance_data.get('v1', {}) |
71 | + cloud_id = canonical_cloud_id( |
72 | + v1.get('cloud_name', METADATA_UNKNOWN), |
73 | + v1.get('region', METADATA_UNKNOWN), |
74 | + v1.get('platform', METADATA_UNKNOWN)) |
75 | + if args.json: |
76 | + v1['cloud_id'] = cloud_id |
77 | + response = json.dumps( # Pretty, sorted json |
78 | + v1, indent=1, sort_keys=True, separators=(',', ': ')) |
79 | + elif args.long: |
80 | + response = '%s\t%s' % (cloud_id, v1.get('region', METADATA_UNKNOWN)) |
81 | + else: |
82 | + response = cloud_id |
83 | + sys.stdout.write('%s\n' % response) |
84 | + return 0 |
85 | + |
86 | + |
87 | +def main(): |
88 | + """Tool to query specific instance-data values.""" |
89 | + parser = get_parser() |
90 | + sys.exit(handle_args(NAME, parser.parse_args())) |
91 | + |
92 | + |
93 | +if __name__ == '__main__': |
94 | + main() |
95 | + |
96 | +# vi: ts=4 expandtab |
97 | diff --git a/cloudinit/cmd/tests/test_cloud_id.py b/cloudinit/cmd/tests/test_cloud_id.py |
98 | new file mode 100644 |
99 | index 0000000..7373817 |
100 | --- /dev/null |
101 | +++ b/cloudinit/cmd/tests/test_cloud_id.py |
102 | @@ -0,0 +1,127 @@ |
103 | +# This file is part of cloud-init. See LICENSE file for license information. |
104 | + |
105 | +"""Tests for cloud-id command line utility.""" |
106 | + |
107 | +from cloudinit import util |
108 | +from collections import namedtuple |
109 | +from six import StringIO |
110 | + |
111 | +from cloudinit.cmd import cloud_id |
112 | + |
113 | +from cloudinit.tests.helpers import CiTestCase, mock |
114 | + |
115 | + |
116 | +class TestCloudId(CiTestCase): |
117 | + |
118 | + args = namedtuple('cloudidargs', ('instance_data json long')) |
119 | + |
120 | + def setUp(self): |
121 | + super(TestCloudId, self).setUp() |
122 | + self.tmp = self.tmp_dir() |
123 | + self.instance_data = self.tmp_path('instance-data.json', dir=self.tmp) |
124 | + |
125 | + def test_cloud_id_arg_parser_defaults(self): |
126 | + """Validate the argument defaults when not provided by the end-user.""" |
127 | + cmd = ['cloud-id'] |
128 | + with mock.patch('sys.argv', cmd): |
129 | + args = cloud_id.get_parser().parse_args() |
130 | + self.assertEqual( |
131 | + '/run/cloud-init/instance-data.json', |
132 | + args.instance_data) |
133 | + self.assertEqual(False, args.long) |
134 | + self.assertEqual(False, args.json) |
135 | + |
136 | + def test_cloud_id_arg_parse_overrides(self): |
137 | + """Override argument defaults by specifying values for each param.""" |
138 | + util.write_file(self.instance_data, '{}') |
139 | + cmd = ['cloud-id', '--instance-data', self.instance_data, '--long', |
140 | + '--json'] |
141 | + with mock.patch('sys.argv', cmd): |
142 | + args = cloud_id.get_parser().parse_args() |
143 | + self.assertEqual(self.instance_data, args.instance_data) |
144 | + self.assertEqual(True, args.long) |
145 | + self.assertEqual(True, args.json) |
146 | + |
147 | + def test_cloud_id_missing_instance_data_json(self): |
148 | + """Exit error when the provided instance-data.json does not exist.""" |
149 | + cmd = ['cloud-id', '--instance-data', self.instance_data] |
150 | + with mock.patch('sys.argv', cmd): |
151 | + with mock.patch('sys.stderr', new_callable=StringIO) as m_stderr: |
152 | + with self.assertRaises(SystemExit) as context_manager: |
153 | + cloud_id.main() |
154 | + self.assertEqual(1, context_manager.exception.code) |
155 | + self.assertIn( |
156 | + "ERROR: File not found '%s'" % self.instance_data, |
157 | + m_stderr.getvalue()) |
158 | + |
159 | + def test_cloud_id_non_json_instance_data(self): |
160 | + """Exit error when the provided instance-data.json is not json.""" |
161 | + cmd = ['cloud-id', '--instance-data', self.instance_data] |
162 | + util.write_file(self.instance_data, '{') |
163 | + with mock.patch('sys.argv', cmd): |
164 | + with mock.patch('sys.stderr', new_callable=StringIO) as m_stderr: |
165 | + with self.assertRaises(SystemExit) as context_manager: |
166 | + cloud_id.main() |
167 | + self.assertEqual(1, context_manager.exception.code) |
168 | + self.assertIn( |
169 | + "ERROR: File '%s' is not valid json." % self.instance_data, |
170 | + m_stderr.getvalue()) |
171 | + |
172 | + def test_cloud_id_from_cloud_name_in_instance_data(self): |
173 | + """Report canonical cloud-id from cloud_name in instance-data.""" |
174 | + util.write_file( |
175 | + self.instance_data, |
176 | + '{"v1": {"cloud_name": "mycloud", "region": "somereg"}}') |
177 | + cmd = ['cloud-id', '--instance-data', self.instance_data] |
178 | + with mock.patch('sys.argv', cmd): |
179 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
180 | + with self.assertRaises(SystemExit) as context_manager: |
181 | + cloud_id.main() |
182 | + self.assertEqual(0, context_manager.exception.code) |
183 | + self.assertEqual("mycloud\n", m_stdout.getvalue()) |
184 | + |
185 | + def test_cloud_id_long_name_from_instance_data(self): |
186 | + """Report long cloud-id format from cloud_name and region.""" |
187 | + util.write_file( |
188 | + self.instance_data, |
189 | + '{"v1": {"cloud_name": "mycloud", "region": "somereg"}}') |
190 | + cmd = ['cloud-id', '--instance-data', self.instance_data, '--long'] |
191 | + with mock.patch('sys.argv', cmd): |
192 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
193 | + with self.assertRaises(SystemExit) as context_manager: |
194 | + cloud_id.main() |
195 | + self.assertEqual(0, context_manager.exception.code) |
196 | + self.assertEqual("mycloud\tsomereg\n", m_stdout.getvalue()) |
197 | + |
198 | + def test_cloud_id_lookup_from_instance_data_region(self): |
199 | + """Report discovered canonical cloud_id when region lookup matches.""" |
200 | + util.write_file( |
201 | + self.instance_data, |
202 | + '{"v1": {"cloud_name": "aws", "region": "cn-north-1",' |
203 | + ' "platform": "ec2"}}') |
204 | + cmd = ['cloud-id', '--instance-data', self.instance_data, '--long'] |
205 | + with mock.patch('sys.argv', cmd): |
206 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
207 | + with self.assertRaises(SystemExit) as context_manager: |
208 | + cloud_id.main() |
209 | + self.assertEqual(0, context_manager.exception.code) |
210 | + self.assertEqual("aws-china\tcn-north-1\n", m_stdout.getvalue()) |
211 | + |
212 | + def test_cloud_id_lookup_json_instance_data_adds_cloud_id_to_json(self): |
213 | + """Report v1 instance-data content with cloud_id when --json set.""" |
214 | + util.write_file( |
215 | + self.instance_data, |
216 | + '{"v1": {"cloud_name": "unknown", "region": "dfw",' |
217 | + ' "platform": "openstack", "public_ssh_keys": []}}') |
218 | + expected = util.json_dumps({ |
219 | + 'cloud_id': 'openstack', 'cloud_name': 'unknown', |
220 | + 'platform': 'openstack', 'public_ssh_keys': [], 'region': 'dfw'}) |
221 | + cmd = ['cloud-id', '--instance-data', self.instance_data, '--json'] |
222 | + with mock.patch('sys.argv', cmd): |
223 | + with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: |
224 | + with self.assertRaises(SystemExit) as context_manager: |
225 | + cloud_id.main() |
226 | + self.assertEqual(0, context_manager.exception.code) |
227 | + self.assertEqual(expected + '\n', m_stdout.getvalue()) |
228 | + |
229 | +# vi: ts=4 expandtab |
230 | diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py |
231 | index 9b90680..e6966b3 100644 |
232 | --- a/cloudinit/sources/__init__.py |
233 | +++ b/cloudinit/sources/__init__.py |
234 | @@ -58,6 +58,14 @@ METADATA_UNKNOWN = 'unknown' |
235 | |
236 | LOG = logging.getLogger(__name__) |
237 | |
238 | +# CLOUD_ID_REGION_PREFIX_MAP format is: |
239 | +# <region-match-prefix>: (<new-cloud-id>: <test_allowed_cloud_callable>) |
240 | +CLOUD_ID_REGION_PREFIX_MAP = { |
241 | + 'cn-': ('aws-china', lambda c: c == 'aws'), # only change aws regions |
242 | + 'us-gov-': ('aws-gov', lambda c: c == 'aws'), # only change aws regions |
243 | + 'china': ('azure-china', lambda c: c == 'azure'), # only change azure |
244 | +} |
245 | + |
246 | |
247 | class DataSourceNotFoundException(Exception): |
248 | pass |
249 | @@ -770,6 +778,25 @@ def instance_id_matches_system_uuid(instance_id, field='system-uuid'): |
250 | return instance_id.lower() == dmi_value.lower() |
251 | |
252 | |
253 | +def canonical_cloud_id(cloud_name, region, platform): |
254 | + """Lookup the canonical cloud-id for a given cloud_name and region.""" |
255 | + if not cloud_name: |
256 | + cloud_name = METADATA_UNKNOWN |
257 | + if not region: |
258 | + region = METADATA_UNKNOWN |
259 | + if region == METADATA_UNKNOWN: |
260 | + if cloud_name != METADATA_UNKNOWN: |
261 | + return cloud_name |
262 | + return platform |
263 | + for prefix, cloud_id_test in CLOUD_ID_REGION_PREFIX_MAP.items(): |
264 | + (cloud_id, valid_cloud) = cloud_id_test |
265 | + if region.startswith(prefix) and valid_cloud(cloud_name): |
266 | + return cloud_id |
267 | + if cloud_name != METADATA_UNKNOWN: |
268 | + return cloud_name |
269 | + return platform |
270 | + |
271 | + |
272 | def convert_vendordata(data, recurse=True): |
273 | """data: a loaded object (strings, arrays, dicts). |
274 | return something suitable for cloudinit vendordata_raw. |
275 | diff --git a/cloudinit/sources/tests/test_init.py b/cloudinit/sources/tests/test_init.py |
276 | index 391b343..6378e98 100644 |
277 | --- a/cloudinit/sources/tests/test_init.py |
278 | +++ b/cloudinit/sources/tests/test_init.py |
279 | @@ -11,7 +11,8 @@ from cloudinit.helpers import Paths |
280 | from cloudinit import importer |
281 | from cloudinit.sources import ( |
282 | EXPERIMENTAL_TEXT, INSTANCE_JSON_FILE, INSTANCE_JSON_SENSITIVE_FILE, |
283 | - REDACT_SENSITIVE_VALUE, UNSET, DataSource, redact_sensitive_keys) |
284 | + METADATA_UNKNOWN, REDACT_SENSITIVE_VALUE, UNSET, DataSource, |
285 | + canonical_cloud_id, redact_sensitive_keys) |
286 | from cloudinit.tests.helpers import CiTestCase, skipIf, mock |
287 | from cloudinit.user_data import UserDataProcessor |
288 | from cloudinit import util |
289 | @@ -607,4 +608,75 @@ class TestRedactSensitiveData(CiTestCase): |
290 | redact_sensitive_keys(md)) |
291 | |
292 | |
293 | +class TestCanonicalCloudID(CiTestCase): |
294 | + |
295 | + def test_cloud_id_returns_platform_on_unknowns(self): |
296 | + """When region and cloud_name are unknown, return platform.""" |
297 | + self.assertEqual( |
298 | + 'platform', |
299 | + canonical_cloud_id(cloud_name=METADATA_UNKNOWN, |
300 | + region=METADATA_UNKNOWN, |
301 | + platform='platform')) |
302 | + |
303 | + def test_cloud_id_returns_platform_on_none(self): |
304 | + """When region and cloud_name are unknown, return platform.""" |
305 | + self.assertEqual( |
306 | + 'platform', |
307 | + canonical_cloud_id(cloud_name=None, |
308 | + region=None, |
309 | + platform='platform')) |
310 | + |
311 | + def test_cloud_id_returns_cloud_name_on_unknown_region(self): |
312 | + """When region is unknown, return cloud_name.""" |
313 | + for region in (None, METADATA_UNKNOWN): |
314 | + self.assertEqual( |
315 | + 'cloudname', |
316 | + canonical_cloud_id(cloud_name='cloudname', |
317 | + region=region, |
318 | + platform='platform')) |
319 | + |
320 | + def test_cloud_id_returns_platform_on_unknown_cloud_name(self): |
321 | + """When region is set but cloud_name is unknown return cloud_name.""" |
322 | + self.assertEqual( |
323 | + 'platform', |
324 | + canonical_cloud_id(cloud_name=METADATA_UNKNOWN, |
325 | + region='region', |
326 | + platform='platform')) |
327 | + |
328 | + def test_cloud_id_aws_based_on_region_and_cloud_name(self): |
329 | + """When cloud_name is aws, return proper cloud-id based on region.""" |
330 | + self.assertEqual( |
331 | + 'aws-china', |
332 | + canonical_cloud_id(cloud_name='aws', |
333 | + region='cn-north-1', |
334 | + platform='platform')) |
335 | + self.assertEqual( |
336 | + 'aws', |
337 | + canonical_cloud_id(cloud_name='aws', |
338 | + region='us-east-1', |
339 | + platform='platform')) |
340 | + self.assertEqual( |
341 | + 'aws-gov', |
342 | + canonical_cloud_id(cloud_name='aws', |
343 | + region='us-gov-1', |
344 | + platform='platform')) |
345 | + self.assertEqual( # Overrideen non-aws cloud_name is returned |
346 | + '!aws', |
347 | + canonical_cloud_id(cloud_name='!aws', |
348 | + region='us-gov-1', |
349 | + platform='platform')) |
350 | + |
351 | + def test_cloud_id_azure_based_on_region_and_cloud_name(self): |
352 | + """Report cloud-id when cloud_name is azure and region is in china.""" |
353 | + self.assertEqual( |
354 | + 'azure-china', |
355 | + canonical_cloud_id(cloud_name='azure', |
356 | + region='chinaeast', |
357 | + platform='platform')) |
358 | + self.assertEqual( |
359 | + 'azure', |
360 | + canonical_cloud_id(cloud_name='azure', |
361 | + region='!chinaeast', |
362 | + platform='platform')) |
363 | + |
364 | # vi: ts=4 expandtab |
365 | diff --git a/setup.py b/setup.py |
366 | index 5ed8eae..ea37efc 100755 |
367 | --- a/setup.py |
368 | +++ b/setup.py |
369 | @@ -282,7 +282,8 @@ setuptools.setup( |
370 | cmdclass=cmdclass, |
371 | entry_points={ |
372 | 'console_scripts': [ |
373 | - 'cloud-init = cloudinit.cmd.main:main' |
374 | + 'cloud-init = cloudinit.cmd.main:main', |
375 | + 'cloud-id = cloudinit.cmd.cloud_id:main' |
376 | ], |
377 | } |
378 | ) |
FAILED: Continuous integration, rev:abc12004af1 c150d5f3504766e d858373ed254d5 /jenkins. ubuntu. com/server/ job/cloud- init-ci/ 382/
https:/
Executed test runs:
SUCCESS: Checkout
FAILED: Unit & Style Tests
Click here to trigger a rebuild: /jenkins. ubuntu. com/server/ job/cloud- init-ci/ 382/rebuild
https:/