summary refs log tree commit diff
path: root/tools
diff options
context:
space:
mode:
authorMichael Biebl <biebl@debian.org>2023-08-09 21:55:35 +0200
committerMichael Biebl <biebl@debian.org>2023-08-09 21:55:35 +0200
commit05e4a733f2141995181a551854d5df929f084adf (patch)
tree83bb937740a6667525ba0df046748ecaa829c269 /tools
parent14b0f3a9dc9ea90d60a3b057350fd4d637dc021a (diff)
New upstream version 1.44.0 upstream/1.44.0
Diffstat (limited to 'tools')
-rwxr-xr-xtools/check-docs.sh2
-rwxr-xr-xtools/check-nm-autoptr.sh26
-rwxr-xr-xtools/check-vapi.sh25
-rwxr-xr-xtools/create-exports-NetworkManager.sh2
-rwxr-xr-xtools/generate-docs-nm-property-infos.py23
-rwxr-xr-xtools/generate-docs-nm-settings-docs-gir.py17
-rwxr-xr-xtools/generate-docs-nm-settings-docs-merge.py171
-rwxr-xr-xtools/meson-post-install.sh1
-rwxr-xr-xtools/test-cloud-meta-mock.py285
-rwxr-xr-xtools/test-networkmanager-service.py90
10 files changed, 569 insertions, 73 deletions
diff --git a/tools/check-docs.sh b/tools/check-docs.sh
index 2746848c..b0fd3131 100755
--- a/tools/check-docs.sh
+++ b/tools/check-docs.sh
@@ -39,7 +39,7 @@ libnm_headers() {
 
 SOURCEDIR="$1"
 BUILDDIR="$2"
-if test "$SOURCEDIR" == "$BUILDDIR"; then
+if test "$SOURCEDIR" = "$BUILDDIR"; then
     BUILDDIR=
 fi
 [ -z "$SOURCEDIR" ] && SOURCEDIR='.'
diff --git a/tools/check-nm-autoptr.sh b/tools/check-nm-autoptr.sh
new file mode 100755
index 00000000..466958c8
--- /dev/null
+++ b/tools/check-nm-autoptr.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+
+set -e
+
+die() {
+    printf "%s\n" "$*" >&2
+    exit 1
+}
+
+cd "$(dirname "$(readlink -f "$0")")/.." || die "cannot change to srcdir"
+
+AUTOPTR_H=./src/libnm-client-public/nm-autoptr.h
+
+for s in $( sed -n 's/^ *typedef \+struct \+.*[A-Za-z0-9_]\+ \+\(NMSetting[A-Za-z0-9_]\+\)\> *;$/\1/p' ./src/libnm-core-public/nm-core-types.h ) ; do
+    grep -q "^ *G_DEFINE_AUTOPTR_CLEANUP_FUNC *( *\\<$s\\> *, *g_object_unref *)" -- "$AUTOPTR_H" || die "didn't see setting '$s' in \"$AUTOPTR_H\""
+done
+
+for s in $( grep -h -o '\<NMDevice[A-Za-z0-9_]\+\>' ./src/libnm-client-public/nm-device-*.h | sort -u | grep -v 'Class$') ; do
+    case "$s" in
+        NMDeviceModemCapabilities| \
+        NMDeviceWifiCapabilities)
+            continue
+            ;;
+    esac
+    grep -q "^ *G_DEFINE_AUTOPTR_CLEANUP_FUNC *( *\\<$s\\> *, *g_object_unref *)" -- "$AUTOPTR_H" || die "didn't see device '$s' in \"$AUTOPTR_H\""
+done
diff --git a/tools/check-vapi.sh b/tools/check-vapi.sh
new file mode 100755
index 00000000..da640cfd
--- /dev/null
+++ b/tools/check-vapi.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+
+set -e
+
+die() {
+    printf "%s\n" "$*" >&2
+    exit 1
+}
+
+cd "$(dirname "$(readlink -f "$0")")/.." || die "cannot change to srcdir"
+
+VAPI=./vapi/NM-1.0.metadata
+
+for s in $(grep -r -h '#define \+\<NM_SETTING_.*SETTING_NAME\>' -- ./src/libnm-core-public/ \
+           | sed -n 's/^#define \+NM_\(SETTING_[A-Z0-9_]\+\)_SETTING_NAME\> \+.*/\1/p') ; do
+    grep -q "^$s" -- "$VAPI" || die "didn't see '$s' in \"$VAPI\""
+done
+
+for f in ./src/libnm-client-public/nm-device-*.h ; do
+    D=( $(sed -n 's/^#define \+NM_IS_DEVICE_\([A-Z0-9_]\+\)_CLASS\>(.*/\1/p' "$f") )
+    test ${#D[@]} = 1 || die "did not detect device in \"$f\""
+    s="${D[0]}"
+    c="$(grep -c "^DEVICE_${s}_\* *parent=" -- "$VAPI")"
+    test "$c" = 1 || die "didn't see device '$s' in \"$VAPI\""
+done
diff --git a/tools/create-exports-NetworkManager.sh b/tools/create-exports-NetworkManager.sh
index 21ba369a..ef4b381a 100755
--- a/tools/create-exports-NetworkManager.sh
+++ b/tools/create-exports-NetworkManager.sh
@@ -71,7 +71,7 @@ get_symbols_missing() {
 }
 
 pretty() {
-    sed 's/.*/\t\0;/'
+    sed 's/.*/\t&;/'
 }
 
 do_build() {
diff --git a/tools/generate-docs-nm-property-infos.py b/tools/generate-docs-nm-property-infos.py
index 58ad751f..d7e3c8c2 100755
--- a/tools/generate-docs-nm-property-infos.py
+++ b/tools/generate-docs-nm-property-infos.py
@@ -116,7 +116,7 @@ def get_file_infos(source_files):
 
 
 KEYWORD_XML_TYPE_NESTED = "nested"
-KEYWORD_XML_TYPE_NODE = "node"
+KEYWORD_XML_TYPE_ELEM = "elem"
 KEYWORD_XML_TYPE_ATTR = "attr"
 
 keywords = collections.OrderedDict(
@@ -127,7 +127,7 @@ keywords = collections.OrderedDict(
         ("values", KEYWORD_XML_TYPE_ATTR),
         ("default", KEYWORD_XML_TYPE_ATTR),
         ("example", KEYWORD_XML_TYPE_ATTR),
-        ("description", KEYWORD_XML_TYPE_ATTR),
+        ("description", KEYWORD_XML_TYPE_ELEM),
         ("description-docbook", KEYWORD_XML_TYPE_NESTED),
     ]
 )
@@ -163,7 +163,7 @@ def write_data(tag, setting_node, line_no, parsed_data):
             # Set as XML nodes. The input data is XML itself.
             des = ET.fromstring("<%s>%s</%s>" % (k, v, k))
             property_node.append(des)
-        elif xmltype == KEYWORD_XML_TYPE_NODE:
+        elif xmltype == KEYWORD_XML_TYPE_ELEM:
             node = ET.SubElement(property_node, k)
             node.text = v
         elif xmltype == KEYWORD_XML_TYPE_ATTR:
@@ -171,6 +171,23 @@ def write_data(tag, setting_node, line_no, parsed_data):
         else:
             assert False
 
+    if (
+        parsed_data.get("description", None) is not None
+        and parsed_data.get("description-docbook", None) is None
+    ):
+        # we have a description, but no docbook. Generate one.
+        node = ET.SubElement(property_node, "description-docbook")
+        for l in re.split("\n", parsed_data["description"]):
+            paragraph = ET.SubElement(node, "para")
+            paragraph.text = l
+    elif (
+        parsed_data.get("description-docbook", None) is not None
+        and parsed_data.get("description", None) is None
+    ):
+        raise Exception(
+            'Invalid configuration. When specifying "description-docbook:" there MUST be also a  "description:"'
+        )
+
 
 kwd_first_line_re = re.compile(r"^ *\* ([-a-z0-9]+): (.*)$")
 kwd_more_line_re = re.compile(r"^ *\*( *)(.*?)\s*$")
diff --git a/tools/generate-docs-nm-settings-docs-gir.py b/tools/generate-docs-nm-settings-docs-gir.py
index 88b87a72..b8f29d3b 100755
--- a/tools/generate-docs-nm-settings-docs-gir.py
+++ b/tools/generate-docs-nm-settings-docs-gir.py
@@ -203,7 +203,6 @@ def settings_sort_key(x):
 
 def create_desc_docbook(desc_docbook, description):
     lines = re.split("\n", description)
-    in_par = True
 
     paragraph = ET.SubElement(
         desc_docbook,
@@ -211,10 +210,11 @@ def create_desc_docbook(desc_docbook, description):
     )
 
     for l in lines:
-        if l and in_par:
-            paragraph.text = l
-        else:
+        if not l:
+            # A blank line. This starts a new paragraph
             paragraph = ET.SubElement(desc_docbook, "para")
+            continue
+        paragraph.text = l
 
 
 def main(gir_path_str, output_path_str):
@@ -328,12 +328,9 @@ def main(gir_path_str, output_path_str):
                     },
                 ).text = deprecated_desc
 
-                deprecated_docbook = ET.SubElement(
-                    property_element,
-                    "deprecated-docbook",
-                )
-
-                create_desc_docbook(deprecated_docbook, deprecated_desc)
+                # The text should only be one line. Otherwise, our simple "<deprecated>" element
+                # cannot be rendered nicely.
+                assert re.split("\n", deprecated_desc) == [deprecated_desc]
 
     docs_gir.write(
         output_path_str,
diff --git a/tools/generate-docs-nm-settings-docs-merge.py b/tools/generate-docs-nm-settings-docs-merge.py
index 0131a024..a927400f 100755
--- a/tools/generate-docs-nm-settings-docs-merge.py
+++ b/tools/generate-docs-nm-settings-docs-merge.py
@@ -4,15 +4,19 @@
 from __future__ import print_function
 
 import collections
+import os
 import sys
 import xml.etree.ElementTree as ET
 
 ###############################################################################
 
 
+DEBUG = os.environ.get("NM_GENERATE_DOCS_NM_SETTINGS_DOCS_MERGE_DEBUG", None) == "1"
+
+
 def dbg(msg):
-    pass
-    # print("%s" % (msg,))
+    if DEBUG:
+        print("%s" % (msg,))
 
 
 ###############################################################################
@@ -113,40 +117,135 @@ def node_set_attr(dst_node, name, nodes):
 
 def find_attr(properties_attrs, name):
     for p_attr in properties_attrs:
-        if p_attr is not None:
-            p_attr = p_attr.find(name)
+        if p_attr is None:
+            continue
+        p_attr = p_attr.find(name)
         if p_attr is not None:
             return p_attr
 
 
+def find_description(properties_attrs):
+    for p in properties_attrs:
+        if p is None:
+            continue
+
+        # These are not attributes, but XML element.
+        assert p.get("description", None) is None
+        assert p.get("description-docbook", None) is None
+
+        p_elem = p.find("description")
+        p_elem_docbook = p.find("description-docbook")
+
+        if p_elem is not None or p_elem_docbook is not None:
+            if p_elem is None or p_elem_docbook is None:
+                # invalid input!
+                if p_elem:
+                    s = ET.tostring(p_elem)
+                else:
+                    s = ET.tostring(p_elem_docbook)
+                raise Exception(
+                    "We expect both a <description> and <description-docbook> tag, but we only have %s"
+                    % (s,)
+                )
+            return p_elem, p_elem_docbook
+
+    return None, None
+
+
+def find_deprecated(properties_attrs):
+    for p in properties_attrs:
+        if p is None:
+            continue
+
+        # These are not attributes, but XML element.
+        assert p.get("deprecated", None) is None
+        assert p.get("deprecated-docbook", None) is None
+
+        # We don't expect a <deprecated-docbook> tag.
+        assert p.find("deprecated-docbook") is None
+
+        p_elem = p.find("deprecated")
+
+        if p_elem is not None:
+            # We require a "since" attribute
+            assert p_elem.get("since", None) is not None
+            return p_elem
+
+    return None
+
+
 ###############################################################################
 
 gl_only_from_first = False
 
-argv = list(sys.argv[1:])
-while True:
-    if argv[0] == "--only-from-first":
-        gl_only_from_first = True
-        del argv[0]
-        continue
-    break
-if len(argv) < 2:
-    print("%s [--only-from-first] [OUT_FILE] [SETTING_XML [...]]" % (sys.argv[0]))
-    exit(1)
-
-gl_output_xml_file = argv[0]
-gl_input_files = list(argv[1:])
+gl_only_properties_from = None
+gl_output_xml_file = None
+gl_input_files = []
+
+
+def usage_and_quit(exit_code):
+    print(
+        "%s [--only-properties-from SLECTOR_FILE] [OUT_FILE] [SETTING_XML [...]]"
+        % (sys.argv[0])
+    )
+    exit(exit_code)
+
+
+i = 1
+special_args = True
+while i < len(sys.argv):
+    if special_args and sys.argv[i] in ["-h", "--help"]:
+        usage_and_quit(0)
+    elif special_args and sys.argv[i] == "--only-properties-from":
+        i += 1
+        gl_only_properties_from = sys.argv[i]
+    elif special_args and sys.argv[i] == "--":
+        special_args = False
+    elif gl_output_xml_file is None:
+        gl_output_xml_file = sys.argv[i]
+    else:
+        gl_input_files.append(sys.argv[i])
+    i += 1
+if len(gl_input_files) < 2:
+    usage_and_quit(1)
 
 ###############################################################################
 
 for f in gl_input_files:
     dbg("> input file %s" % (f))
 
-xml_roots = list([ET.parse(f).getroot() for f in gl_input_files])
+xml_roots = [ET.parse(f).getroot() for f in gl_input_files]
 
 assert all([root.tag == "nm-setting-docs" for root in xml_roots])
 
-settings_roots = list([node_to_dict(root, "setting", "name") for root in xml_roots])
+
+def skip_property(setting_name, property_name):
+    return False
+
+
+if gl_only_properties_from:
+    xml_root = ET.parse(gl_only_properties_from).getroot()
+    opf_setting_root = node_to_dict(xml_root, "setting", "name")
+    opf_cache = {}
+
+    def skip_property(setting_name, property_name):
+        if setting_name not in opf_cache:
+            s = opf_setting_root.get(setting_name)
+            if s is not None:
+                s = node_to_dict(s, "property", "name")
+            opf_cache[setting_name] = s
+        else:
+            s = opf_cache[setting_name]
+        if not s:
+            return True
+        if property_name is not None:
+            p = s.get(property_name)
+            if p is None:
+                return True
+        return False
+
+
+settings_roots = [node_to_dict(root, "setting", "name") for root in xml_roots]
 
 root_node = ET.Element("nm-setting-docs")
 
@@ -154,17 +253,13 @@ for setting_name in iter_keys_of_dicts(settings_roots, key_fcn_setting_name):
 
     dbg("> > setting_name: %s" % (setting_name))
 
-    settings = list([d.get(setting_name) for d in settings_roots])
-
-    if gl_only_from_first and settings[0] is None:
-        dbg("> > > skip (only-from-first")
+    if skip_property(setting_name, None):
+        dbg("> > > skip (only-properties-from)")
         continue
 
-    properties = list([node_to_dict(s, "property", "name") for s in settings])
+    settings = [d.get(setting_name) for d in settings_roots]
 
-    if gl_only_from_first and not properties[0]:
-        dbg("> > > skip (no properties")
-        continue
+    properties = [node_to_dict(s, "property", "name") for s in settings]
 
     setting_node = ET.SubElement(root_node, "setting")
 
@@ -180,14 +275,13 @@ for setting_name in iter_keys_of_dicts(settings_roots, key_fcn_setting_name):
 
         dbg("> > > > property_name: %s" % (property_name))
 
-        properties_attrs = list([p.get(property_name) for p in properties])
-        description_docbook = find_attr(properties_attrs, "description-docbook")
-        description = find_attr(properties_attrs, "description")
-        deprecated_docbook = find_attr(properties_attrs, "deprecated-docbook")
-        deprecated = find_attr(properties_attrs, "deprecated")
+        properties_attrs = [p.get(property_name) for p in properties]
 
-        if gl_only_from_first and properties_attrs[0] is None:
-            dbg("> > > > skip (only-from-first")
+        description, description_docbook = find_description(properties_attrs)
+        deprecated = find_deprecated(properties_attrs)
+
+        if skip_property(setting_name, property_name):
+            dbg("> > > > skip (only-properties-from)")
             continue
 
         property_node = ET.SubElement(setting_node, "property")
@@ -203,20 +297,13 @@ for setting_name in iter_keys_of_dicts(settings_roots, key_fcn_setting_name):
             node_set_attr(property_node, "type", properties_attrs)
 
         node_set_attr(property_node, "default", properties_attrs)
-        desc_value = node_get_attr(properties_attrs, "description")
         node_set_attr(property_node, "alias", properties_attrs)
 
         if description_docbook is not None:
             property_node.insert(0, description_docbook)
-        if desc_value:
-            description = ET.Element("description")
-            description.text = desc_value
-            property_node.append(description)
-        elif description is not None:
+        if description is not None:
             property_node.append(description)
 
-        if deprecated_docbook is not None:
-            property_node.insert(0, deprecated_docbook)
         if deprecated is not None:
             property_node.insert(0, deprecated)
 
diff --git a/tools/meson-post-install.sh b/tools/meson-post-install.sh
index 45f3e37c..fe85d417 100755
--- a/tools/meson-post-install.sh
+++ b/tools/meson-post-install.sh
@@ -55,6 +55,7 @@ if [ "$enable_ifcfg_rh" = 1 ]; then
 fi
 
 if [ "$enable_nm_cloud_setup" = 1 -a "$install_systemdunitdir" = 1 ]; then
+    ln -sfn '../pre-up.d/90-nm-cloud-setup.sh' "${DESTDIR}${nm_pkglibdir}/dispatcher.d/no-wait.d/90-nm-cloud-setup.sh"
     ln -sfn 'no-wait.d/90-nm-cloud-setup.sh' "${DESTDIR}${nm_pkglibdir}/dispatcher.d/90-nm-cloud-setup.sh"
 fi
 
diff --git a/tools/test-cloud-meta-mock.py b/tools/test-cloud-meta-mock.py
new file mode 100755
index 00000000..bc0a829e
--- /dev/null
+++ b/tools/test-cloud-meta-mock.py
@@ -0,0 +1,285 @@
+#!/usr/bin/env python
+
+# A service that mocks up various metadata providers. Used for testing,
+# can also be used standalone as a development aid.
+#
+# To run standalone:
+#
+# run:     $ systemd-socket-activate -l 8000 python tools/test-cloud-meta-mock.py &
+#          $ NM_CLOUD_SETUP_EC2_HOST=http://localhost:8000 \
+#            NM_CLOUD_SETUP_LOG=trace \
+#            NM_CLOUD_SETUP_EC2=yes src/nm-cloud-setup/nm-cloud-setup
+# or just: $ python tools/test-cloud-meta-mock.py
+#
+# By default, the utility will server some resources for each known cloud
+# providers, for convenience. The tests start this with "--empty" argument,
+# which starts with no resources.
+
+import os
+import socket
+import sys
+
+from http.server import HTTPServer
+from http.server import BaseHTTPRequestHandler
+from socketserver import BaseServer
+
+
+PROVIDERS = [
+    "aliyun",
+    "azure",
+    "ec2",
+    "gcp",
+]
+
+
+def _s_to_bool(s):
+    s0 = s
+    if isinstance(s, bytes):
+        s = s.encode("utf-8", errors="replace")
+    if isinstance(s, str):
+        s = s.lower()
+        if s in ["yes", "y", "true", "1"]:
+            return True
+        if s in ["no", "n", "false", "0"]:
+            return False
+    if isinstance(s, int):
+        if s in [0, 1]:
+            return s == 1
+    raise ValueError('Not a boolean value ("%s")' % (s0,))
+
+
+DEBUG = _s_to_bool(os.environ.get("NM_TEST_CLOUD_SETUP_MOCK_DEBUG", "0"))
+
+
+def dbg(msg):
+    if DEBUG:
+        print("DBG: %s" % (msg,))
+
+
+class MockCloudMDRequestHandler(BaseHTTPRequestHandler):
+    """
+    Respond to cloud metadata service requests.
+    Currently implements a fairly minimal subset of AWS EC2 API.
+    """
+
+    def log_message(self, format, *args):
+        pass
+
+    def _response_and_end(self, code, write=None):
+        self.send_response(code)
+        self.end_headers()
+        if write is None:
+            dbg("response %s" % (code,))
+        else:
+            if isinstance(write, str):
+                write = write.encode("utf-8")
+            dbg("response %s, %s" % (code, write))
+            self.wfile.write(write)
+
+    def _read(self):
+        length = int(self.headers["content-length"])
+        v = self.rfile.read(length)
+        dbg('receive "%s"' % (v,))
+        return v
+
+    def do_GET(self):
+        path = self.path.encode("ascii")
+        dbg("GET %s" % (path,))
+        r = None
+        if path in self.server._resources:
+            r = self.server._resources[path]
+        elif self.server.config_get_allow_default():
+            for p in self.server.config_get_providers():
+                if path in DEFAULT_RESOURCES[p]:
+                    r = DEFAULT_RESOURCES[p][path]
+                    break
+        if r is None:
+            self._response_and_end(404)
+            return
+        self._response_and_end(200, write=r)
+
+    def do_PUT(self):
+        path = self.path.encode("ascii")
+        dbg("PUT %s" % (path,))
+        if path.startswith(b"/.nmtest/"):
+            conf_name = path[len(b"/.nmtest/") :]
+            v = self._read()
+
+            self.server._config[conf_name] = v
+
+            assert self.server.config_get_providers() is not None
+            assert self.server.config_get_allow_default() is not None
+
+            self._response_and_end(201)
+        elif path == b"/latest/api/token":
+            if "ec2" not in self.server.config_get_providers():
+                self._response_and_end(404)
+            else:
+                self._response_and_end(
+                    200,
+                    write="AQAAALH-k7i18JMkK-ORLZQfAa7nkNjQbKwpQPExNHqzk1oL_7eh-A==",
+                )
+        else:
+            self.server._resources[path] = self._read()
+            self._response_and_end(201)
+
+    def do_DELETE(self):
+        path = self.path.encode("ascii")
+        dbg("DELETE %s" % (path,))
+        if path in self.server._resources:
+            del self.server._resources[path]
+            self._response_and_end(204)
+        else:
+            self._response_and_end(404)
+
+
+class SocketHTTPServer(HTTPServer):
+    """
+    A HTTP server that accepts a socket (that has already been
+    listen()-ed on). This is useful when the socket is passed
+    fron the test runner.
+    """
+
+    def __init__(
+        self,
+        server_address,
+        RequestHandlerClass,
+        socket,
+        resources=None,
+        allow_default=True,
+    ):
+        BaseServer.__init__(self, server_address, RequestHandlerClass)
+        self.socket = socket
+        self.server_address = self.socket.getsockname()
+        self._resources = resources or {}
+        self._config = {
+            "allow-default": "yes" if allow_default else "no",
+        }
+
+    def config_get_providers(self):
+        conf = self._config.get(b"providers", None)
+        if not conf:
+            return PROVIDERS
+        parsed = [s.lower() for s in conf.decode("utf-8", errors="replace").split(" ")]
+        assert all(p in PROVIDERS for p in parsed)
+        return parsed
+
+    def config_get_allow_default(self):
+        return _s_to_bool(self._config.get(b"allow-default", "yes"))
+
+
+def create_default_resources_for_provider(provider):
+
+    mac1 = b"cc:00:00:00:00:01"
+    mac2 = b"cc:00:00:00:00:02"
+
+    ip1 = b"172.31.26.249"
+    ip2 = b"172.31.176.249"
+
+    if provider == "aliyun":
+        aliyun_meta = b"/2016-01-01/meta-data/"
+        aliyun_macs = aliyun_meta + b"network/interfaces/macs/"
+        return {
+            aliyun_meta: b"ami-id\n",
+            aliyun_macs: mac2 + b"\n" + mac1,
+            aliyun_macs + mac2 + b"/vpc-cidr-block": b"172.31.16.0/20",
+            aliyun_macs + mac2 + b"/private-ipv4s": ip1,
+            aliyun_macs + mac2 + b"/primary-ip-address": ip1,
+            aliyun_macs + mac2 + b"/netmask": b"255.255.255.0",
+            aliyun_macs + mac2 + b"/gateway": b"172.31.26.2",
+            aliyun_macs + mac1 + b"/vpc-cidr-block": b"172.31.166.0/20",
+            aliyun_macs + mac1 + b"/private-ipv4s": ip2,
+            aliyun_macs + mac1 + b"/primary-ip-address": ip2,
+            aliyun_macs + mac1 + b"/netmask": b"255.255.255.0",
+            aliyun_macs + mac1 + b"/gateway": b"172.31.176.2",
+        }
+
+    if provider == "azure":
+        azure_meta = b"/metadata/instance"
+        azure_iface = azure_meta + b"/network/interface/"
+        azure_query = b"?format=text&api-version=2017-04-02"
+        return {
+            azure_meta + azure_query: b"",
+            azure_iface + azure_query: b"0\n1\n",
+            azure_iface + b"0/macAddress" + azure_query: mac1,
+            azure_iface + b"1/macAddress" + azure_query: mac2,
+            azure_iface + b"0/ipv4/ipAddress/" + azure_query: b"0\n",
+            azure_iface + b"1/ipv4/ipAddress/" + azure_query: b"0\n",
+            azure_iface + b"0/ipv4/ipAddress/0/privateIpAddress" + azure_query: ip1,
+            azure_iface + b"1/ipv4/ipAddress/0/privateIpAddress" + azure_query: ip2,
+            azure_iface + b"0/ipv4/subnet/0/address/" + azure_query: b"172.31.16.0",
+            azure_iface + b"1/ipv4/subnet/0/address/" + azure_query: b"172.31.166.0",
+            azure_iface + b"0/ipv4/subnet/0/prefix/" + azure_query: b"20",
+            azure_iface + b"1/ipv4/subnet/0/prefix/" + azure_query: b"20",
+        }
+
+    if provider == "ec2":
+        ec2_macs = b"/2018-09-24/meta-data/network/interfaces/macs/"
+        return (
+            {
+                b"/latest/meta-data/": b"ami-id\n",
+                ec2_macs: mac2 + b"\n" + mac1,
+                ec2_macs + mac2 + b"/subnet-ipv4-cidr-block": b"172.31.16.0/20",
+                ec2_macs + mac2 + b"/local-ipv4s": ip1,
+                ec2_macs + mac1 + b"/subnet-ipv4-cidr-block": b"172.31.166.0/20",
+                ec2_macs + mac1 + b"/local-ipv4s": ip2,
+            },
+        )
+
+    if provider == "gcp":
+        gcp_meta = b"/computeMetadata/v1/instance/"
+        gcp_iface = gcp_meta + b"network-interfaces/"
+        return {
+            gcp_meta + b"id": b"",
+            gcp_iface: b"0\n1\n",
+            gcp_iface + b"0/mac": mac1,
+            gcp_iface + b"1/mac": mac2,
+            gcp_iface + b"0/forwarded-ips/": b"0\n",
+            gcp_iface + b"0/forwarded-ips/0": ip1,
+            gcp_iface + b"1/forwarded-ips/": b"0\n",
+            gcp_iface + b"1/forwarded-ips/0": ip2,
+        }
+
+    raise ValueError("invalid provider %s" % (provider,))
+
+
+def create_default_resources():
+
+    return {p: create_default_resources_for_provider(p) for p in PROVIDERS}
+
+
+DEFAULT_RESOURCES = create_default_resources()
+
+
+allow_default = True
+try:
+    if sys.argv[1] == "--empty":
+        allow_default = False
+except IndexError:
+    pass
+
+# See sd_listen_fds(3)
+fileno = os.getenv("LISTEN_FDS")
+if fileno is not None:
+    if fileno != "1":
+        raise Exception("Bad LISTEN_FDS")
+    s = socket.socket(fileno=3)
+else:
+    addr = ("localhost", 0)
+    s = socket.socket()
+    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+    s.bind(addr)
+
+httpd = SocketHTTPServer(
+    None,
+    MockCloudMDRequestHandler,
+    socket=s,
+    allow_default=allow_default,
+)
+
+print("Listening on http://%s:%d" % (httpd.server_address[0], httpd.server_address[1]))
+httpd.server_activate()
+
+httpd.serve_forever()
+httpd.server_close()
diff --git a/tools/test-networkmanager-service.py b/tools/test-networkmanager-service.py
index a7788510..9a423123 100755
--- a/tools/test-networkmanager-service.py
+++ b/tools/test-networkmanager-service.py
@@ -288,8 +288,15 @@ class Util:
         return (Util.ip_addr_ntop(a, family), family)
 
     @staticmethod
-    def eprint(*args, **kwargs):
-        print(*args, file=sys.stderr, **kwargs)
+    def log(message):
+        if gl.log_file:
+            try:
+                gl.log_file.write(message + "\n")
+                gl.log_file.flush()
+            except Exception:
+                pass
+        else:
+            print(message, file=sys.stderr)
 
     @staticmethod
     def variant_from_dbus(val):
@@ -308,9 +315,9 @@ class Util:
         if isinstance(val, dbus.Array):
             try:
                 if val.signature == "s":
-                    return GLib.Variant("as", [Util.variant_from_dbus(x) for x in val])
+                    return GLib.Variant("as", [str(x) for x in val])
                 if val.signature == "b":
-                    return GLib.Variant("ab", [Util.variant_from_dbus(x) for x in val])
+                    return GLib.Variant("ab", [bool(x) for x in val])
                 if val.signature == "y":
                     return GLib.Variant("ay", [int(x) for x in val])
                 if val.signature == "u":
@@ -342,8 +349,7 @@ class Util:
                     )
             except Exception as e:
                 raise Exception(
-                    "Cannot convert array element to type '%s': %s"
-                    % (val.signature, e.message)
+                    "Cannot convert array element to type '%s': %s" % (val.signature, e)
                 )
         if isinstance(val, dbus.Dictionary):
             if val.signature == "ss":
@@ -358,7 +364,7 @@ class Util:
                         [(str(k), Util.variant_from_dbus(v)) for k, v in val.items()]
                     ),
                 )
-            if val.signature == "sa{sv}":
+            if val.signature == "sa{sv}" or val.signature == "sa{ss}":
                 c = collections.OrderedDict(
                     [
                         (
@@ -825,10 +831,12 @@ class Device(ExportedObj):
 
         ExportedObj.__init__(self, ExportedObj.create_path(Device), ident)
 
+        self.applied_con = {}
         self.ip4_config = None
         self.ip6_config = None
         self.dhcp4_config = None
         self.dhcp6_config = None
+        self.activation_state_change_delay_ms = 50
 
         self.prp_state = NM.DeviceState.UNAVAILABLE
 
@@ -1037,6 +1045,20 @@ class Device(ExportedObj):
     def Disconnect(self):
         pass
 
+    @dbus.service.method(
+        dbus_interface=IFACE_DEVICE, in_signature="u", out_signature="a{sa{sv}}t"
+    )
+    def GetAppliedConnection(self, flags):
+        ac = self._dbus_property_get(IFACE_DEVICE, PRP_DEVICE_ACTIVE_CONNECTION)
+        return (self.applied_con, 0)
+
+    @dbus.service.method(
+        dbus_interface=IFACE_DEVICE, in_signature="a{sa{sv}}tu", out_signature=""
+    )
+    def Reapply(self, connection, version_id, flags):
+        self.applied_con = connection
+        pass
+
     @dbus.service.method(dbus_interface=IFACE_DEVICE, in_signature="", out_signature="")
     def Delete(self):
         # We don't currently support any software device types, so...
@@ -1067,6 +1089,10 @@ class Device(ExportedObj):
 
     def set_active_connection(self, ac):
         self._dbus_property_set(IFACE_DEVICE, PRP_DEVICE_ACTIVE_CONNECTION, ac)
+        if ac is None:
+            self.applied_con = {}
+        else:
+            self.applied_con = ac.con_inst.con_hash
 
     def connection_is_available(self, con_inst):
         if con_inst.is_vpn():
@@ -1388,9 +1414,9 @@ class ActiveConnection(ExportedObj):
         self.con_inst = con_inst
         self.is_vpn = con_inst.is_vpn()
 
+        self.activation_state_change_delay_ms = device.activation_state_change_delay_ms
         self._activation_id = None
         self._deactivation_id = None
-        self.activation_state_change_delay_ms = 50
 
         s_con = con_inst.con_hash[NM.SETTING_CONNECTION_SETTING_NAME]
 
@@ -1486,9 +1512,17 @@ class ActiveConnection(ExportedObj):
 
     def start_activation(self):
         assert self._activation_id is None
-        self._activation_id = GLib.timeout_add(
-            self.activation_state_change_delay_ms, self._activation_step1
-        )
+        if self.activation_state_change_delay_ms == 0:
+            self.device.set_active_connection(self)
+            self._set_state(
+                NM.ActiveConnectionState.ACTIVATED,
+                NM.ActiveConnectionStateReason.UNKNOWN,
+            )
+            self.device.set_state(NM.DeviceState.ACTIVATED, NM.DeviceStateReason.NONE)
+        else:
+            self._activation_id = GLib.timeout_add(
+                self.activation_state_change_delay_ms, self._activation_step1
+            )
 
     def start_deactivation(self):
         assert self._deactivation_id is None
@@ -1553,7 +1587,9 @@ class NetworkManager(ExportedObj):
             PRP_NM_ACTIVATING_CONNECTION: ExportedObj.to_path(None),
             PRP_NM_STARTUP: False,
             PRP_NM_STATE: dbus.UInt32(NM.State.DISCONNECTED),
-            PRP_NM_VERSION: "0.9.9.0",
+            PRP_NM_VERSION: os.environ.get(
+                "NM_TEST_NETWORKMANAGER_SERVICE_VERSION", "0.9.9.0"
+            ),
             PRP_NM_CONNECTIVITY: dbus.UInt32(NM.ConnectivityState.NONE),
         }
 
@@ -1918,9 +1954,9 @@ class NetworkManager(ExportedObj):
 
     @dbus.service.method(dbus_interface=IFACE_TEST, in_signature="ou", out_signature="")
     def SetActiveConnectionStateChangedDelay(self, devpath, delay_ms):
-        for ac in reversed(self.active_connections):
-            if ac.device.path == devpath:
-                ac.activation_state_change_delay_ms = delay_ms
+        for d in self.devices:
+            if d.path == devpath:
+                d.activation_state_change_delay_ms = delay_ms
                 return
         raise BusErr.UnknownDeviceException(
             "Device with iface '%s' not found" % devpath
@@ -2856,6 +2892,26 @@ class ObjectManager(dbus.service.Object):
         return managed_objects
 
 
+def setup_log_file():
+    """
+    Get environment variable for the log file , if any
+    We accept a %p placeholder to replace with the pid of the current
+    process
+    """
+    try:
+        log_file_name = os.environ["NM_TEST_NETWORKMANAGER_SERVICE_LOGFILE"]
+    except KeyError:
+        return None
+    if "%p" in log_file_name:
+        log_file_name = log_file_name.replace("%p", str(os.getpid()))
+    try:
+        log_file = open(log_file_name, "w")
+    except Exception:
+        log_file = None
+
+    return log_file
+
+
 ###############################################################################
 
 
@@ -2867,6 +2923,7 @@ def main():
     global gl
     gl = Global()
 
+    gl.log_file = setup_log_file()
     gl.mainloop = GLib.MainLoop()
     gl.bus = dbus.SessionBus()
     gl.force_activation_failure = {}
@@ -2899,7 +2956,8 @@ def main():
     gl.settings.unexport()
     gl.manager.unexport()
     gl.object_manager.remove_from_connection()
-
+    if gl.log_file:
+        gl.log_file.close()
     sys.exit(0)