canonical / cloud-init

Official upstream for the cloud-init: cloud instance initialization
https://cloud-init.io/
Other
2.87k stars 857 forks source link

[enhancement]: Provide a mechanism that allows bootcmd and runcmd to be preserved when issued in cloud.cfg #5738

Open caavery opened 3 hours ago

caavery commented 3 hours ago

Enhancement

I have been working on an idea that will allow both runcmd and bootcmd to not be overwritten when issued through cloud.cfg. The problem is described in more detail in #2611. I am presenting this idea and the associated rough patches as a proof of concept to start the discussion about this or any other solution. Basically I am introducing a new setting for cloud.cfg called persistent_cmds which indicates the bootcmd and runcmd to be executed and cannot be overwritten by subsequent user data. These commands are stripped off and saved to be applied after the user data has been merged.

Example cloud.cfg:

persistent_cmds:
     bootcmd:
        - touch /root/file0
        - touch /root/file1

@TheRealFalcon

Thanks, Cathy

caavery commented 3 hours ago

I seem to be having trouble attaching my patch file

We don’t support that file type. Try again with GIF, JPEG, JPG, MOV, MP4, PNG, SVG, WEBM, CPUPROFILE, CSV, DMP, DOCX, FODG, FODP, FODS, FODT, GZ, JSON, JSONC, LOG, MD, ODF, ODG, ODP, ODS, ODT, PATCH, PDF, PPTX, TGZ, TXT, XLS, XLSX or ZIP.

For now I'll just cat it out.

From 4fdb088b4d417c6c41c5b73d71ac0b621128d6c8 Mon Sep 17 00:00:00 2001
From: Cathy Avery <cavery@redhat.com>
Date: Tue, 24 Sep 2024 10:16:46 -0400
Subject: [PATCH] Persistent commands proof of concept

---
 cloudinit/config/modules.py | 16 +++++++++++++++-
 cloudinit/settings.py       |  5 +++++
 cloudinit/stages.py         | 18 ++++++++++++++++++
 3 files changed, 38 insertions(+), 1 deletion(-)

diff --git a/cloudinit/config/modules.py b/cloudinit/config/modules.py
index a82e1ff8e..f6841fd18 100644
--- a/cloudinit/config/modules.py
+++ b/cloudinit/config/modules.py
@@ -16,7 +16,7 @@ from cloudinit import config, importer, lifecycle, type_utils, util
 from cloudinit.distros import ALL_DISTROS
 from cloudinit.helpers import ConfigMerger
 from cloudinit.reporting.events import ReportEventStack
-from cloudinit.settings import FREQUENCIES
+from cloudinit.settings import FREQUENCIES, PERSISTENT_CMD_LIST
 from cloudinit.stages import Init

 LOG = logging.getLogger(__name__)
@@ -117,9 +117,23 @@ class Modules:
                 base_cfg=self.init.cfg,
             )
             self._cached_cfg = merger.cfg
+            self._merge_persistent_cmds(self.init.persistent_cmds)
         # Only give out a copy so that others can't modify this...
         return copy.deepcopy(self._cached_cfg)

+    def _merge_persistent_cmds(self, persistent_cmds):
+        for key in PERSISTENT_CMD_LIST:
+            if (
+                persist_value := persistent_cmds.get(key)
+            ) and persist_value is not None:
+                # reorder so the persistent commands will be the first to be executed
+                if cached_value := self._cached_cfg.get(key):
+                    for i in range(len(persist_value)):
+                        cached_value.insert(i, persist_value[i])
+                    self._cached_cfg[key] = cached_value
+                else:
+                    self._cached_cfg[key] = persist_value
+
     def _read_modules(self, name) -> List[Dict]:
         """Read the modules from the config file given the specified name.

diff --git a/cloudinit/settings.py b/cloudinit/settings.py
index a73f25118..b500f107d 100644
--- a/cloudinit/settings.py
+++ b/cloudinit/settings.py
@@ -18,6 +18,11 @@ CLEAN_RUNPARTS_DIR = "/etc/cloud/clean.d"

 DEFAULT_RUN_DIR = "/run/cloud-init"

+PERSISTENT_CMD_LIST = [
+    "bootcmd",
+    "runcmd",
+]
+
 # What u get if no config is provided
 CFG_BUILTIN = {
     "datasource_list": [
diff --git a/cloudinit/stages.py b/cloudinit/stages.py
index ff0e336e8..0071df258 100644
--- a/cloudinit/stages.py
+++ b/cloudinit/stages.py
@@ -47,6 +47,7 @@ from cloudinit.settings import (
     PER_ALWAYS,
     PER_INSTANCE,
     PER_ONCE,
+    PERSISTENT_CMD_LIST,
 )
 from cloudinit.sources import NetworkConfigSource

@@ -142,6 +143,8 @@ class Init:
         self._cfg: Dict[str, Any] = {}
         self._paths: Optional[helpers.Paths] = None
         self._distro: Optional[distros.Distro] = None
+        self._persistent_cmds: Optional[dict] = None
+
         # Changed only when a fetch occurs
         self.datasource: Optional[sources.DataSource] = None
         self.ds_restored = False
@@ -178,6 +181,10 @@ class Init:
                 self.datasource.sys_cfg = self.cfg
         return self._distro

+    @property
+    def persistent_cmds(self):
+        return self._persistent_cmds
+
     @property
     def cfg(self):
         return self._extract_cfg("restricted")
@@ -301,6 +308,7 @@ class Init:
         instance_data_file = no_cfg_paths.get_runpath(
             "instance_data_sensitive"
         )
+        self._persistent_cmds = get_persistent_cmds(read_cloud_config())
         merger = helpers.ConfigMerger(
             paths=no_cfg_paths,
             datasource=self.datasource,
@@ -1140,6 +1148,16 @@ class Init:
 def read_runtime_config(run_dir: str):
     return util.read_conf(os.path.join(run_dir, "cloud.cfg"))

+def read_cloud_config():
+    return util.read_conf(CLOUD_CONFIG)
+
+def get_persistent_cmds(cfg):
+    valid_cmds = {}
+    if (persistent_cmds := cfg.get("persistent_cmds")) is not None:
+        for key in PERSISTENT_CMD_LIST:
+            if (value := persistent_cmds.get(key)) and value is not None:
+                valid_cmds[key] = value
+    return valid_cmds

 def fetch_base_config(run_dir: str, *, instance_data_file=None) -> dict:
     return util.mergemanydict(
-- 
2.42.0
aciba90 commented 3 hours ago

https://github.com/canonical/cloud-init/issues/2611