Merge ~smoser/cloud-init:fix/fix-dhclient-hook-down into cloud-init:master

Proposed by Scott Moser
Status: Merged
Approved by: Chad Smith
Approved revision: 4fdd42ed0a1a10eb213b041517ae715f93e6a7c7
Merge reported by: Server Team CI bot
Merged at revision: not available
Proposed branch: ~smoser/cloud-init:fix/fix-dhclient-hook-down
Merge into: cloud-init:master
Diff against target: 330 lines (+193/-63)
5 files modified
bash_completion/cloud-init (+4/-1)
cloudinit/cmd/main.py (+4/-16)
cloudinit/dhclient_hook.py (+72/-38)
cloudinit/tests/test_dhclient_hook.py (+105/-0)
tests/unittests/test_cli.py (+8/-8)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
Chad Smith Approve
Review via email: mp+356428@code.launchpad.net

Commit message

dhclient-hook: cleanups, tests and fix a bug on 'down' event.

I noticed a bug in dhclient_hook on the 'down' event, using 'is'
operator rather than '==' (if self.net_action is 'down').

This refactors/simplifies the code a bit for easier testing and adds
tests. The reason for the rename of 'action' to 'event' is to just
be internally consistent. The word and Namespace 'action' is used
by cloud-init main, so it was not really usable here.

Also adds a main which can easily be debugged with:
  CI_DHCP_HOOK_DATA_D=./my.d python -m cloudinit.dhclient_hook up eth0

Description of the change

see commit message

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

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

review: Approve (continuous-integration)
Revision history for this message
Chad Smith (chad.smith) wrote :

Can we add some bash_completion/cloud-init goodness for dhclient-hook.

Also, do we know of any public consumers that might be hurt by changing these cmdline parameter names from
(net_action, net_interface) TO (event, interface) negative?

review: Needs Information
Revision history for this message
Chad Smith (chad.smith) :
Revision history for this message
Scott Moser (smoser) wrote :

> Can we add some bash_completion/cloud-init goodness for dhclient-hook.
>
> Also, do we know of any public consumers that might be hurt by changing these
> cmdline parameter names from
> (net_action, net_interface) TO (event, interface) negative?

I dont think there would be any external consumers.

7c6cc7d... by Scott Moser

address Chad's feedback. ACTIONS -> EVENTS

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

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

review: Approve (continuous-integration)
4fdd42e... by Scott Moser

per chad feedback update dhclient-hook bash completion

Revision history for this message
Chad Smith (chad.smith) wrote :

Thanks for the update on tab-completion. LGTM!

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

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

review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/bash_completion/cloud-init b/bash_completion/cloud-init
index 8c25032..a9577e9 100644
--- a/bash_completion/cloud-init
+++ b/bash_completion/cloud-init
@@ -30,7 +30,10 @@ _cloudinit_complete()
30 devel)30 devel)
31 COMPREPLY=($(compgen -W "--help schema net-convert" -- $cur_word))31 COMPREPLY=($(compgen -W "--help schema net-convert" -- $cur_word))
32 ;;32 ;;
33 dhclient-hook|features)33 dhclient-hook)
34 COMPREPLY=($(compgen -W "--help up down" -- $cur_word))
35 ;;
36 features)
34 COMPREPLY=($(compgen -W "--help" -- $cur_word))37 COMPREPLY=($(compgen -W "--help" -- $cur_word))
35 ;;38 ;;
36 init)39 init)
diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
index 5a43702..933c019 100644
--- a/cloudinit/cmd/main.py
+++ b/cloudinit/cmd/main.py
@@ -41,7 +41,7 @@ from cloudinit.settings import (PER_INSTANCE, PER_ALWAYS, PER_ONCE,
41from cloudinit import atomic_helper41from cloudinit import atomic_helper
4242
43from cloudinit.config import cc_set_hostname43from cloudinit.config import cc_set_hostname
44from cloudinit.dhclient_hook import LogDhclient44from cloudinit import dhclient_hook
4545
4646
47# Welcome message template47# Welcome message template
@@ -586,12 +586,6 @@ def main_single(name, args):
586 return 0586 return 0
587587
588588
589def dhclient_hook(name, args):
590 record = LogDhclient(args)
591 record.check_hooks_dir()
592 record.record()
593
594
595def status_wrapper(name, args, data_d=None, link_d=None):589def status_wrapper(name, args, data_d=None, link_d=None):
596 if data_d is None:590 if data_d is None:
597 data_d = os.path.normpath("/var/lib/cloud/data")591 data_d = os.path.normpath("/var/lib/cloud/data")
@@ -795,15 +789,9 @@ def main(sysv_args=None):
795 'query',789 'query',
796 help='Query standardized instance metadata from the command line.')790 help='Query standardized instance metadata from the command line.')
797791
798 parser_dhclient = subparsers.add_parser('dhclient-hook',792 parser_dhclient = subparsers.add_parser(
799 help=('run the dhclient hook'793 dhclient_hook.NAME, help=dhclient_hook.__doc__)
800 'to record network info'))794 dhclient_hook.get_parser(parser_dhclient)
801 parser_dhclient.add_argument("net_action",
802 help=('action taken on the interface'))
803 parser_dhclient.add_argument("net_interface",
804 help=('the network interface being acted'
805 ' upon'))
806 parser_dhclient.set_defaults(action=('dhclient_hook', dhclient_hook))
807795
808 parser_features = subparsers.add_parser('features',796 parser_features = subparsers.add_parser('features',
809 help=('list defined features'))797 help=('list defined features'))
diff --git a/cloudinit/dhclient_hook.py b/cloudinit/dhclient_hook.py
index 7f02d7f..72b51b6 100644
--- a/cloudinit/dhclient_hook.py
+++ b/cloudinit/dhclient_hook.py
@@ -1,5 +1,8 @@
1# This file is part of cloud-init. See LICENSE file for license information.1# This file is part of cloud-init. See LICENSE file for license information.
22
3"""Run the dhclient hook to record network info."""
4
5import argparse
3import os6import os
47
5from cloudinit import atomic_helper8from cloudinit import atomic_helper
@@ -8,44 +11,75 @@ from cloudinit import stages
811
9LOG = logging.getLogger(__name__)12LOG = logging.getLogger(__name__)
1013
14NAME = "dhclient-hook"
15UP = "up"
16DOWN = "down"
17EVENTS = (UP, DOWN)
18
19
20def _get_hooks_dir():
21 i = stages.Init()
22 return os.path.join(i.paths.get_runpath(), 'dhclient.hooks')
23
24
25def _filter_env_vals(info):
26 """Given info (os.environ), return a dictionary with
27 lower case keys for each entry starting with DHCP4_ or new_."""
28 new_info = {}
29 for k, v in info.items():
30 if k.startswith("DHCP4_") or k.startswith("new_"):
31 key = (k.replace('DHCP4_', '').replace('new_', '')).lower()
32 new_info[key] = v
33 return new_info
34
35
36def run_hook(interface, event, data_d=None, env=None):
37 if event not in EVENTS:
38 raise ValueError("Unexpected event '%s'. Expected one of: %s" %
39 (event, EVENTS))
40 if data_d is None:
41 data_d = _get_hooks_dir()
42 if env is None:
43 env = os.environ
44 hook_file = os.path.join(data_d, interface + ".json")
45
46 if event == UP:
47 if not os.path.exists(data_d):
48 os.makedirs(data_d)
49 atomic_helper.write_json(hook_file, _filter_env_vals(env))
50 LOG.debug("Wrote dhclient options in %s", hook_file)
51 elif event == DOWN:
52 if os.path.exists(hook_file):
53 os.remove(hook_file)
54 LOG.debug("Removed dhclient options file %s", hook_file)
55
56
57def get_parser(parser=None):
58 if parser is None:
59 parser = argparse.ArgumentParser(prog=NAME, description=__doc__)
60 parser.add_argument(
61 "event", help='event taken on the interface', choices=EVENTS)
62 parser.add_argument(
63 "interface", help='the network interface being acted upon')
64 # cloud-init main uses 'action'
65 parser.set_defaults(action=(NAME, handle_args))
66 return parser
67
68
69def handle_args(name, args, data_d=None):
70 """Handle the Namespace args.
71 Takes 'name' as passed by cloud-init main. not used here."""
72 return run_hook(interface=args.interface, event=args.event, data_d=data_d)
73
74
75if __name__ == '__main__':
76 import sys
77 parser = get_parser()
78 args = parser.parse_args(args=sys.argv[1:])
79 return_value = handle_args(
80 NAME, args, data_d=os.environ.get('_CI_DHCP_HOOK_DATA_D'))
81 if return_value:
82 sys.exit(return_value)
1183
12class LogDhclient(object):
13
14 def __init__(self, cli_args):
15 self.hooks_dir = self._get_hooks_dir()
16 self.net_interface = cli_args.net_interface
17 self.net_action = cli_args.net_action
18 self.hook_file = os.path.join(self.hooks_dir,
19 self.net_interface + ".json")
20
21 @staticmethod
22 def _get_hooks_dir():
23 i = stages.Init()
24 return os.path.join(i.paths.get_runpath(), 'dhclient.hooks')
25
26 def check_hooks_dir(self):
27 if not os.path.exists(self.hooks_dir):
28 os.makedirs(self.hooks_dir)
29 else:
30 # If the action is down and the json file exists, we need to
31 # delete the file
32 if self.net_action is 'down' and os.path.exists(self.hook_file):
33 os.remove(self.hook_file)
34
35 @staticmethod
36 def get_vals(info):
37 new_info = {}
38 for k, v in info.items():
39 if k.startswith("DHCP4_") or k.startswith("new_"):
40 key = (k.replace('DHCP4_', '').replace('new_', '')).lower()
41 new_info[key] = v
42 return new_info
43
44 def record(self):
45 envs = os.environ
46 if self.hook_file is None:
47 return
48 atomic_helper.write_json(self.hook_file, self.get_vals(envs))
49 LOG.debug("Wrote dhclient options in %s", self.hook_file)
5084
51# vi: ts=4 expandtab85# vi: ts=4 expandtab
diff --git a/cloudinit/tests/test_dhclient_hook.py b/cloudinit/tests/test_dhclient_hook.py
52new file mode 10064486new file mode 100644
index 0000000..7aab8dd
--- /dev/null
+++ b/cloudinit/tests/test_dhclient_hook.py
@@ -0,0 +1,105 @@
1# This file is part of cloud-init. See LICENSE file for license information.
2
3"""Tests for cloudinit.dhclient_hook."""
4
5from cloudinit import dhclient_hook as dhc
6from cloudinit.tests.helpers import CiTestCase, dir2dict, populate_dir
7
8import argparse
9import json
10import mock
11import os
12
13
14class TestDhclientHook(CiTestCase):
15
16 ex_env = {
17 'interface': 'eth0',
18 'new_dhcp_lease_time': '3600',
19 'new_host_name': 'x1',
20 'new_ip_address': '10.145.210.163',
21 'new_subnet_mask': '255.255.255.0',
22 'old_host_name': 'x1',
23 'PATH': '/usr/sbin:/usr/bin:/sbin:/bin',
24 'pid': '614',
25 'reason': 'BOUND',
26 }
27
28 # some older versions of dhclient put the same content,
29 # but in upper case with DHCP4_ instead of new_
30 ex_env_dhcp4 = {
31 'REASON': 'BOUND',
32 'DHCP4_dhcp_lease_time': '3600',
33 'DHCP4_host_name': 'x1',
34 'DHCP4_ip_address': '10.145.210.163',
35 'DHCP4_subnet_mask': '255.255.255.0',
36 'INTERFACE': 'eth0',
37 'PATH': '/usr/sbin:/usr/bin:/sbin:/bin',
38 'pid': '614',
39 }
40
41 expected = {
42 'dhcp_lease_time': '3600',
43 'host_name': 'x1',
44 'ip_address': '10.145.210.163',
45 'subnet_mask': '255.255.255.0'}
46
47 def setUp(self):
48 super(TestDhclientHook, self).setUp()
49 self.tmp = self.tmp_dir()
50
51 def test_handle_args(self):
52 """quick test of call to handle_args."""
53 nic = 'eth0'
54 args = argparse.Namespace(event=dhc.UP, interface=nic)
55 with mock.patch.dict("os.environ", clear=True, values=self.ex_env):
56 dhc.handle_args(dhc.NAME, args, data_d=self.tmp)
57 found = dir2dict(self.tmp + os.path.sep)
58 self.assertEqual([nic + ".json"], list(found.keys()))
59 self.assertEqual(self.expected, json.loads(found[nic + ".json"]))
60
61 def test_run_hook_up_creates_dir(self):
62 """If dir does not exist, run_hook should create it."""
63 subd = self.tmp_path("subdir", self.tmp)
64 nic = 'eth1'
65 dhc.run_hook(nic, 'up', data_d=subd, env=self.ex_env)
66 self.assertEqual(
67 set([nic + ".json"]), set(dir2dict(subd + os.path.sep)))
68
69 def test_run_hook_up(self):
70 """Test expected use of run_hook_up."""
71 nic = 'eth0'
72 dhc.run_hook(nic, 'up', data_d=self.tmp, env=self.ex_env)
73 found = dir2dict(self.tmp + os.path.sep)
74 self.assertEqual([nic + ".json"], list(found.keys()))
75 self.assertEqual(self.expected, json.loads(found[nic + ".json"]))
76
77 def test_run_hook_up_dhcp4_prefix(self):
78 """Test run_hook filters correctly with older DHCP4_ data."""
79 nic = 'eth0'
80 dhc.run_hook(nic, 'up', data_d=self.tmp, env=self.ex_env_dhcp4)
81 found = dir2dict(self.tmp + os.path.sep)
82 self.assertEqual([nic + ".json"], list(found.keys()))
83 self.assertEqual(self.expected, json.loads(found[nic + ".json"]))
84
85 def test_run_hook_down_deletes(self):
86 """down should delete the created json file."""
87 nic = 'eth1'
88 populate_dir(
89 self.tmp, {nic + ".json": "{'abcd'}", 'myfile.txt': 'text'})
90 dhc.run_hook(nic, 'down', data_d=self.tmp, env={'old_host_name': 'x1'})
91 self.assertEqual(
92 set(['myfile.txt']),
93 set(dir2dict(self.tmp + os.path.sep)))
94
95 def test_get_parser(self):
96 """Smoke test creation of get_parser."""
97 # cloud-init main uses 'action'.
98 event, interface = (dhc.UP, 'mynic0')
99 self.assertEqual(
100 argparse.Namespace(event=event, interface=interface,
101 action=(dhc.NAME, dhc.handle_args)),
102 dhc.get_parser().parse_args([event, interface]))
103
104
105# vi: ts=4 expandtab
diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py
index 199d69b..d283f13 100644
--- a/tests/unittests/test_cli.py
+++ b/tests/unittests/test_cli.py
@@ -246,18 +246,18 @@ class TestCLI(test_helpers.FilesystemMockingTestCase):
246 self.assertEqual('cc_ntp', parseargs.name)246 self.assertEqual('cc_ntp', parseargs.name)
247 self.assertFalse(parseargs.report)247 self.assertFalse(parseargs.report)
248248
249 @mock.patch('cloudinit.cmd.main.dhclient_hook')249 @mock.patch('cloudinit.cmd.main.dhclient_hook.handle_args')
250 def test_dhclient_hook_subcommand(self, m_dhclient_hook):250 def test_dhclient_hook_subcommand(self, m_handle_args):
251 """The subcommand 'dhclient-hook' calls dhclient_hook with args."""251 """The subcommand 'dhclient-hook' calls dhclient_hook with args."""
252 self._call_main(['cloud-init', 'dhclient-hook', 'net_action', 'eth0'])252 self._call_main(['cloud-init', 'dhclient-hook', 'up', 'eth0'])
253 (name, parseargs) = m_dhclient_hook.call_args_list[0][0]253 (name, parseargs) = m_handle_args.call_args_list[0][0]
254 self.assertEqual('dhclient_hook', name)254 self.assertEqual('dhclient-hook', name)
255 self.assertEqual('dhclient-hook', parseargs.subcommand)255 self.assertEqual('dhclient-hook', parseargs.subcommand)
256 self.assertEqual('dhclient_hook', parseargs.action[0])256 self.assertEqual('dhclient-hook', parseargs.action[0])
257 self.assertFalse(parseargs.debug)257 self.assertFalse(parseargs.debug)
258 self.assertFalse(parseargs.force)258 self.assertFalse(parseargs.force)
259 self.assertEqual('net_action', parseargs.net_action)259 self.assertEqual('up', parseargs.event)
260 self.assertEqual('eth0', parseargs.net_interface)260 self.assertEqual('eth0', parseargs.interface)
261261
262 @mock.patch('cloudinit.cmd.main.main_features')262 @mock.patch('cloudinit.cmd.main.main_features')
263 def test_features_hook_subcommand(self, m_features):263 def test_features_hook_subcommand(self, m_features):

Subscribers

People subscribed via source and target branches