Merge ~chad.smith/cloud-init:feature/command-cloud-id into cloud-init:master

Proposed by Chad Smith
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)
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-init/instance-data.json to print the canonical cloud-id
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

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:abc12004af1c150d5f3504766ed858373ed254d5
https://jenkins.ubuntu.com/server/job/cloud-init-ci/382/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/382/rebuild

review: Needs Fixing (continuous-integration)
03cbba6... by Chad Smith

drop rackspace canonical_cloud_id rules fixup unit tests

a71d78e... by Chad Smith

pycodestyle

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:a71d78e776ec846a9ffd8fc901d4f4aa7e50c66a
https://jenkins.ubuntu.com/server/job/cloud-init-ci/384/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/384/rebuild

review: Needs Fixing (continuous-integration)
420a47f... by Chad Smith

fix rackspace unit test in cloud_id. Catch invalid json

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:420a47f551c2e5d743b821a524e952961e552d10
https://jenkins.ubuntu.com/server/job/cloud-init-ci/386/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/386/rebuild

review: Needs Fixing (continuous-integration)
5066f41... by Chad Smith

style and lints

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:5066f4117c680c7d824731226958f8007cd2a0ea
https://jenkins.ubuntu.com/server/job/cloud-init-ci/387/
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://jenkins.ubuntu.com/server/job/cloud-init-ci/387/rebuild

review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/cmd/cloud_id.py b/cloudinit/cmd/cloud_id.py
2new file mode 100755
3index 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
97diff --git a/cloudinit/cmd/tests/test_cloud_id.py b/cloudinit/cmd/tests/test_cloud_id.py
98new file mode 100644
99index 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
230diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
231index 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.
275diff --git a/cloudinit/sources/tests/test_init.py b/cloudinit/sources/tests/test_init.py
276index 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
365diff --git a/setup.py b/setup.py
366index 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 )

Subscribers

People subscribed via source and target branches