1160f5
From d1790e6462e509e3cd87fc449df84fbd02ca1d89 Mon Sep 17 00:00:00 2001
1160f5
From: Emanuele Giuseppe Esposito <eesposit@redhat.com>
1160f5
Date: Thu, 2 Jun 2022 16:03:43 +0200
1160f5
Subject: [PATCH 2/2] cc_set_hostname: do not write "localhost" when no
1160f5
 hostname is given (#1453)
1160f5
1160f5
RH-Author: Emanuele Giuseppe Esposito <eesposit@redhat.com>
1160f5
RH-MergeRequest: 28: cc_set_hostname: do not write "localhost" when no hostname is given (#1453)
1160f5
RH-Commit: [1/1] 4370e9149371dc89be82cb05d30d33e4d2638cec (eesposit/cloud-init-centos-)
1160f5
RH-Bugzilla: 1980403
1160f5
RH-Acked-by: Miroslav Rezanina <mrezanin@redhat.com>
1160f5
RH-Acked-by: Mohamed Gamal Morsy <mmorsy@redhat.com>
1160f5
1160f5
commit 74e43496f353db52e15d96abeb54ad63baac5be9
1160f5
Author: Emanuele Giuseppe Esposito <eesposit@redhat.com>
1160f5
Date:   Tue May 31 16:03:44 2022 +0200
1160f5
1160f5
    cc_set_hostname: do not write "localhost" when no hostname is given (#1453)
1160f5
1160f5
    Systemd used to sometimes ignore localhost in /etc/hostnames, and many programs
1160f5
    like cloud-init used this as a workaround to set a default hostname.
1160f5
1160f5
    From https://github.com/systemd/systemd/commit/d39079fcaa05e23540d2b1f0270fa31c22a7e9f1:
1160f5
1160f5
            We would sometimes ignore localhost-style names in /etc/hostname. That is
1160f5
            brittle. If the user configured some hostname, it's most likely because they
1160f5
            want to use that as the hostname. If they don't want to use such a hostname,
1160f5
            they should just not create the config. Everything becomes simples if we just
1160f5
            use the configured hostname as-is.
1160f5
1160f5
            This behaviour seems to have been a workaround for Anaconda installer and other
1160f5
            tools writing out /etc/hostname with the default of "localhost.localdomain".
1160f5
            Anaconda PR to stop doing that: rhinstaller/anaconda#3040.
1160f5
            That might have been useful as a work-around for other programs misbehaving if
1160f5
            /etc/hostname was not present, but nowadays it's not useful because systemd
1160f5
            mostly controls the hostname and it is perfectly happy without that file.
1160f5
1160f5
            Apart from making things simpler, this allows users to set a hostname like
1160f5
            "localhost" and have it honoured, if such a whim strikes them.
1160f5
1160f5
    As also suggested by the Anaconda PR, we need to stop writing default "localhost"
1160f5
    in /etc/hostnames, and let the right service (networking, user) do that if they
1160f5
    need to. Otherwise, "localhost" will permanently stay as hostname and will
1160f5
    prevent other tools like NetworkManager from setting the right one.
1160f5
1160f5
    Signed-off-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
1160f5
1160f5
    RHBZ: 1980403
1160f5
1160f5
Conflicts:
1160f5
	cloudinit/config/cc_update_etc_hosts.py
1160f5
	cloudinit/sources/DataSourceCloudSigma.py
1160f5
	cloudinit/util.py
1160f5
	tests/unittests/test_util.py
1160f5
	    Additional imports and/or conditionals that are not present in this version
1160f5
1160f5
Signed-off-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
1160f5
---
1160f5
 cloudinit/cmd/main.py                         |  2 +-
1160f5
 cloudinit/config/cc_apt_configure.py          |  2 +-
1160f5
 cloudinit/config/cc_debug.py                  |  2 +-
1160f5
 cloudinit/config/cc_phone_home.py             |  4 +-
1160f5
 cloudinit/config/cc_set_hostname.py           |  6 ++-
1160f5
 cloudinit/config/cc_spacewalk.py              |  2 +-
1160f5
 cloudinit/config/cc_update_etc_hosts.py       |  4 +-
1160f5
 cloudinit/config/cc_update_hostname.py        |  7 +++-
1160f5
 cloudinit/sources/DataSourceAliYun.py         |  8 +++-
1160f5
 cloudinit/sources/DataSourceCloudSigma.py     |  6 ++-
1160f5
 cloudinit/sources/DataSourceGCE.py            |  5 ++-
1160f5
 cloudinit/sources/DataSourceScaleway.py       |  3 +-
1160f5
 cloudinit/sources/__init__.py                 | 28 ++++++++++---
1160f5
 cloudinit/util.py                             | 29 +++++++++++---
1160f5
 .../unittests/config/test_cc_set_hostname.py  | 40 ++++++++++++++++++-
1160f5
 tests/unittests/sources/test_aliyun.py        |  2 +-
1160f5
 tests/unittests/sources/test_cloudsigma.py    |  8 ++--
1160f5
 tests/unittests/sources/test_digitalocean.py  |  2 +-
1160f5
 tests/unittests/sources/test_gce.py           |  4 +-
1160f5
 tests/unittests/sources/test_hetzner.py       |  2 +-
1160f5
 tests/unittests/sources/test_init.py          | 29 +++++++++-----
1160f5
 tests/unittests/sources/test_scaleway.py      |  2 +-
1160f5
 tests/unittests/sources/test_vmware.py        |  4 +-
1160f5
 tests/unittests/test_util.py                  | 17 ++++----
1160f5
 tests/unittests/util.py                       |  3 +-
1160f5
 25 files changed, 166 insertions(+), 55 deletions(-)
1160f5
1160f5
diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
1160f5
index c9be41b3..816d31aa 100644
1160f5
--- a/cloudinit/cmd/main.py
1160f5
+++ b/cloudinit/cmd/main.py
1160f5
@@ -813,7 +813,7 @@ def _maybe_set_hostname(init, stage, retry_stage):
1160f5
     @param retry_stage: String represented logs upon error setting hostname.
1160f5
     """
1160f5
     cloud = init.cloudify()
1160f5
-    (hostname, _fqdn) = util.get_hostname_fqdn(
1160f5
+    (hostname, _fqdn, _) = util.get_hostname_fqdn(
1160f5
         init.cfg, cloud, metadata_only=True
1160f5
     )
1160f5
     if hostname:  # meta-data or user-data hostname content
1160f5
diff --git a/cloudinit/config/cc_apt_configure.py b/cloudinit/config/cc_apt_configure.py
1160f5
index c558311a..0e6466ec 100644
1160f5
--- a/cloudinit/config/cc_apt_configure.py
1160f5
+++ b/cloudinit/config/cc_apt_configure.py
1160f5
@@ -753,7 +753,7 @@ def search_for_mirror_dns(configured, mirrortype, cfg, cloud):
1160f5
             raise ValueError("unknown mirror type")
1160f5
 
1160f5
         # if we have a fqdn, then search its domain portion first
1160f5
-        (_, fqdn) = util.get_hostname_fqdn(cfg, cloud)
1160f5
+        fqdn = util.get_hostname_fqdn(cfg, cloud).fqdn
1160f5
         mydom = ".".join(fqdn.split(".")[1:])
1160f5
         if mydom:
1160f5
             doms.append(".%s" % mydom)
1160f5
diff --git a/cloudinit/config/cc_debug.py b/cloudinit/config/cc_debug.py
1160f5
index c51818c3..a00f2823 100644
1160f5
--- a/cloudinit/config/cc_debug.py
1160f5
+++ b/cloudinit/config/cc_debug.py
1160f5
@@ -95,7 +95,7 @@ def handle(name, cfg, cloud, log, args):
1160f5
         "Datasource: %s\n" % (type_utils.obj_name(cloud.datasource))
1160f5
     )
1160f5
     to_print.write("Distro: %s\n" % (type_utils.obj_name(cloud.distro)))
1160f5
-    to_print.write("Hostname: %s\n" % (cloud.get_hostname(True)))
1160f5
+    to_print.write("Hostname: %s\n" % (cloud.get_hostname(True).hostname))
1160f5
     to_print.write("Instance ID: %s\n" % (cloud.get_instance_id()))
1160f5
     to_print.write("Locale: %s\n" % (cloud.get_locale()))
1160f5
     to_print.write("Launch IDX: %s\n" % (cloud.launch_index))
1160f5
diff --git a/cloudinit/config/cc_phone_home.py b/cloudinit/config/cc_phone_home.py
1160f5
index a0e1da78..1cf270aa 100644
1160f5
--- a/cloudinit/config/cc_phone_home.py
1160f5
+++ b/cloudinit/config/cc_phone_home.py
1160f5
@@ -119,8 +119,8 @@ def handle(name, cfg, cloud, log, args):
1160f5
 
1160f5
     all_keys = {}
1160f5
     all_keys["instance_id"] = cloud.get_instance_id()
1160f5
-    all_keys["hostname"] = cloud.get_hostname()
1160f5
-    all_keys["fqdn"] = cloud.get_hostname(fqdn=True)
1160f5
+    all_keys["hostname"] = cloud.get_hostname().hostname
1160f5
+    all_keys["fqdn"] = cloud.get_hostname(fqdn=True).hostname
1160f5
 
1160f5
     pubkeys = {
1160f5
         "pub_key_dsa": "/etc/ssh/ssh_host_dsa_key.pub",
1160f5
diff --git a/cloudinit/config/cc_set_hostname.py b/cloudinit/config/cc_set_hostname.py
1160f5
index eb0ca328..2674fa20 100644
1160f5
--- a/cloudinit/config/cc_set_hostname.py
1160f5
+++ b/cloudinit/config/cc_set_hostname.py
1160f5
@@ -76,7 +76,7 @@ def handle(name, cfg, cloud, log, _args):
1160f5
     if hostname_fqdn is not None:
1160f5
         cloud.distro.set_option("prefer_fqdn_over_hostname", hostname_fqdn)
1160f5
 
1160f5
-    (hostname, fqdn) = util.get_hostname_fqdn(cfg, cloud)
1160f5
+    (hostname, fqdn, is_default) = util.get_hostname_fqdn(cfg, cloud)
1160f5
     # Check for previous successful invocation of set-hostname
1160f5
 
1160f5
     # set-hostname artifact file accounts for both hostname and fqdn
1160f5
@@ -94,6 +94,10 @@ def handle(name, cfg, cloud, log, _args):
1160f5
     if not hostname_changed:
1160f5
         log.debug("No hostname changes. Skipping set-hostname")
1160f5
         return
1160f5
+    if is_default and hostname == "localhost":
1160f5
+        # https://github.com/systemd/systemd/commit/d39079fcaa05e23540d2b1f0270fa31c22a7e9f1
1160f5
+        log.debug("Hostname is localhost. Let other services handle this.")
1160f5
+        return
1160f5
     log.debug("Setting the hostname to %s (%s)", fqdn, hostname)
1160f5
     try:
1160f5
         cloud.distro.set_hostname(hostname, fqdn)
1160f5
diff --git a/cloudinit/config/cc_spacewalk.py b/cloudinit/config/cc_spacewalk.py
1160f5
index 3fa6c388..419c8b32 100644
1160f5
--- a/cloudinit/config/cc_spacewalk.py
1160f5
+++ b/cloudinit/config/cc_spacewalk.py
1160f5
@@ -89,7 +89,7 @@ def handle(name, cfg, cloud, log, _args):
1160f5
         if not is_registered():
1160f5
             do_register(
1160f5
                 spacewalk_server,
1160f5
-                cloud.datasource.get_hostname(fqdn=True),
1160f5
+                cloud.datasource.get_hostname(fqdn=True).hostname,
1160f5
                 proxy=cfg.get("proxy"),
1160f5
                 log=log,
1160f5
                 activation_key=cfg.get("activation_key"),
1160f5
diff --git a/cloudinit/config/cc_update_etc_hosts.py b/cloudinit/config/cc_update_etc_hosts.py
1160f5
index f0aa9b0f..d2ee6f45 100644
1160f5
--- a/cloudinit/config/cc_update_etc_hosts.py
1160f5
+++ b/cloudinit/config/cc_update_etc_hosts.py
1160f5
@@ -62,7 +62,7 @@ def handle(name, cfg, cloud, log, _args):
1160f5
     hosts_fn = cloud.distro.hosts_fn
1160f5
 
1160f5
     if util.translate_bool(manage_hosts, addons=["template"]):
1160f5
-        (hostname, fqdn) = util.get_hostname_fqdn(cfg, cloud)
1160f5
+        (hostname, fqdn, _) = util.get_hostname_fqdn(cfg, cloud)
1160f5
         if not hostname:
1160f5
             log.warning(
1160f5
                 "Option 'manage_etc_hosts' was set, but no hostname was found"
1160f5
@@ -84,7 +84,7 @@ def handle(name, cfg, cloud, log, _args):
1160f5
         )
1160f5
 
1160f5
     elif manage_hosts == "localhost":
1160f5
-        (hostname, fqdn) = util.get_hostname_fqdn(cfg, cloud)
1160f5
+        (hostname, fqdn, _) = util.get_hostname_fqdn(cfg, cloud)
1160f5
         if not hostname:
1160f5
             log.warning(
1160f5
                 "Option 'manage_etc_hosts' was set, but no hostname was found"
1160f5
diff --git a/cloudinit/config/cc_update_hostname.py b/cloudinit/config/cc_update_hostname.py
1160f5
index 09f6f6da..e2046020 100644
1160f5
--- a/cloudinit/config/cc_update_hostname.py
1160f5
+++ b/cloudinit/config/cc_update_hostname.py
1160f5
@@ -56,7 +56,12 @@ def handle(name, cfg, cloud, log, _args):
1160f5
     if hostname_fqdn is not None:
1160f5
         cloud.distro.set_option("prefer_fqdn_over_hostname", hostname_fqdn)
1160f5
 
1160f5
-    (hostname, fqdn) = util.get_hostname_fqdn(cfg, cloud)
1160f5
+    (hostname, fqdn, is_default) = util.get_hostname_fqdn(cfg, cloud)
1160f5
+    if is_default and hostname == "localhost":
1160f5
+        # https://github.com/systemd/systemd/commit/d39079fcaa05e23540d2b1f0270fa31c22a7e9f1
1160f5
+        log.debug("Hostname is localhost. Let other services handle this.")
1160f5
+        return
1160f5
+
1160f5
     try:
1160f5
         prev_fn = os.path.join(cloud.get_cpath("data"), "previous-hostname")
1160f5
         log.debug("Updating hostname to %s (%s)", fqdn, hostname)
1160f5
diff --git a/cloudinit/sources/DataSourceAliYun.py b/cloudinit/sources/DataSourceAliYun.py
1160f5
index 37f512e3..b9390aca 100644
1160f5
--- a/cloudinit/sources/DataSourceAliYun.py
1160f5
+++ b/cloudinit/sources/DataSourceAliYun.py
1160f5
@@ -2,6 +2,7 @@
1160f5
 
1160f5
 from cloudinit import dmi, sources
1160f5
 from cloudinit.sources import DataSourceEc2 as EC2
1160f5
+from cloudinit.sources import DataSourceHostname
1160f5
 
1160f5
 ALIYUN_PRODUCT = "Alibaba Cloud ECS"
1160f5
 
1160f5
@@ -16,7 +17,12 @@ class DataSourceAliYun(EC2.DataSourceEc2):
1160f5
     extended_metadata_versions = []
1160f5
 
1160f5
     def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
1160f5
-        return self.metadata.get("hostname", "localhost.localdomain")
1160f5
+        hostname = self.metadata.get("hostname")
1160f5
+        is_default = False
1160f5
+        if hostname is None:
1160f5
+            hostname = "localhost.localdomain"
1160f5
+            is_default = True
1160f5
+        return DataSourceHostname(hostname, is_default)
1160f5
 
1160f5
     def get_public_ssh_keys(self):
1160f5
         return parse_public_keys(self.metadata.get("public-keys", {}))
1160f5
diff --git a/cloudinit/sources/DataSourceCloudSigma.py b/cloudinit/sources/DataSourceCloudSigma.py
1160f5
index de71c3e9..91ebb084 100644
1160f5
--- a/cloudinit/sources/DataSourceCloudSigma.py
1160f5
+++ b/cloudinit/sources/DataSourceCloudSigma.py
1160f5
@@ -11,6 +11,7 @@ from cloudinit import dmi
1160f5
 from cloudinit import log as logging
1160f5
 from cloudinit import sources
1160f5
 from cloudinit.cs_utils import SERIAL_PORT, Cepko
1160f5
+from cloudinit.sources import DataSourceHostname
1160f5
 
1160f5
 LOG = logging.getLogger(__name__)
1160f5
 
1160f5
@@ -90,9 +91,10 @@ class DataSourceCloudSigma(sources.DataSource):
1160f5
         the first part from uuid is being used.
1160f5
         """
1160f5
         if re.match(r"^[A-Za-z0-9 -_\.]+$", self.metadata["name"]):
1160f5
-            return self.metadata["name"][:61]
1160f5
+            ret = self.metadata["name"][:61]
1160f5
         else:
1160f5
-            return self.metadata["uuid"].split("-")[0]
1160f5
+            ret = self.metadata["uuid"].split("-")[0]
1160f5
+        return DataSourceHostname(ret, False)
1160f5
 
1160f5
     def get_public_ssh_keys(self):
1160f5
         return [self.ssh_public_key]
1160f5
diff --git a/cloudinit/sources/DataSourceGCE.py b/cloudinit/sources/DataSourceGCE.py
1160f5
index c470bea8..f7ec6b52 100644
1160f5
--- a/cloudinit/sources/DataSourceGCE.py
1160f5
+++ b/cloudinit/sources/DataSourceGCE.py
1160f5
@@ -12,6 +12,7 @@ from cloudinit import log as logging
1160f5
 from cloudinit import sources, url_helper, util
1160f5
 from cloudinit.distros import ug_util
1160f5
 from cloudinit.net.dhcp import EphemeralDHCPv4
1160f5
+from cloudinit.sources import DataSourceHostname
1160f5
 
1160f5
 LOG = logging.getLogger(__name__)
1160f5
 
1160f5
@@ -122,7 +123,9 @@ class DataSourceGCE(sources.DataSource):
1160f5
 
1160f5
     def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
1160f5
         # GCE has long FDQN's and has asked for short hostnames.
1160f5
-        return self.metadata["local-hostname"].split(".")[0]
1160f5
+        return DataSourceHostname(
1160f5
+            self.metadata["local-hostname"].split(".")[0], False
1160f5
+        )
1160f5
 
1160f5
     @property
1160f5
     def availability_zone(self):
1160f5
diff --git a/cloudinit/sources/DataSourceScaleway.py b/cloudinit/sources/DataSourceScaleway.py
1160f5
index 8e5dd82c..8f08dc6d 100644
1160f5
--- a/cloudinit/sources/DataSourceScaleway.py
1160f5
+++ b/cloudinit/sources/DataSourceScaleway.py
1160f5
@@ -30,6 +30,7 @@ from cloudinit import log as logging
1160f5
 from cloudinit import net, sources, url_helper, util
1160f5
 from cloudinit.event import EventScope, EventType
1160f5
 from cloudinit.net.dhcp import EphemeralDHCPv4, NoDHCPLeaseError
1160f5
+from cloudinit.sources import DataSourceHostname
1160f5
 
1160f5
 LOG = logging.getLogger(__name__)
1160f5
 
1160f5
@@ -282,7 +283,7 @@ class DataSourceScaleway(sources.DataSource):
1160f5
         return ssh_keys
1160f5
 
1160f5
     def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
1160f5
-        return self.metadata["hostname"]
1160f5
+        return DataSourceHostname(self.metadata["hostname"], False)
1160f5
 
1160f5
     @property
1160f5
     def availability_zone(self):
1160f5
diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
1160f5
index 88028cfa..77b24fd7 100644
1160f5
--- a/cloudinit/sources/__init__.py
1160f5
+++ b/cloudinit/sources/__init__.py
1160f5
@@ -148,6 +148,11 @@ URLParams = namedtuple(
1160f5
     ],
1160f5
 )
1160f5
 
1160f5
+DataSourceHostname = namedtuple(
1160f5
+    "DataSourceHostname",
1160f5
+    ["hostname", "is_default"],
1160f5
+)
1160f5
+
1160f5
 
1160f5
 class DataSource(CloudInitPickleMixin, metaclass=abc.ABCMeta):
1160f5
 
1160f5
@@ -291,7 +296,7 @@ class DataSource(CloudInitPickleMixin, metaclass=abc.ABCMeta):
1160f5
 
1160f5
     def _get_standardized_metadata(self, instance_data):
1160f5
         """Return a dictionary of standardized metadata keys."""
1160f5
-        local_hostname = self.get_hostname()
1160f5
+        local_hostname = self.get_hostname().hostname
1160f5
         instance_id = self.get_instance_id()
1160f5
         availability_zone = self.availability_zone
1160f5
         # In the event of upgrade from existing cloudinit, pickled datasource
1160f5
@@ -697,22 +702,33 @@ class DataSource(CloudInitPickleMixin, metaclass=abc.ABCMeta):
1160f5
         @param metadata_only: Boolean, set True to avoid looking up hostname
1160f5
             if meta-data doesn't have local-hostname present.
1160f5
 
1160f5
-        @return: hostname or qualified hostname. Optionally return None when
1160f5
+        @return: a DataSourceHostname namedtuple
1160f5
+            <hostname or qualified hostname>, <is_default> (str, bool).
1160f5
+            is_default is a bool and
1160f5
+            it's true only if hostname is localhost and was
1160f5
+            returned by util.get_hostname() as a default.
1160f5
+            This is used to differentiate with a user-defined
1160f5
+            localhost hostname.
1160f5
+            Optionally return (None, False) when
1160f5
             metadata_only is True and local-hostname data is not available.
1160f5
         """
1160f5
         defdomain = "localdomain"
1160f5
         defhost = "localhost"
1160f5
         domain = defdomain
1160f5
+        is_default = False
1160f5
 
1160f5
         if not self.metadata or not self.metadata.get("local-hostname"):
1160f5
             if metadata_only:
1160f5
-                return None
1160f5
+                return DataSourceHostname(None, is_default)
1160f5
             # this is somewhat questionable really.
1160f5
             # the cloud datasource was asked for a hostname
1160f5
             # and didn't have one. raising error might be more appropriate
1160f5
             # but instead, basically look up the existing hostname
1160f5
             toks = []
1160f5
             hostname = util.get_hostname()
1160f5
+            if hostname == "localhost":
1160f5
+                # default hostname provided by socket.gethostname()
1160f5
+                is_default = True
1160f5
             hosts_fqdn = util.get_fqdn_from_hosts(hostname)
1160f5
             if hosts_fqdn and hosts_fqdn.find(".") > 0:
1160f5
                 toks = str(hosts_fqdn).split(".")
1160f5
@@ -745,9 +761,9 @@ class DataSource(CloudInitPickleMixin, metaclass=abc.ABCMeta):
1160f5
             hostname = toks[0]
1160f5
 
1160f5
         if fqdn and domain != defdomain:
1160f5
-            return "%s.%s" % (hostname, domain)
1160f5
-        else:
1160f5
-            return hostname
1160f5
+            hostname = "%s.%s" % (hostname, domain)
1160f5
+
1160f5
+        return DataSourceHostname(hostname, is_default)
1160f5
 
1160f5
     def get_package_mirror_info(self):
1160f5
         return self.distro.get_package_mirror_info(data_source=self)
1160f5
diff --git a/cloudinit/util.py b/cloudinit/util.py
1160f5
index 569fc215..4cb21551 100644
1160f5
--- a/cloudinit/util.py
1160f5
+++ b/cloudinit/util.py
1160f5
@@ -32,7 +32,8 @@ import subprocess
1160f5
 import sys
1160f5
 import time
1160f5
 from base64 import b64decode, b64encode
1160f5
-from errno import ENOENT
1160f5
+from collections import deque, namedtuple
1160f5
+from errno import EACCES, ENOENT
1160f5
 from functools import lru_cache
1160f5
 from typing import List
1160f5
 from urllib import parse
1160f5
@@ -1072,6 +1073,12 @@ def dos2unix(contents):
1160f5
     return contents.replace("\r\n", "\n")
1160f5
 
1160f5
 
1160f5
+HostnameFqdnInfo = namedtuple(
1160f5
+    "HostnameFqdnInfo",
1160f5
+    ["hostname", "fqdn", "is_default"],
1160f5
+)
1160f5
+
1160f5
+
1160f5
 def get_hostname_fqdn(cfg, cloud, metadata_only=False):
1160f5
     """Get hostname and fqdn from config if present and fallback to cloud.
1160f5
 
1160f5
@@ -1079,9 +1086,17 @@ def get_hostname_fqdn(cfg, cloud, metadata_only=False):
1160f5
     @param cloud: Cloud instance from init.cloudify().
1160f5
     @param metadata_only: Boolean, set True to only query cloud meta-data,
1160f5
         returning None if not present in meta-data.
1160f5
-    @return: a Tuple of strings <hostname>, <fqdn>. Values can be none when
1160f5
+    @return: a namedtuple of
1160f5
+        <hostname>, <fqdn>, <is_default> (str, str, bool).
1160f5
+        Values can be none when
1160f5
         metadata_only is True and no cfg or metadata provides hostname info.
1160f5
+        is_default is a bool and
1160f5
+        it's true only if hostname is localhost and was
1160f5
+        returned by util.get_hostname() as a default.
1160f5
+        This is used to differentiate with a user-defined
1160f5
+        localhost hostname.
1160f5
     """
1160f5
+    is_default = False
1160f5
     if "fqdn" in cfg:
1160f5
         # user specified a fqdn.  Default hostname then is based off that
1160f5
         fqdn = cfg["fqdn"]
1160f5
@@ -1095,12 +1110,16 @@ def get_hostname_fqdn(cfg, cloud, metadata_only=False):
1160f5
         else:
1160f5
             # no fqdn set, get fqdn from cloud.
1160f5
             # get hostname from cfg if available otherwise cloud
1160f5
-            fqdn = cloud.get_hostname(fqdn=True, metadata_only=metadata_only)
1160f5
+            fqdn = cloud.get_hostname(
1160f5
+                fqdn=True, metadata_only=metadata_only
1160f5
+            ).hostname
1160f5
             if "hostname" in cfg:
1160f5
                 hostname = cfg["hostname"]
1160f5
             else:
1160f5
-                hostname = cloud.get_hostname(metadata_only=metadata_only)
1160f5
-    return (hostname, fqdn)
1160f5
+                hostname, is_default = cloud.get_hostname(
1160f5
+                    metadata_only=metadata_only
1160f5
+                )
1160f5
+    return HostnameFqdnInfo(hostname, fqdn, is_default)
1160f5
 
1160f5
 
1160f5
 def get_fqdn_from_hosts(hostname, filename="/etc/hosts"):
1160f5
diff --git a/tests/unittests/config/test_cc_set_hostname.py b/tests/unittests/config/test_cc_set_hostname.py
1160f5
index fd994c4e..3d1d86ee 100644
1160f5
--- a/tests/unittests/config/test_cc_set_hostname.py
1160f5
+++ b/tests/unittests/config/test_cc_set_hostname.py
1160f5
@@ -11,6 +11,7 @@ from configobj import ConfigObj
1160f5
 
1160f5
 from cloudinit import cloud, distros, helpers, util
1160f5
 from cloudinit.config import cc_set_hostname
1160f5
+from cloudinit.sources import DataSourceNone
1160f5
 from tests.unittests import helpers as t_help
1160f5
 
1160f5
 LOG = logging.getLogger(__name__)
1160f5
@@ -153,7 +154,8 @@ class TestHostname(t_help.FilesystemMockingTestCase):
1160f5
                     )
1160f5
                 ] not in m_subp.call_args_list
1160f5
 
1160f5
-    def test_multiple_calls_skips_unchanged_hostname(self):
1160f5
+    @mock.patch("cloudinit.util.get_hostname", return_value="localhost")
1160f5
+    def test_multiple_calls_skips_unchanged_hostname(self, get_hostname):
1160f5
         """Only new hostname or fqdn values will generate a hostname call."""
1160f5
         distro = self._fetch_distro("debian")
1160f5
         paths = helpers.Paths({"cloud_dir": self.tmp})
1160f5
@@ -182,6 +184,42 @@ class TestHostname(t_help.FilesystemMockingTestCase):
1160f5
             self.logs.getvalue(),
1160f5
         )
1160f5
 
1160f5
+    @mock.patch("cloudinit.util.get_hostname", return_value="localhost")
1160f5
+    def test_localhost_default_hostname(self, get_hostname):
1160f5
+        """
1160f5
+        No hostname set. Default value returned is localhost,
1160f5
+        but we shouldn't write it in /etc/hostname
1160f5
+        """
1160f5
+        distro = self._fetch_distro("debian")
1160f5
+        paths = helpers.Paths({"cloud_dir": self.tmp})
1160f5
+        ds = DataSourceNone.DataSourceNone({}, None, paths)
1160f5
+        cc = cloud.Cloud(ds, paths, {}, distro, None)
1160f5
+        self.patchUtils(self.tmp)
1160f5
+
1160f5
+        util.write_file("/etc/hostname", "")
1160f5
+        cc_set_hostname.handle("cc_set_hostname", {}, cc, LOG, [])
1160f5
+        contents = util.load_file("/etc/hostname")
1160f5
+        self.assertEqual("", contents.strip())
1160f5
+
1160f5
+    @mock.patch("cloudinit.util.get_hostname", return_value="localhost")
1160f5
+    def test_localhost_user_given_hostname(self, get_hostname):
1160f5
+        """
1160f5
+        User set hostname is localhost. We should write it in /etc/hostname
1160f5
+        """
1160f5
+        distro = self._fetch_distro("debian")
1160f5
+        paths = helpers.Paths({"cloud_dir": self.tmp})
1160f5
+        ds = DataSourceNone.DataSourceNone({}, None, paths)
1160f5
+        cc = cloud.Cloud(ds, paths, {}, distro, None)
1160f5
+        self.patchUtils(self.tmp)
1160f5
+
1160f5
+        # user-provided localhost should not be ignored
1160f5
+        util.write_file("/etc/hostname", "")
1160f5
+        cc_set_hostname.handle(
1160f5
+            "cc_set_hostname", {"hostname": "localhost"}, cc, LOG, []
1160f5
+        )
1160f5
+        contents = util.load_file("/etc/hostname")
1160f5
+        self.assertEqual("localhost", contents.strip())
1160f5
+
1160f5
     def test_error_on_distro_set_hostname_errors(self):
1160f5
         """Raise SetHostnameError on exceptions from distro.set_hostname."""
1160f5
         distro = self._fetch_distro("debian")
1160f5
diff --git a/tests/unittests/sources/test_aliyun.py b/tests/unittests/sources/test_aliyun.py
1160f5
index 8a61d5ee..e628dc02 100644
1160f5
--- a/tests/unittests/sources/test_aliyun.py
1160f5
+++ b/tests/unittests/sources/test_aliyun.py
1160f5
@@ -149,7 +149,7 @@ class TestAliYunDatasource(test_helpers.HttprettyTestCase):
1160f5
 
1160f5
     def _test_host_name(self):
1160f5
         self.assertEqual(
1160f5
-            self.default_metadata["hostname"], self.ds.get_hostname()
1160f5
+            self.default_metadata["hostname"], self.ds.get_hostname().hostname
1160f5
         )
1160f5
 
1160f5
     @mock.patch("cloudinit.sources.DataSourceAliYun._is_aliyun")
1160f5
diff --git a/tests/unittests/sources/test_cloudsigma.py b/tests/unittests/sources/test_cloudsigma.py
1160f5
index a2f26245..3dca7ea8 100644
1160f5
--- a/tests/unittests/sources/test_cloudsigma.py
1160f5
+++ b/tests/unittests/sources/test_cloudsigma.py
1160f5
@@ -58,12 +58,14 @@ class DataSourceCloudSigmaTest(test_helpers.CiTestCase):
1160f5
 
1160f5
     def test_get_hostname(self):
1160f5
         self.datasource.get_data()
1160f5
-        self.assertEqual("test_server", self.datasource.get_hostname())
1160f5
+        self.assertEqual(
1160f5
+            "test_server", self.datasource.get_hostname().hostname
1160f5
+        )
1160f5
         self.datasource.metadata["name"] = ""
1160f5
-        self.assertEqual("65b2fb23", self.datasource.get_hostname())
1160f5
+        self.assertEqual("65b2fb23", self.datasource.get_hostname().hostname)
1160f5
         utf8_hostname = b"\xd1\x82\xd0\xb5\xd1\x81\xd1\x82".decode("utf-8")
1160f5
         self.datasource.metadata["name"] = utf8_hostname
1160f5
-        self.assertEqual("65b2fb23", self.datasource.get_hostname())
1160f5
+        self.assertEqual("65b2fb23", self.datasource.get_hostname().hostname)
1160f5
 
1160f5
     def test_get_public_ssh_keys(self):
1160f5
         self.datasource.get_data()
1160f5
diff --git a/tests/unittests/sources/test_digitalocean.py b/tests/unittests/sources/test_digitalocean.py
1160f5
index f3e6224e..47e46c66 100644
1160f5
--- a/tests/unittests/sources/test_digitalocean.py
1160f5
+++ b/tests/unittests/sources/test_digitalocean.py
1160f5
@@ -178,7 +178,7 @@ class TestDataSourceDigitalOcean(CiTestCase):
1160f5
         self.assertEqual(DO_META.get("vendor_data"), ds.get_vendordata_raw())
1160f5
         self.assertEqual(DO_META.get("region"), ds.availability_zone)
1160f5
         self.assertEqual(DO_META.get("droplet_id"), ds.get_instance_id())
1160f5
-        self.assertEqual(DO_META.get("hostname"), ds.get_hostname())
1160f5
+        self.assertEqual(DO_META.get("hostname"), ds.get_hostname().hostname)
1160f5
 
1160f5
         # Single key
1160f5
         self.assertEqual(
1160f5
diff --git a/tests/unittests/sources/test_gce.py b/tests/unittests/sources/test_gce.py
1160f5
index e030931b..1ce0c6ec 100644
1160f5
--- a/tests/unittests/sources/test_gce.py
1160f5
+++ b/tests/unittests/sources/test_gce.py
1160f5
@@ -126,7 +126,7 @@ class TestDataSourceGCE(test_helpers.HttprettyTestCase):
1160f5
         self.ds.get_data()
1160f5
 
1160f5
         shostname = GCE_META.get("instance/hostname").split(".")[0]
1160f5
-        self.assertEqual(shostname, self.ds.get_hostname())
1160f5
+        self.assertEqual(shostname, self.ds.get_hostname().hostname)
1160f5
 
1160f5
         self.assertEqual(
1160f5
             GCE_META.get("instance/id"), self.ds.get_instance_id()
1160f5
@@ -147,7 +147,7 @@ class TestDataSourceGCE(test_helpers.HttprettyTestCase):
1160f5
         )
1160f5
 
1160f5
         shostname = GCE_META_PARTIAL.get("instance/hostname").split(".")[0]
1160f5
-        self.assertEqual(shostname, self.ds.get_hostname())
1160f5
+        self.assertEqual(shostname, self.ds.get_hostname().hostname)
1160f5
 
1160f5
     def test_userdata_no_encoding(self):
1160f5
         """check that user-data is read."""
1160f5
diff --git a/tests/unittests/sources/test_hetzner.py b/tests/unittests/sources/test_hetzner.py
1160f5
index f80ed45f..193b7e42 100644
1160f5
--- a/tests/unittests/sources/test_hetzner.py
1160f5
+++ b/tests/unittests/sources/test_hetzner.py
1160f5
@@ -116,7 +116,7 @@ class TestDataSourceHetzner(CiTestCase):
1160f5
 
1160f5
         self.assertTrue(m_readmd.called)
1160f5
 
1160f5
-        self.assertEqual(METADATA.get("hostname"), ds.get_hostname())
1160f5
+        self.assertEqual(METADATA.get("hostname"), ds.get_hostname().hostname)
1160f5
 
1160f5
         self.assertEqual(METADATA.get("public-keys"), ds.get_public_ssh_keys())
1160f5
 
1160f5
diff --git a/tests/unittests/sources/test_init.py b/tests/unittests/sources/test_init.py
1160f5
index ce8fc970..79fc9c5b 100644
1160f5
--- a/tests/unittests/sources/test_init.py
1160f5
+++ b/tests/unittests/sources/test_init.py
1160f5
@@ -272,9 +272,11 @@ class TestDataSource(CiTestCase):
1160f5
         self.assertEqual(
1160f5
             "test-subclass-hostname", datasource.metadata["local-hostname"]
1160f5
         )
1160f5
-        self.assertEqual("test-subclass-hostname", datasource.get_hostname())
1160f5
+        self.assertEqual(
1160f5
+            "test-subclass-hostname", datasource.get_hostname().hostname
1160f5
+        )
1160f5
         datasource.metadata["local-hostname"] = "hostname.my.domain.com"
1160f5
-        self.assertEqual("hostname", datasource.get_hostname())
1160f5
+        self.assertEqual("hostname", datasource.get_hostname().hostname)
1160f5
 
1160f5
     def test_get_hostname_with_fqdn_returns_local_hostname_with_domain(self):
1160f5
         """Datasource.get_hostname with fqdn set gets qualified hostname."""
1160f5
@@ -285,7 +287,8 @@ class TestDataSource(CiTestCase):
1160f5
         self.assertTrue(datasource.get_data())
1160f5
         datasource.metadata["local-hostname"] = "hostname.my.domain.com"
1160f5
         self.assertEqual(
1160f5
-            "hostname.my.domain.com", datasource.get_hostname(fqdn=True)
1160f5
+            "hostname.my.domain.com",
1160f5
+            datasource.get_hostname(fqdn=True).hostname,
1160f5
         )
1160f5
 
1160f5
     def test_get_hostname_without_metadata_uses_system_hostname(self):
1160f5
@@ -300,10 +303,12 @@ class TestDataSource(CiTestCase):
1160f5
             with mock.patch(mock_fqdn) as m_fqdn:
1160f5
                 m_gethost.return_value = "systemhostname.domain.com"
1160f5
                 m_fqdn.return_value = None  # No maching fqdn in /etc/hosts
1160f5
-                self.assertEqual("systemhostname", datasource.get_hostname())
1160f5
+                self.assertEqual(
1160f5
+                    "systemhostname", datasource.get_hostname().hostname
1160f5
+                )
1160f5
                 self.assertEqual(
1160f5
                     "systemhostname.domain.com",
1160f5
-                    datasource.get_hostname(fqdn=True),
1160f5
+                    datasource.get_hostname(fqdn=True).hostname,
1160f5
                 )
1160f5
 
1160f5
     def test_get_hostname_without_metadata_returns_none(self):
1160f5
@@ -316,9 +321,13 @@ class TestDataSource(CiTestCase):
1160f5
         mock_fqdn = "cloudinit.sources.util.get_fqdn_from_hosts"
1160f5
         with mock.patch("cloudinit.sources.util.get_hostname") as m_gethost:
1160f5
             with mock.patch(mock_fqdn) as m_fqdn:
1160f5
-                self.assertIsNone(datasource.get_hostname(metadata_only=True))
1160f5
                 self.assertIsNone(
1160f5
-                    datasource.get_hostname(fqdn=True, metadata_only=True)
1160f5
+                    datasource.get_hostname(metadata_only=True).hostname
1160f5
+                )
1160f5
+                self.assertIsNone(
1160f5
+                    datasource.get_hostname(
1160f5
+                        fqdn=True, metadata_only=True
1160f5
+                    ).hostname
1160f5
                 )
1160f5
         self.assertEqual([], m_gethost.call_args_list)
1160f5
         self.assertEqual([], m_fqdn.call_args_list)
1160f5
@@ -335,10 +344,12 @@ class TestDataSource(CiTestCase):
1160f5
             with mock.patch(mock_fqdn) as m_fqdn:
1160f5
                 m_gethost.return_value = "systemhostname.domain.com"
1160f5
                 m_fqdn.return_value = "fqdnhostname.domain.com"
1160f5
-                self.assertEqual("fqdnhostname", datasource.get_hostname())
1160f5
+                self.assertEqual(
1160f5
+                    "fqdnhostname", datasource.get_hostname().hostname
1160f5
+                )
1160f5
                 self.assertEqual(
1160f5
                     "fqdnhostname.domain.com",
1160f5
-                    datasource.get_hostname(fqdn=True),
1160f5
+                    datasource.get_hostname(fqdn=True).hostname,
1160f5
                 )
1160f5
 
1160f5
     def test_get_data_does_not_write_instance_data_on_failure(self):
1160f5
diff --git a/tests/unittests/sources/test_scaleway.py b/tests/unittests/sources/test_scaleway.py
1160f5
index d7e8b969..56735dd0 100644
1160f5
--- a/tests/unittests/sources/test_scaleway.py
1160f5
+++ b/tests/unittests/sources/test_scaleway.py
1160f5
@@ -236,7 +236,7 @@ class TestDataSourceScaleway(HttprettyTestCase):
1160f5
             ].sort(),
1160f5
         )
1160f5
         self.assertEqual(
1160f5
-            self.datasource.get_hostname(),
1160f5
+            self.datasource.get_hostname().hostname,
1160f5
             MetadataResponses.FAKE_METADATA["hostname"],
1160f5
         )
1160f5
         self.assertEqual(
1160f5
diff --git a/tests/unittests/sources/test_vmware.py b/tests/unittests/sources/test_vmware.py
1160f5
index dd331349..753bb774 100644
1160f5
--- a/tests/unittests/sources/test_vmware.py
1160f5
+++ b/tests/unittests/sources/test_vmware.py
1160f5
@@ -368,7 +368,9 @@ class TestDataSourceVMwareGuestInfo_InvalidPlatform(FilesystemMockingTestCase):
1160f5
 
1160f5
 def assert_metadata(test_obj, ds, metadata):
1160f5
     test_obj.assertEqual(metadata.get("instance-id"), ds.get_instance_id())
1160f5
-    test_obj.assertEqual(metadata.get("local-hostname"), ds.get_hostname())
1160f5
+    test_obj.assertEqual(
1160f5
+        metadata.get("local-hostname"), ds.get_hostname().hostname
1160f5
+    )
1160f5
 
1160f5
     expected_public_keys = metadata.get("public_keys")
1160f5
     if not isinstance(expected_public_keys, list):
1160f5
diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py
1160f5
index 3765511b..528b7f36 100644
1160f5
--- a/tests/unittests/test_util.py
1160f5
+++ b/tests/unittests/test_util.py
1160f5
@@ -19,6 +19,7 @@ import pytest
1160f5
 import yaml
1160f5
 
1160f5
 from cloudinit import importer, subp, util
1160f5
+from cloudinit.sources import DataSourceHostname
1160f5
 from tests.unittests import helpers
1160f5
 from tests.unittests.helpers import CiTestCase
1160f5
 
1160f5
@@ -331,8 +332,8 @@ class FakeCloud(object):
1160f5
             myargs["metadata_only"] = metadata_only
1160f5
         self.calls.append(myargs)
1160f5
         if fqdn:
1160f5
-            return self.fqdn
1160f5
-        return self.hostname
1160f5
+            return DataSourceHostname(self.fqdn, False)
1160f5
+        return DataSourceHostname(self.hostname, False)
1160f5
 
1160f5
 
1160f5
 class TestUtil(CiTestCase):
1160f5
@@ -420,7 +421,7 @@ class TestShellify(CiTestCase):
1160f5
 class TestGetHostnameFqdn(CiTestCase):
1160f5
     def test_get_hostname_fqdn_from_only_cfg_fqdn(self):
1160f5
         """When cfg only has the fqdn key, derive hostname and fqdn from it."""
1160f5
-        hostname, fqdn = util.get_hostname_fqdn(
1160f5
+        hostname, fqdn, _ = util.get_hostname_fqdn(
1160f5
             cfg={"fqdn": "myhost.domain.com"}, cloud=None
1160f5
         )
1160f5
         self.assertEqual("myhost", hostname)
1160f5
@@ -428,7 +429,7 @@ class TestGetHostnameFqdn(CiTestCase):
1160f5
 
1160f5
     def test_get_hostname_fqdn_from_cfg_fqdn_and_hostname(self):
1160f5
         """When cfg has both fqdn and hostname keys, return them."""
1160f5
-        hostname, fqdn = util.get_hostname_fqdn(
1160f5
+        hostname, fqdn, _ = util.get_hostname_fqdn(
1160f5
             cfg={"fqdn": "myhost.domain.com", "hostname": "other"}, cloud=None
1160f5
         )
1160f5
         self.assertEqual("other", hostname)
1160f5
@@ -436,7 +437,7 @@ class TestGetHostnameFqdn(CiTestCase):
1160f5
 
1160f5
     def test_get_hostname_fqdn_from_cfg_hostname_with_domain(self):
1160f5
         """When cfg has only hostname key which represents a fqdn, use that."""
1160f5
-        hostname, fqdn = util.get_hostname_fqdn(
1160f5
+        hostname, fqdn, _ = util.get_hostname_fqdn(
1160f5
             cfg={"hostname": "myhost.domain.com"}, cloud=None
1160f5
         )
1160f5
         self.assertEqual("myhost", hostname)
1160f5
@@ -445,7 +446,7 @@ class TestGetHostnameFqdn(CiTestCase):
1160f5
     def test_get_hostname_fqdn_from_cfg_hostname_without_domain(self):
1160f5
         """When cfg has a hostname without a '.' query cloud.get_hostname."""
1160f5
         mycloud = FakeCloud("cloudhost", "cloudhost.mycloud.com")
1160f5
-        hostname, fqdn = util.get_hostname_fqdn(
1160f5
+        hostname, fqdn, _ = util.get_hostname_fqdn(
1160f5
             cfg={"hostname": "myhost"}, cloud=mycloud
1160f5
         )
1160f5
         self.assertEqual("myhost", hostname)
1160f5
@@ -457,7 +458,7 @@ class TestGetHostnameFqdn(CiTestCase):
1160f5
     def test_get_hostname_fqdn_from_without_fqdn_or_hostname(self):
1160f5
         """When cfg has neither hostname nor fqdn cloud.get_hostname."""
1160f5
         mycloud = FakeCloud("cloudhost", "cloudhost.mycloud.com")
1160f5
-        hostname, fqdn = util.get_hostname_fqdn(cfg={}, cloud=mycloud)
1160f5
+        hostname, fqdn, _ = util.get_hostname_fqdn(cfg={}, cloud=mycloud)
1160f5
         self.assertEqual("cloudhost", hostname)
1160f5
         self.assertEqual("cloudhost.mycloud.com", fqdn)
1160f5
         self.assertEqual(
1160f5
@@ -468,7 +469,7 @@ class TestGetHostnameFqdn(CiTestCase):
1160f5
     def test_get_hostname_fqdn_from_passes_metadata_only_to_cloud(self):
1160f5
         """Calls to cloud.get_hostname pass the metadata_only parameter."""
1160f5
         mycloud = FakeCloud("cloudhost", "cloudhost.mycloud.com")
1160f5
-        _hn, _fqdn = util.get_hostname_fqdn(
1160f5
+        _hn, _fqdn, _def_hostname = util.get_hostname_fqdn(
1160f5
             cfg={}, cloud=mycloud, metadata_only=True
1160f5
         )
1160f5
         self.assertEqual(
1160f5
diff --git a/tests/unittests/util.py b/tests/unittests/util.py
1160f5
index 79a6e1d0..6fb39506 100644
1160f5
--- a/tests/unittests/util.py
1160f5
+++ b/tests/unittests/util.py
1160f5
@@ -1,5 +1,6 @@
1160f5
 # This file is part of cloud-init. See LICENSE file for license information.
1160f5
 from cloudinit import cloud, distros, helpers
1160f5
+from cloudinit.sources import DataSourceHostname
1160f5
 from cloudinit.sources.DataSourceNone import DataSourceNone
1160f5
 
1160f5
 
1160f5
@@ -37,7 +38,7 @@ def abstract_to_concrete(abclass):
1160f5
 
1160f5
 class DataSourceTesting(DataSourceNone):
1160f5
     def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
1160f5
-        return "hostname"
1160f5
+        return DataSourceHostname("hostname", False)
1160f5
 
1160f5
     def persist_instance_data(self):
1160f5
         return True
1160f5
-- 
1160f5
2.31.1
1160f5