Skip to content

Commit fc4b966

Browse files
blackboxswServer Team CI Bot
authored andcommitted
cli: add cloud-init query subcommand to query instance metadata
Cloud-init caches any cloud metadata crawled during boot in the file /run/cloud-init/instance-data.json. Cloud-init also standardizes some of that metadata across all clouds. The command 'cloud-init query' surfaces a simple CLI to query or format any cached instance metadata so that scripts or end-users do not have to write tools to crawl metadata themselves. Since 'cloud-init query' is runnable by non-root users, redact any sensitive data from instance-data.json and provide a root-readable unredacted instance-data-sensitive.json. Datasources can now define a sensitive_metadata_keys tuple which will redact any matching keys which could contain passwords or credentials from instance-data.json. Also add the following standardized 'v1' instance-data.json keys:   - user_data: The base64encoded user-data provided at instance launch   - vendor_data: Any vendor_data provided to the instance at launch   - underscore_delimited versions of existing hyphenated keys:     instance_id, local_hostname, availability_zone, cloud_name
1 parent 0b0378d commit fc4b966

File tree

14 files changed

+952
-233
lines changed

14 files changed

+952
-233
lines changed

bash_completion/cloud-init

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ _cloudinit_complete()
1010
cur_word="${COMP_WORDS[COMP_CWORD]}"
1111
prev_word="${COMP_WORDS[COMP_CWORD-1]}"
1212

13-
subcmds="analyze clean collect-logs devel dhclient-hook features init modules single status"
13+
subcmds="analyze clean collect-logs devel dhclient-hook features init modules query single status"
1414
base_params="--help --file --version --debug --force"
1515
case ${COMP_CWORD} in
1616
1)
@@ -40,6 +40,8 @@ _cloudinit_complete()
4040
COMPREPLY=($(compgen -W "--help --mode" -- $cur_word))
4141
;;
4242

43+
query)
44+
COMPREPLY=($(compgen -W "--all --help --instance-data --list-keys --user-data --vendor-data --debug" -- $cur_word));;
4345
single)
4446
COMPREPLY=($(compgen -W "--help --name --frequency --report" -- $cur_word))
4547
;;

cloudinit/cmd/devel/render.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from cloudinit.handlers.jinja_template import render_jinja_payload_from_file
1010
from cloudinit import log
1111
from cloudinit.sources import INSTANCE_JSON_FILE
12-
from cloudinit import util
1312
from . import addLogHandlerCLI, read_cfg_paths
1413

1514
NAME = 'render'
@@ -54,11 +53,7 @@ def handle_args(name, args):
5453
paths.run_dir, INSTANCE_JSON_FILE)
5554
else:
5655
instance_data_fn = args.instance_data
57-
try:
58-
with open(instance_data_fn) as stream:
59-
instance_data = stream.read()
60-
instance_data = util.load_json(instance_data)
61-
except IOError:
56+
if not os.path.exists(instance_data_fn):
6257
LOG.error('Missing instance-data.json file: %s', instance_data_fn)
6358
return 1
6459
try:

cloudinit/cmd/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,10 @@ def main(sysv_args=None):
791791
' pass to this module'))
792792
parser_single.set_defaults(action=('single', main_single))
793793

794+
parser_query = subparsers.add_parser(
795+
'query',
796+
help='Query standardized instance metadata from the command line.')
797+
794798
parser_dhclient = subparsers.add_parser('dhclient-hook',
795799
help=('run the dhclient hook'
796800
'to record network info'))
@@ -842,6 +846,12 @@ def main(sysv_args=None):
842846
clean_parser(parser_clean)
843847
parser_clean.set_defaults(
844848
action=('clean', handle_clean_args))
849+
elif sysv_args[0] == 'query':
850+
from cloudinit.cmd.query import (
851+
get_parser as query_parser, handle_args as handle_query_args)
852+
query_parser(parser_query)
853+
parser_query.set_defaults(
854+
action=('render', handle_query_args))
845855
elif sysv_args[0] == 'status':
846856
from cloudinit.cmd.status import (
847857
get_parser as status_parser, handle_status_args)

cloudinit/cmd/query.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# This file is part of cloud-init. See LICENSE file for license information.
2+
3+
"""Query standardized instance metadata from the command line."""
4+
5+
import argparse
6+
import os
7+
import six
8+
import sys
9+
10+
from cloudinit.handlers.jinja_template import (
11+
convert_jinja_instance_data, render_jinja_payload)
12+
from cloudinit.cmd.devel import addLogHandlerCLI, read_cfg_paths
13+
from cloudinit import log
14+
from cloudinit.sources import (
15+
INSTANCE_JSON_FILE, INSTANCE_JSON_SENSITIVE_FILE, REDACT_SENSITIVE_VALUE)
16+
from cloudinit import util
17+
18+
NAME = 'query'
19+
LOG = log.getLogger(NAME)
20+
21+
22+
def get_parser(parser=None):
23+
"""Build or extend an arg parser for query utility.
24+
25+
@param parser: Optional existing ArgumentParser instance representing the
26+
query subcommand which will be extended to support the args of
27+
this utility.
28+
29+
@returns: ArgumentParser with proper argument configuration.
30+
"""
31+
if not parser:
32+
parser = argparse.ArgumentParser(
33+
prog=NAME, description='Query cloud-init instance data')
34+
parser.add_argument(
35+
'-d', '--debug', action='store_true', default=False,
36+
help='Add verbose messages during template render')
37+
parser.add_argument(
38+
'-i', '--instance-data', type=str,
39+
help=('Path to instance-data.json file. Default is /run/cloud-init/%s'
40+
% INSTANCE_JSON_FILE))
41+
parser.add_argument(
42+
'-l', '--list-keys', action='store_true', default=False,
43+
help=('List query keys available at the provided instance-data'
44+
' <varname>.'))
45+
parser.add_argument(
46+
'-u', '--user-data', type=str,
47+
help=('Path to user-data file. Default is'
48+
' /var/lib/cloud/instance/user-data.txt'))
49+
parser.add_argument(
50+
'-v', '--vendor-data', type=str,
51+
help=('Path to vendor-data file. Default is'
52+
' /var/lib/cloud/instance/vendor-data.txt'))
53+
parser.add_argument(
54+
'varname', type=str, nargs='?',
55+
help=('A dot-delimited instance data variable to query from'
56+
' instance-data query. For example: v2.local_hostname'))
57+
parser.add_argument(
58+
'-a', '--all', action='store_true', default=False, dest='dump_all',
59+
help='Dump all available instance-data')
60+
parser.add_argument(
61+
'-f', '--format', type=str, dest='format',
62+
help=('Optionally specify a custom output format string. Any'
63+
' instance-data variable can be specified between double-curly'
64+
' braces. For example -f "{{ v2.cloud_name }}"'))
65+
return parser
66+
67+
68+
def handle_args(name, args):
69+
"""Handle calls to 'cloud-init query' as a subcommand."""
70+
paths = None
71+
addLogHandlerCLI(LOG, log.DEBUG if args.debug else log.WARNING)
72+
if not any([args.list_keys, args.varname, args.format, args.dump_all]):
73+
LOG.error(
74+
'Expected one of the options: --all, --format,'
75+
' --list-keys or varname')
76+
get_parser().print_help()
77+
return 1
78+
79+
uid = os.getuid()
80+
if not all([args.instance_data, args.user_data, args.vendor_data]):
81+
paths = read_cfg_paths()
82+
if not args.instance_data:
83+
if uid == 0:
84+
default_json_fn = INSTANCE_JSON_SENSITIVE_FILE
85+
else:
86+
default_json_fn = INSTANCE_JSON_FILE # World readable
87+
instance_data_fn = os.path.join(paths.run_dir, default_json_fn)
88+
else:
89+
instance_data_fn = args.instance_data
90+
if not args.user_data:
91+
user_data_fn = os.path.join(paths.instance_link, 'user-data.txt')
92+
else:
93+
user_data_fn = args.user_data
94+
if not args.vendor_data:
95+
vendor_data_fn = os.path.join(paths.instance_link, 'vendor-data.txt')
96+
else:
97+
vendor_data_fn = args.vendor_data
98+
99+
try:
100+
instance_json = util.load_file(instance_data_fn)
101+
except IOError:
102+
LOG.error('Missing instance-data.json file: %s', instance_data_fn)
103+
return 1
104+
105+
instance_data = util.load_json(instance_json)
106+
if uid != 0:
107+
instance_data['userdata'] = (
108+
'<%s> file:%s' % (REDACT_SENSITIVE_VALUE, user_data_fn))
109+
instance_data['vendordata'] = (
110+
'<%s> file:%s' % (REDACT_SENSITIVE_VALUE, vendor_data_fn))
111+
else:
112+
instance_data['userdata'] = util.load_file(user_data_fn)
113+
instance_data['vendordata'] = util.load_file(vendor_data_fn)
114+
if args.format:
115+
payload = '## template: jinja\n{fmt}'.format(fmt=args.format)
116+
rendered_payload = render_jinja_payload(
117+
payload=payload, payload_fn='query commandline',
118+
instance_data=instance_data,
119+
debug=True if args.debug else False)
120+
if rendered_payload:
121+
print(rendered_payload)
122+
return 0
123+
return 1
124+
125+
response = convert_jinja_instance_data(instance_data)
126+
if args.varname:
127+
try:
128+
for var in args.varname.split('.'):
129+
response = response[var]
130+
except KeyError:
131+
LOG.error('Undefined instance-data key %s', args.varname)
132+
return 1
133+
if args.list_keys:
134+
if not isinstance(response, dict):
135+
LOG.error("--list-keys provided but '%s' is not a dict", var)
136+
return 1
137+
response = '\n'.join(sorted(response.keys()))
138+
elif args.list_keys:
139+
response = '\n'.join(sorted(response.keys()))
140+
if not isinstance(response, six.string_types):
141+
response = util.json_dumps(response)
142+
print(response)
143+
return 0
144+
145+
146+
def main():
147+
"""Tool to query specific instance-data values."""
148+
parser = get_parser()
149+
sys.exit(handle_args(NAME, parser.parse_args()))
150+
151+
152+
if __name__ == '__main__':
153+
main()
154+
155+
# vi: ts=4 expandtab

0 commit comments

Comments
 (0)