From eee603294f120cf98696351433e7e6dbc9a3dbc2 Mon Sep 17 00:00:00 2001
From: James Falcon <james.falcon@canonical.com>
Date: Wed, 23 Mar 2022 18:03:00 -0500
Subject: [PATCH] Fix cloud-init status --wait when no datasource found (#1349)

* Fix cloud-init status --wait when no datasource found

In 0de7acb1, we modified status checks to wait until we get an "enabled"
or "disabled" file from ds-identiy. ds-identify never outputs a
"disabled" file, so "status --wait" will wait indefinitely if no
datasource is found.

LP: #1966085
---
 systemd/cloud-init-generator.tmpl          |  7 +++
 tests/integration_tests/clouds.py          | 16 ++++--
 tests/integration_tests/cmd/test_status.py | 65 ++++++++++++++++++++++
 3 files changed, 82 insertions(+), 6 deletions(-)
 create mode 100644 tests/integration_tests/cmd/test_status.py

Origin: backport, https://github.com/canonical/cloud-init/commit/eee60329
Bug-Ubuntu: https://bugs.launchpad.net/bugs/1966085
Last-Update: 2022-03-24
Index: cloud-init/systemd/cloud-init-generator.tmpl
===================================================================
--- cloud-init.orig/systemd/cloud-init-generator.tmpl
+++ cloud-init/systemd/cloud-init-generator.tmpl
@@ -10,6 +10,7 @@ DISABLE="disabled"
 FOUND="found"
 NOTFOUND="notfound"
 RUN_ENABLED_FILE="$LOG_D/$ENABLE"
+RUN_DISABLED_FILE="$LOG_D/$DISABLE"
 {% if variant in ["suse"] %}
 CLOUD_SYSTEM_TARGET="/usr/lib/systemd/system/cloud-init.target"
 {% else %}
@@ -154,6 +155,10 @@ main() {
                     "ln $CLOUD_SYSTEM_TARGET $link_path"
             fi
         fi
+        if [ -e $RUN_DISABLED_FILE ]; then
+            debug 1 "removing $RUN_DISABLED_FILE and creating $RUN_ENABLED_FILE"
+            rm -f $RUN_DISABLED_FILE
+        fi
         : > "$RUN_ENABLED_FILE"
     elif [ "$result" = "$DISABLE" ]; then
         if [ -f "$link_path" ]; then
@@ -167,8 +172,10 @@ main() {
             debug 1 "already disabled: no change needed [no $link_path]"
         fi
         if [ -e "$RUN_ENABLED_FILE" ]; then
+            debug 1 "removing $RUN_ENABLED_FILE and creating $RUN_DISABLED_FILE"
             rm -f "$RUN_ENABLED_FILE"
         fi
+        : > "$RUN_DISABLED_FILE"
     else
         debug 0 "unexpected result '$result' 'ds=$ds'"
         ret=3
Index: cloud-init/tests/integration_tests/clouds.py
===================================================================
--- cloud-init.orig/tests/integration_tests/clouds.py
+++ cloud-init/tests/integration_tests/clouds.py
@@ -5,6 +5,7 @@ import os.path
 import random
 import string
 from abc import ABC, abstractmethod
+from copy import deepcopy
 from typing import Optional, Type
 from uuid import UUID
 
@@ -291,12 +292,15 @@ class _LxdIntegrationCloud(IntegrationCl
             subp(command.split())
 
     def _perform_launch(self, launch_kwargs, **kwargs):
-        launch_kwargs["inst_type"] = launch_kwargs.pop("instance_type", None)
-        wait = launch_kwargs.pop("wait", True)
-        release = launch_kwargs.pop("image_id")
+        instance_kwargs = deepcopy(launch_kwargs)
+        instance_kwargs["inst_type"] = instance_kwargs.pop(
+            "instance_type", None
+        )
+        wait = instance_kwargs.pop("wait", True)
+        release = instance_kwargs.pop("image_id")
 
         try:
-            profile_list = launch_kwargs["profile_list"]
+            profile_list = instance_kwargs["profile_list"]
         except KeyError:
             profile_list = self._get_or_set_profile_list(release)
 
@@ -305,10 +309,10 @@ class _LxdIntegrationCloud(IntegrationCl
             random.choices(string.ascii_lowercase + string.digits, k=8)
         )
         pycloudlib_instance = self.cloud_instance.init(
-            launch_kwargs.pop("name", default_name),
+            instance_kwargs.pop("name", default_name),
             release,
             profile_list=profile_list,
-            **launch_kwargs,
+            **instance_kwargs,
         )
         if self.settings.CLOUD_INIT_SOURCE == "IN_PLACE":
             self._mount_source(pycloudlib_instance)
Index: cloud-init/tests/integration_tests/cmd/test_status.py
===================================================================
--- /dev/null
+++ cloud-init/tests/integration_tests/cmd/test_status.py
@@ -0,0 +1,65 @@
+"""Tests for `cloud-init status`"""
+from time import sleep
+
+import pytest
+
+from tests.integration_tests.clouds import ImageSpecification, IntegrationCloud
+from tests.integration_tests.instances import IntegrationInstance
+
+
+# We're implementing our own here in case cloud-init status --wait
+# isn't working correctly (LP: #1966085)
+def _wait_for_cloud_init(client: IntegrationInstance):
+    last_exception = None
+    for _ in range(30):
+        try:
+            result = client.execute("cloud-init status --long")
+            if result and result.ok:
+                return result
+        except Exception as e:
+            last_exception = e
+        sleep(1)
+    raise Exception(
+        "cloud-init status did not return successfully."
+    ) from last_exception
+
+
+def _remove_nocloud_dir_and_reboot(client: IntegrationInstance):
+    # On Impish and below, NoCloud will be detected on an LXD container.
+    # If we remove this directory, it will no longer be detected.
+    client.execute("rm -rf /var/lib/cloud/seed/nocloud-net")
+    client.execute("cloud-init clean --logs --reboot")
+
+
+@pytest.mark.ubuntu
+@pytest.mark.lxd_container
+def test_wait_when_no_datasource(session_cloud: IntegrationCloud, setup_image):
+    """Ensure that when no datasource is found, we get status: disabled
+
+    LP: #1966085
+    """
+    with session_cloud.launch(
+        launch_kwargs={
+            # On Jammy and above, we detect the LXD datasource using a
+            # socket available to the container. This prevents the socket
+            # from being exposed in the container, causing datasource detection
+            # to fail. ds-identify will then have failed to detect a datasource
+            "config_dict": {"security.devlxd": False},
+            "wait": False,  # to prevent cloud-init status --wait
+        }
+    ) as client:
+        # We know this will be an LXD instance due to our pytest mark
+        client.instance.execute_via_ssh = False  # type: ignore
+        # No ubuntu user if cloud-init didn't run
+        client.instance.username = "root"
+        # Jammy and above will use LXD datasource by default
+        if ImageSpecification.from_os_image().release in [
+            "bionic",
+            "focal",
+            "impish",
+        ]:
+            _remove_nocloud_dir_and_reboot(client)
+        status_out = _wait_for_cloud_init(client).stdout.strip()
+        assert "status: disabled" in status_out
+        assert "Cloud-init disabled by cloud-init-generator" in status_out
+        assert client.execute("cloud-init status --wait").ok
