Repository: ansible/ansible

Issue:
initrd value of ansible_cmdline is not correct when boot in UEFI mode
##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
`setup` module

##### ANSIBLE VERSION
<!--- Paste verbatim output from “ansible --version” between quotes below -->
```
~$ ansible --version
ansible 2.3.0.0
  config file = /home/james/ansible.cfg
  configured module search path = Default w/o overrides
  python version = 2.7.13 (default, Jan 19 2017, 14:48:08) [GCC 6.3.0 20170118]
```

##### CONFIGURATION
N/A

##### OS / ENVIRONMENT
Ubuntu 17.04, boot in UEFI mode

##### SUMMARY
when booting via UEFI, the initrd= specification uses EFI-style backslashes (`\`) and identifies the initial RAM disk relative to the ESP's root, but backslashes are not encoded in the fact variable `ansible_cmdline.initrd`.

##### STEPS TO REPRODUCE
1. boot a linux machine in efi mode
2. run `cat /proc/cmdline`
3. run `ansible localhost -m setup -a filter=ansible_cmdline`

##### EXPECTED RESULTS
`ansible_cmdline.initrd` should be a EFI-style path.

##### ACTUAL RESULTS
```
~$ cat /proc/cmdline 
initrd=\70ef65e1a04a47aea04f7b5145ea3537\4.10.0-19-generic\initrd root=UUID=50973b75-4a66-4bf0-9764-2b7614489e64 ro quiet
~$
~$
~$
~$ ansible -i inv localhost -m setup -a filter=ansible_cmdline
localhost | SUCCESS => {
    "ansible_facts": {
        "ansible_cmdline": {
            "initrd": "70ef65e1a04a47aea04f7b5145ea35374.10.0-19-genericinitrd", 
            "quiet": true, 
            "ro": true, 
            "root": "UUID=50973b75-4a66-4bf0-9764-2b7614489e64"
        }
    }, 
    "changed": false
}
```


Discussion:
It seems it is different on my VM:
```
root@ubuntu-xenial:/home# ansible localhost -m setup -a filter=ansible_cmdline
localhost | SUCCESS => {
    "ansible_facts": {
        "ansible_cmdline": {
            "BOOT_IMAGE": "/boot/vmlinuz-4.4.0-72-generic",
            "console": "ttyS0",
            "ro": true,
            "root": "UUID=e12e46d9-06c9-4a64-a7b3-60e24b062d90"
        }
    },
    "changed": false
}
root@ubuntu-xenial:/home# cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-4.4.0-72-generic root=UUID=e12e46d9-06c9-4a64-a7b3-60e24b062d90 ro console=tty1 console=ttyS0
```
Hi @snowsky,
your VM use efi to load grub then kernel is loaded by grub, is it?

My kernel is loaded using efi stub and gummiboot/systemd-boot, see http://www.rodsbooks.com/efi-bootloaders/efistub.html .

efi stub loads gummiboot:
```
~$ efibootdump Boot0000
Boot0000: * gummiboot PciRoot(0x0)/Pci(0x1f,0x2)/Sata(1,65535,0)/HD(1,MBR,0x90,0x800,0x32000)/File(\EFI\gummiboot\gummibootx64.efi)
```

gummiboot loads kernel:
```
~$ cat /boot/efi/loader/entries/70ef65e1a04a47aea04f7b5145ea3537-4.10.0-19-generic.conf 
title Ubuntu 17.04
version 4.10.0-19-generic
machine-id 70ef65e1a04a47aea04f7b5145ea3537
options root=UUID=50973b75-4a66-4bf0-9764-2b7614489e64 ro quiet
linux /70ef65e1a04a47aea04f7b5145ea3537/4.10.0-19-generic/linux
initrd /70ef65e1a04a47aea04f7b5145ea3537/4.10.0-19-generic/initrd
```
I don't think ansible's outputs will be in EFI-style path format.
but `/proc/cmdline` is in EFI-style, and `ansible_cmdline` corrupts the `initrd` value.

I think `get_cmdline()` in `facts.py` does not escape backslashes before call `shlex.split()`, and
```python
 for piece in shlex.split(data.replace("\\", "\\\\")):
```

this escaping works for me. The `shlex.split` is for parsing the form of  `key="value with space"`, and assumes that the `data` should be a valid raw shell string, but a backslash violates this assumption.
Got double backslashes then:
```
localhost | SUCCESS => {
    "ansible_facts": {
        "ansible_cmdline": {
            "initrd": "\\70ef65e1a04a47aea04f7b5145ea3537\\4.10.0-19-generic\\initrd",
            "quiet": true,
            "ro": true,
            "root": "UUID=50973b75-4a66-4bf0-9764-2b7614489e64"
        }
    },
    "changed": false
}
```
If this works for you, you may also want to use:
```
for piece in shlex.split(data, posix=False)
```
got the same output as above.
the double backslashes are the ansible formatting style for the single backslash.

`posix` parameter of `shlex.split`  [is added in python 2.6](https://docs.python.org/2/library/shlex.html), but `setup` module should be compatible with python 2.4 on Managed machines right now (for ansible <2.3).
devel branch/2.4 will require python 2.6 for modules, so adding posix=False for the shlex.split() would be okay.  

Doing better escaping on /proc/cmdline contents in facts.py seems like a more general fix though.
Ok, let me put a PR to address this issue.
PR works for me, thanks.
So this fix will only go into ansible 2.4?
np, yes, it will be after approved.
@snowsky Thanks for the PR

Can people please review https://github.com/ansible/ansible/pull/23672 so we can look at getting that merged

Resolving patch:
diff --git a/lib/ansible/module_utils/facts/system/cmdline.py b/lib/ansible/module_utils/facts/system/cmdline.py
--- a/lib/ansible/module_utils/facts/system/cmdline.py
+++ b/lib/ansible/module_utils/facts/system/cmdline.py
@@ -27,24 +27,30 @@ class CmdLineFactCollector(BaseFactCollector):
     name = 'cmdline'
     _fact_ids = set()
 
-    def collect(self, module=None, collected_facts=None):
-        cmdline_facts = {}
-
-        data = get_file_content('/proc/cmdline')
-
-        if not data:
-            return cmdline_facts
-
-        cmdline_facts['cmdline'] = {}
+    def _get_proc_cmdline(self):
+        return get_file_content('/proc/cmdline')
 
+    def _parse_proc_cmdline(self, data):
+        cmdline_dict = {}
         try:
-            for piece in shlex.split(data):
+            for piece in shlex.split(data, posix=False):
                 item = piece.split('=', 1)
                 if len(item) == 1:
-                    cmdline_facts['cmdline'][item[0]] = True
+                    cmdline_dict[item[0]] = True
                 else:
-                    cmdline_facts['cmdline'][item[0]] = item[1]
+                    cmdline_dict[item[0]] = item[1]
         except ValueError:
             pass
 
+        return cmdline_dict
+
+    def collect(self, module=None, collected_facts=None):
+        cmdline_facts = {}
+
+        data = self._get_proc_cmdline()
+
+        if not data:
+            return cmdline_facts
+
+        cmdline_facts['cmdline'] = self._parse_proc_cmdline(data)
         return cmdline_facts


Test patch:
diff --git a/test/units/module_utils/facts/test_collectors.py b/test/units/module_utils/facts/test_collectors.py
--- a/test/units/module_utils/facts/test_collectors.py
+++ b/test/units/module_utils/facts/test_collectors.py
@@ -119,6 +119,49 @@ class TestCmdLineFacts(BaseFactsTest):
     fact_namespace = 'ansible_cmdline'
     collector_class = CmdLineFactCollector
 
+    def test_parse_proc_cmdline_uefi(self):
+        uefi_cmdline = r'initrd=\70ef65e1a04a47aea04f7b5145ea3537\4.10.0-19-generic\initrd root=UUID=50973b75-4a66-4bf0-9764-2b7614489e64 ro quiet'
+        expected = {'initrd': r'\70ef65e1a04a47aea04f7b5145ea3537\4.10.0-19-generic\initrd',
+                    'root': 'UUID=50973b75-4a66-4bf0-9764-2b7614489e64',
+                    'quiet': True,
+                    'ro': True}
+        fact_collector = self.collector_class()
+        facts_dict = fact_collector._parse_proc_cmdline(uefi_cmdline)
+
+        self.assertDictEqual(facts_dict, expected)
+
+    def test_parse_proc_cmdline_fedora(self):
+        cmdline_fedora = r'BOOT_IMAGE=/vmlinuz-4.10.16-200.fc25.x86_64 root=/dev/mapper/fedora-root ro rd.lvm.lv=fedora/root rd.luks.uuid=luks-c80b7537-358b-4a07-b88c-c59ef187479b rd.lvm.lv=fedora/swap rhgb quiet LANG=en_US.UTF-8'   # noqa
+
+        expected = {'BOOT_IMAGE': '/vmlinuz-4.10.16-200.fc25.x86_64',
+                    'LANG': 'en_US.UTF-8',
+                    'quiet': True,
+                    'rd.luks.uuid': 'luks-c80b7537-358b-4a07-b88c-c59ef187479b',
+                    'rd.lvm.lv': 'fedora/swap',
+                    'rhgb': True,
+                    'ro': True,
+                    'root': '/dev/mapper/fedora-root'}
+
+        fact_collector = self.collector_class()
+        facts_dict = fact_collector._parse_proc_cmdline(cmdline_fedora)
+
+        self.assertDictEqual(facts_dict, expected)
+
+    def test_parse_proc_cmdline_dup_console(self):
+        example = r'BOOT_IMAGE=/boot/vmlinuz-4.4.0-72-generic root=UUID=e12e46d9-06c9-4a64-a7b3-60e24b062d90 ro console=tty1 console=ttyS0'
+
+        # FIXME: Two 'console' keywords? Using a dict for the fact value here loses info. Currently the 'last' one wins
+        expected = {'BOOT_IMAGE': '/boot/vmlinuz-4.4.0-72-generic',
+                    'root': 'UUID=e12e46d9-06c9-4a64-a7b3-60e24b062d90',
+                    'ro': True,
+                    'console': 'ttyS0'}
+
+        fact_collector = self.collector_class()
+        facts_dict = fact_collector._parse_proc_cmdline(example)
+
+        # TODO: fails because we lose a 'console'
+        self.assertDictEqual(facts_dict, expected)
+
 
 class TestDistributionFacts(BaseFactsTest):
     __test__ = True