From 972b2cfaf96ccb2b7ed844f4a08926c3d3045184 Mon Sep 17 00:00:00 2001 From: Brett Holman Date: Mon, 29 Jan 2024 16:59:27 -0700 Subject: [PATCH 1/5] wip: Respect rundir --- cloudinit/cmd/devel/logs.py | 6 +++--- cloudinit/cmd/devel/render.py | 5 +++-- cloudinit/cmd/main.py | 10 ++++----- cloudinit/settings.py | 2 -- cloudinit/stages.py | 38 +++++++++++++++++++++++------------ 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/cloudinit/cmd/devel/logs.py b/cloudinit/cmd/devel/logs.py index d888d07f396..3ac3ee4835d 100755 --- a/cloudinit/cmd/devel/logs.py +++ b/cloudinit/cmd/devel/logs.py @@ -16,7 +16,6 @@ from typing import NamedTuple from cloudinit.cmd.devel import read_cfg_paths -from cloudinit.helpers import Paths from cloudinit.stages import Init from cloudinit.subp import ProcessExecutionError, subp from cloudinit.temp_utils import tempdir @@ -28,7 +27,8 @@ write_file, ) -CLOUDINIT_RUN_DIR = "/run/cloud-init" +PATHS = read_cfg_paths() +CLOUDINIT_RUN_DIR = PATHS.run_dir class ApportFile(NamedTuple): @@ -144,7 +144,7 @@ def _copytree_rundir_ignore_files(curdir, files): ] if os.getuid() != 0: # Ignore root-permissioned files - ignored_files.append(Paths({}).lookups["instance_data_sensitive"]) + ignored_files.append(PATHS.lookups["instance_data_sensitive"]) return ignored_files diff --git a/cloudinit/cmd/devel/render.py b/cloudinit/cmd/devel/render.py index 99c24e1deb6..152b7768197 100755 --- a/cloudinit/cmd/devel/render.py +++ b/cloudinit/cmd/devel/render.py @@ -18,6 +18,7 @@ ) NAME = "render" +CLOUDINIT_RUN_DIR = read_cfg_paths().run_dir LOG = logging.getLogger(__name__) @@ -40,8 +41,8 @@ def get_parser(parser=None): "--instance-data", type=str, help=( - "Optional path to instance-data.json file. Defaults to" - " /run/cloud-init/instance-data.json" + "Optional path to instance-data.json file. " + f"Defaults to {CLOUDINIT_RUN_DIR}" ), ) parser.add_argument( diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py index 7aa6b445e16..aeb7de89a5f 100644 --- a/cloudinit/cmd/main.py +++ b/cloudinit/cmd/main.py @@ -697,12 +697,10 @@ def main_single(name, args): return 0 -def status_wrapper(name, args, data_d=None, link_d=None): - if data_d is None: - paths = read_cfg_paths() - data_d = paths.get_cpath("data") - if link_d is None: - link_d = os.path.normpath("/run/cloud-init") +def status_wrapper(name, args): + paths = read_cfg_paths() + data_d = paths.get_cpath("data") + link_d = os.path.normpath(paths.run_dir) status_path = os.path.join(data_d, "status.json") status_link = os.path.join(link_d, "status.json") diff --git a/cloudinit/settings.py b/cloudinit/settings.py index 6f06ea3ace2..4b533e1a085 100644 --- a/cloudinit/settings.py +++ b/cloudinit/settings.py @@ -16,8 +16,6 @@ CLEAN_RUNPARTS_DIR = "/etc/cloud/clean.d" -RUN_CLOUD_CONFIG = "/run/cloud-init/cloud.cfg" - # What u get if no config is provided CFG_BUILTIN = { "datasource_list": [ diff --git a/cloudinit/stages.py b/cloudinit/stages.py index c228805d11e..fd8d603c55c 100644 --- a/cloudinit/stages.py +++ b/cloudinit/stages.py @@ -38,13 +38,7 @@ ) from cloudinit.net import cmdline from cloudinit.reporting import events -from cloudinit.settings import ( - CLOUD_CONFIG, - PER_ALWAYS, - PER_INSTANCE, - PER_ONCE, - RUN_CLOUD_CONFIG, -) +from cloudinit.settings import CLOUD_CONFIG, PER_ALWAYS, PER_INSTANCE, PER_ONCE from cloudinit.sources import NetworkConfigSource LOG = logging.getLogger(__name__) @@ -275,7 +269,20 @@ def read_cfg(self, extra_fns=None): self._cfg = self._read_cfg(extra_fns) def _read_cfg(self, extra_fns): - no_cfg_paths = helpers.Paths({}, self.datasource) + """read and merge our configuration""" + # No config is passed to Paths() here because we don't yet have a + # config to pass. We must bootstrap a config to identify + # distro-specific rund_dir locations. Once we have the run_dir + # we re-read our config with a valid Paths() object. This code has to + # assume the location of /etc/cloud/cloud.cfg && /etc/cloud/cloud.cfg.d + bootstrapped_config = self._read_bootstrap_cfg(extra_fns, {}) + + # Now that we know run_dir, lets re-read the config to get a valid + # configuration + return self._read_bootstrap_cfg(extra_fns, bootstrapped_config) + + def _read_bootstrap_cfg(self, extra_fns, bootstrapped_config: dict): + no_cfg_paths = helpers.Paths(bootstrapped_config, self.datasource) instance_data_file = no_cfg_paths.get_runpath( "instance_data_sensitive" ) @@ -283,7 +290,9 @@ def _read_cfg(self, extra_fns): paths=no_cfg_paths, datasource=self.datasource, additional_fns=extra_fns, - base_cfg=fetch_base_config(instance_data_file=instance_data_file), + base_cfg=fetch_base_config( + no_cfg_paths.run_dir, instance_data_file=instance_data_file + ), ) return merger.cfg @@ -510,6 +519,9 @@ def is_new_instance(self): return ret def fetch(self, existing="check"): + """optionally load datasource from cache, otherwise discovery + datasource + """ return self._get_data_source(existing=existing) def instancify(self): @@ -1088,11 +1100,11 @@ def should_run_on_boot_event(): return -def read_runtime_config(): - return util.read_conf(RUN_CLOUD_CONFIG) +def read_runtime_config(run_dir: str): + return util.read_conf(run_dir) -def fetch_base_config(*, instance_data_file=None) -> dict: +def fetch_base_config(run_dir: str, *, instance_data_file=None) -> dict: return util.mergemanydict( [ # builtin config, hardcoded in settings.py. @@ -1102,7 +1114,7 @@ def fetch_base_config(*, instance_data_file=None) -> dict: CLOUD_CONFIG, instance_data_file=instance_data_file ), # runtime config. I.e., /run/cloud-init/cloud.cfg - read_runtime_config(), + read_runtime_config(run_dir), # Kernel/cmdline parameters override system config util.read_conf_from_cmdline(), ], From 833171c17e470c24803a5a25522f0cb80e3bdbf5 Mon Sep 17 00:00:00 2001 From: Brett Holman Date: Wed, 28 Feb 2024 18:21:47 -0700 Subject: [PATCH 2/5] comments --- cloudinit/helpers.py | 4 ++-- cloudinit/settings.py | 2 ++ cloudinit/stages.py | 28 ++++++++++++++++++++-------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/cloudinit/helpers.py b/cloudinit/helpers.py index 2c25dfc2c83..97036d6605f 100644 --- a/cloudinit/helpers.py +++ b/cloudinit/helpers.py @@ -15,7 +15,7 @@ from io import StringIO from time import time -from cloudinit import persistence, type_utils, util +from cloudinit import persistence, settings, type_utils, util from cloudinit.settings import CFG_ENV_NAME, PER_ALWAYS, PER_INSTANCE, PER_ONCE LOG = logging.getLogger(__name__) @@ -307,7 +307,7 @@ def __init__(self, path_cfgs: dict, ds=None): self.cfgs = path_cfgs # Populate all the initial paths self.cloud_dir: str = path_cfgs.get("cloud_dir", "/var/lib/cloud") - self.run_dir: str = path_cfgs.get("run_dir", "/run/cloud-init") + self.run_dir: str = path_cfgs.get("run_dir", settings.DEFAULT_RUN_DIR) self.instance_link: str = os.path.join(self.cloud_dir, "instance") self.boot_finished: str = os.path.join( self.instance_link, "boot-finished" diff --git a/cloudinit/settings.py b/cloudinit/settings.py index 4b533e1a085..b075682f964 100644 --- a/cloudinit/settings.py +++ b/cloudinit/settings.py @@ -16,6 +16,8 @@ CLEAN_RUNPARTS_DIR = "/etc/cloud/clean.d" +DEFAULT_RUN_DIR = "/run/cloud-init" + # What u get if no config is provided CFG_BUILTIN = { "datasource_list": [ diff --git a/cloudinit/stages.py b/cloudinit/stages.py index fd8d603c55c..6ed4d1754c3 100644 --- a/cloudinit/stages.py +++ b/cloudinit/stages.py @@ -38,7 +38,13 @@ ) from cloudinit.net import cmdline from cloudinit.reporting import events -from cloudinit.settings import CLOUD_CONFIG, PER_ALWAYS, PER_INSTANCE, PER_ONCE +from cloudinit.settings import ( + CLOUD_CONFIG, + DEFAULT_RUN_DIR, + PER_ALWAYS, + PER_INSTANCE, + PER_ONCE, +) from cloudinit.sources import NetworkConfigSource LOG = logging.getLogger(__name__) @@ -272,14 +278,20 @@ def _read_cfg(self, extra_fns): """read and merge our configuration""" # No config is passed to Paths() here because we don't yet have a # config to pass. We must bootstrap a config to identify - # distro-specific rund_dir locations. Once we have the run_dir + # distro-specific run_dir locations. Once we have the run_dir # we re-read our config with a valid Paths() object. This code has to # assume the location of /etc/cloud/cloud.cfg && /etc/cloud/cloud.cfg.d - bootstrapped_config = self._read_bootstrap_cfg(extra_fns, {}) - # Now that we know run_dir, lets re-read the config to get a valid - # configuration - return self._read_bootstrap_cfg(extra_fns, bootstrapped_config) + inital_config = self._read_bootstrap_cfg(extra_fns, {}) + paths = inital_config.get("system_info", {}).get("paths", {}) + + # run_dir hasn't changed so we can safely return the config + if DEFAULT_RUN_DIR == paths.get("run_dir"): + return inital_config + + # run_dir has changed so re-read the config to get a valid one + # using the new location of run_dir + return self._read_bootstrap_cfg(extra_fns, paths) def _read_bootstrap_cfg(self, extra_fns, bootstrapped_config: dict): no_cfg_paths = helpers.Paths(bootstrapped_config, self.datasource) @@ -519,7 +531,7 @@ def is_new_instance(self): return ret def fetch(self, existing="check"): - """optionally load datasource from cache, otherwise discovery + """optionally load datasource from cache, otherwise discover datasource """ return self._get_data_source(existing=existing) @@ -1101,7 +1113,7 @@ def should_run_on_boot_event(): def read_runtime_config(run_dir: str): - return util.read_conf(run_dir) + return util.read_conf(os.path.join(run_dir, "cloud.cfg")) def fetch_base_config(run_dir: str, *, instance_data_file=None) -> dict: From 892f090e5a7a532d45bd426be360fea65649af08 Mon Sep 17 00:00:00 2001 From: Brett Holman Date: Thu, 29 Feb 2024 09:00:26 -0700 Subject: [PATCH 3/5] fix tests --- tests/unittests/test_cli.py | 51 +++++++++++++++++++++++++----------- tests/unittests/test_data.py | 14 +++++----- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py index 2681cddde7e..0e0129d9877 100644 --- a/tests/unittests/test_cli.py +++ b/tests/unittests/test_cli.py @@ -15,6 +15,7 @@ mock = test_helpers.mock M_PATH = "cloudinit.cmd.main." +Tmpdir = namedtuple("Tmpdir", ["tmpdir", "link_d", "data_d"]) @pytest.fixture(autouse=False) @@ -33,6 +34,19 @@ def disable_setup_logging(): yield +@pytest.fixture(autouse=False) +def mock_status_wrapper(mocker, tmpdir): + link_d = os.path.join(tmpdir, "link") + data_d = os.path.join(tmpdir, "data") + with mocker.patch( + "cloudinit.cmd.main.read_cfg_paths", + return_value=mock.Mock(get_cpath=lambda _: data_d), + ), mocker.patch( + "cloudinit.cmd.main.os.path.normpath", return_value=link_d + ): + yield Tmpdir(tmpdir, link_d, data_d) + + class TestCLI: def _call_main(self, sysv_args=None): if not sysv_args: @@ -59,29 +73,29 @@ def _call_main(self, sysv_args=None): ), ], ) - def test_status_wrapper_errors(self, action, name, match, caplog, tmpdir): - data_d = tmpdir.join("data") - link_d = tmpdir.join("link") + def test_status_wrapper_errors( + self, action, name, match, caplog, mock_status_wrapper + ): FakeArgs = namedtuple("FakeArgs", ["action", "local", "mode"]) my_action = mock.Mock() myargs = FakeArgs((action, my_action), False, "bogusmode") with pytest.raises(ValueError, match=match): - cli.status_wrapper(name, myargs, data_d, link_d) + cli.status_wrapper(name, myargs) assert [] == my_action.call_args_list @mock.patch("cloudinit.cmd.main.atomic_helper.write_json") def test_status_wrapper_init_local_writes_fresh_status_info( self, m_json, - tmpdir, + mock_status_wrapper, ): """When running in init-local mode, status_wrapper writes status.json. Old status and results artifacts are also removed. """ - data_d = tmpdir.join("data") - link_d = tmpdir.join("link") + data_d = mock_status_wrapper.data_d + link_d = mock_status_wrapper.link_d # Write old artifacts which will be removed or updated. for _dir in data_d, link_d: test_helpers.populate_dir( @@ -95,7 +109,7 @@ def myaction(name, args): return "SomeDatasource", ["an error"] myargs = FakeArgs(("ignored_name", myaction), True, "bogusmode") - cli.status_wrapper("init", myargs, data_d, link_d) + cli.status_wrapper("init", myargs) # No errors reported in status status_v1 = m_json.call_args_list[1][0][1]["v1"] assert status_v1.keys() == { @@ -117,14 +131,14 @@ def myaction(name, args): @mock.patch("cloudinit.cmd.main.atomic_helper.write_json") def test_status_wrapper_init_local_honor_cloud_dir( - self, m_json, mocker, tmpdir + self, m_json, mocker, mock_status_wrapper ): """When running in init-local mode, status_wrapper honors cloud_dir.""" - cloud_dir = tmpdir.join("cloud") + cloud_dir = mock_status_wrapper.tmpdir.join("cloud") paths = helpers.Paths({"cloud_dir": str(cloud_dir)}) mocker.patch(M_PATH + "read_cfg_paths", return_value=paths) - data_d = cloud_dir.join("data") - link_d = tmpdir.join("link") + data_d = mock_status_wrapper.data_d + link_d = mock_status_wrapper.link_d FakeArgs = namedtuple("FakeArgs", ["action", "local", "mode"]) @@ -133,7 +147,7 @@ def myaction(name, args): return "SomeDatasource", ["an_error"] myargs = FakeArgs(("ignored_name", myaction), True, "bogusmode") - cli.status_wrapper("init", myargs, link_d=link_d) # No explicit data_d + cli.status_wrapper("init", myargs) # No explicit data_d # Access cloud_dir directly status_v1 = m_json.call_args_list[1][0][1]["v1"] @@ -243,7 +257,12 @@ def test_modules_subcommand_parser(self, m_status_wrapper, subcommand): ) @mock.patch("cloudinit.stages.Init._read_cfg", return_value={}) def test_conditional_subcommands_from_entry_point_sys_argv( - self, m_read_cfg, subcommand, capsys, mock_get_user_data_file, tmpdir + self, + m_read_cfg, + subcommand, + capsys, + mock_get_user_data_file, + mock_status_wrapper, ): """Subcommands from entry-point are properly parsed from sys.argv.""" expected_error = f"usage: cloud-init {subcommand}" @@ -264,7 +283,9 @@ def test_conditional_subcommands_from_entry_point_sys_argv( "status", ], ) - def test_subcommand_parser(self, subcommand, mock_get_user_data_file): + def test_subcommand_parser( + self, subcommand, mock_get_user_data_file, mock_status_wrapper + ): """cloud-init `subcommand` calls its subparser.""" # Provide -h param to `subcommand` to avoid having to mock behavior. out = io.StringIO() diff --git a/tests/unittests/test_data.py b/tests/unittests/test_data.py index 823aab58afc..1ee3dfa9007 100644 --- a/tests/unittests/test_data.py +++ b/tests/unittests/test_data.py @@ -23,7 +23,7 @@ from cloudinit import user_data as ud from cloudinit import util from cloudinit.config.modules import Modules -from cloudinit.settings import PER_INSTANCE +from cloudinit.settings import DEFAULT_RUN_DIR, PER_INSTANCE from tests.unittests import helpers from tests.unittests.util import FakeDataSource @@ -826,7 +826,7 @@ def mocks(self, mocker): def test_only_builtin_gets_builtin(self, mocker): mocker.patch(f"{MPATH}.read_runtime_config", return_value={}) mocker.patch(f"{MPATH}.util.read_conf_with_confd") - config = stages.fetch_base_config() + config = stages.fetch_base_config(DEFAULT_RUN_DIR) assert util.get_builtin_cfg() == config def test_conf_d_overrides_defaults(self, mocker): @@ -839,7 +839,7 @@ def test_conf_d_overrides_defaults(self, mocker): return_value={test_key: test_value}, ) mocker.patch(f"{MPATH}.read_runtime_config", return_value={}) - config = stages.fetch_base_config() + config = stages.fetch_base_config(DEFAULT_RUN_DIR) assert config.get(test_key) == test_value builtin[test_key] = test_value assert config == builtin @@ -853,7 +853,7 @@ def test_confd_with_template(self, mocker, tmp_path: Path): mocker.patch("cloudinit.stages.CLOUD_CONFIG", cfg_path) mocker.patch(f"{MPATH}.util.get_builtin_cfg", return_value={}) config = stages.fetch_base_config( - instance_data_file=instance_data_path + DEFAULT_RUN_DIR, instance_data_file=instance_data_path ) assert config == {"key": "template_value"} @@ -869,7 +869,7 @@ def test_cmdline_overrides_defaults(self, mocker): return_value=cmdline, ) mocker.patch(f"{MPATH}.read_runtime_config") - config = stages.fetch_base_config() + config = stages.fetch_base_config(DEFAULT_RUN_DIR) assert config.get(test_key) == test_value builtin[test_key] = test_value assert config == builtin @@ -888,7 +888,7 @@ def test_cmdline_overrides_confd_runtime_and_defaults(self, mocker): return_value=cmdline, ) - config = stages.fetch_base_config() + config = stages.fetch_base_config(DEFAULT_RUN_DIR) assert config == {"key1": "value1", "key2": "other2", "key3": "other3"} def test_order_precedence_is_builtin_system_runtime_cmdline(self, mocker): @@ -905,7 +905,7 @@ def test_order_precedence_is_builtin_system_runtime_cmdline(self, mocker): ) mocker.patch(f"{MPATH}.read_runtime_config", return_value=runtime) - config = stages.fetch_base_config() + config = stages.fetch_base_config(DEFAULT_RUN_DIR) assert config == { "key1": "cmdline1", From 06039be834989dfcd277cded14f6ab3612416f71 Mon Sep 17 00:00:00 2001 From: Brett Holman Date: Wed, 6 Mar 2024 20:00:55 -0700 Subject: [PATCH 4/5] comments --- cloudinit/stages.py | 8 ++++---- tests/unittests/test_cli.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cloudinit/stages.py b/cloudinit/stages.py index 6ed4d1754c3..894eeac5960 100644 --- a/cloudinit/stages.py +++ b/cloudinit/stages.py @@ -282,12 +282,12 @@ def _read_cfg(self, extra_fns): # we re-read our config with a valid Paths() object. This code has to # assume the location of /etc/cloud/cloud.cfg && /etc/cloud/cloud.cfg.d - inital_config = self._read_bootstrap_cfg(extra_fns, {}) - paths = inital_config.get("system_info", {}).get("paths", {}) + initial_config = self._read_bootstrap_cfg(extra_fns, {}) + paths = initial_config.get("system_info", {}).get("paths", {}) # run_dir hasn't changed so we can safely return the config - if DEFAULT_RUN_DIR == paths.get("run_dir"): - return inital_config + if paths.get("run_dir") in (DEFAULT_RUN_DIR, None): + return initial_config # run_dir has changed so re-read the config to get a valid one # using the new location of run_dir diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py index 0e0129d9877..b17d719fa20 100644 --- a/tests/unittests/test_cli.py +++ b/tests/unittests/test_cli.py @@ -18,7 +18,7 @@ Tmpdir = namedtuple("Tmpdir", ["tmpdir", "link_d", "data_d"]) -@pytest.fixture(autouse=False) +@pytest.fixture() def mock_get_user_data_file(mocker, tmpdir): yield mocker.patch( "cloudinit.cmd.devel.logs._get_user_data_file", @@ -34,7 +34,7 @@ def disable_setup_logging(): yield -@pytest.fixture(autouse=False) +@pytest.fixture() def mock_status_wrapper(mocker, tmpdir): link_d = os.path.join(tmpdir, "link") data_d = os.path.join(tmpdir, "data") From 4cf87438221ff4c48d4b4926f652388aeb4e85d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mina=20Gali=C4=87?= Date: Fri, 15 Mar 2024 11:50:07 +0000 Subject: [PATCH 5/5] fix runpath location on BSDs and rc.d ordering --- config/cloud.cfg.tmpl | 2 +- sysvinit/freebsd/cloudinitlocal.tmpl | 2 +- sysvinit/freebsd/dsidentify.tmpl | 8 +------- tools/build-on-openbsd | 1 + 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index 43a603e80b0..d69c51d8395 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -331,7 +331,7 @@ system_info: templates_dir: /etc/cloud/templates/ {% elif is_bsd %} paths: - run_dir: /var/run/ + run_dir: /var/run/cloud-init/ {% endif %} {% if variant == "debian" %} package_mirrors: diff --git a/sysvinit/freebsd/cloudinitlocal.tmpl b/sysvinit/freebsd/cloudinitlocal.tmpl index acf8c20a854..c6a65194e33 100755 --- a/sysvinit/freebsd/cloudinitlocal.tmpl +++ b/sysvinit/freebsd/cloudinitlocal.tmpl @@ -6,7 +6,7 @@ ``cloudinitlocal`` purposefully does not depend on ``dsidentify``. That makes it easy for image builders to disable ``dsidentify``. #} -# REQUIRE: ldconfig mountcritlocal +# REQUIRE: ldconfig cleanvar # BEFORE: NETWORKING cloudinit cloudconfig cloudfinal . /etc/rc.subr diff --git a/sysvinit/freebsd/dsidentify.tmpl b/sysvinit/freebsd/dsidentify.tmpl index d18e0042d68..96bc88aae9a 100755 --- a/sysvinit/freebsd/dsidentify.tmpl +++ b/sysvinit/freebsd/dsidentify.tmpl @@ -2,13 +2,7 @@ #!/bin/sh # PROVIDE: dsidentify -{# -once we are correctly using ``paths.run_dir`` / ``paths.get_runpath()`` in the -python code-base, we can start thinking about how to bring that into -``ds-identify`` itself, and then!, then we can depend on (``REQUIRE``) -``var_run`` instead of ``mountcritlocal`` here. -#} -# REQUIRE: mountcritlocal +# REQUIRE: cleanvar # BEFORE: cloudinitlocal . /etc/rc.subr diff --git a/tools/build-on-openbsd b/tools/build-on-openbsd index 93a9d501676..09262aff6ce 100755 --- a/tools/build-on-openbsd +++ b/tools/build-on-openbsd @@ -43,6 +43,7 @@ else RC_LOCAL="/etc/rc.local" RC_LOCAL_CONTENT=" +rm -rf /var/run/cloud-init /usr/local/lib/cloud-init/ds-identify cloud-init init --local