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