From a71deeb16fad2426c46c977935b95d208696bda7 Mon Sep 17 00:00:00 2001 From: Victor Moene Date: Thu, 5 Nov 2026 09:18:11 +0100 Subject: [PATCH 1/6] Included stderr in output of commands run on remote hosts Previously only stdout was captured, so errors reported on stderr, such as cf-promises complaining about a duplicate bundle, were silently lost. Ticket: ENT-14917 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Victor Moene --- src/cfengine_cli/cfengine_wrapper/cfengine_objects.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py b/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py index d8c917f..193d9bf 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py @@ -72,7 +72,8 @@ def _run_one(self, command: str) -> int: result = subprocess.run(args) return result.returncode - full_command = f"{self.path} {command}" + # Include stderr, so cf-promises errors (e.g. duplicate bundle) are shown + full_command = f"{self.path} {command} 2>&1" log.info(f"Executing command {full_command} on {self.location}") output = run_command(self.location, full_command, sudo=True) if ( From f634c09115560b49fef3b24dc5e5187bda1b2aae Mon Sep 17 00:00:00 2001 From: Victor Moene Date: Thu, 5 Nov 2026 09:18:12 +0100 Subject: [PATCH 2/6] Added .gitignore to cfengine init Ticket: ENT-14917 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Victor Moene --- src/cfengine_cli/commands.py | 9 ++++++--- src/cfengine_cli/initialize_project.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/cfengine_cli/commands.py b/src/cfengine_cli/commands.py index 004de5f..2281576 100644 --- a/src/cfengine_cli/commands.py +++ b/src/cfengine_cli/commands.py @@ -9,9 +9,12 @@ from cfengine_cli.format import format_paths from cfengine_cli.utils import UserError from cfengine_cli.up import validate_config, up_do, resolve_templates -from cfengine_cli.initialize_project import init_policy_module, init_promise_type +from cfengine_cli.initialize_project import ( + init_policy_module, + init_policy_set, + init_promise_type, +) from cf_remote.paths import cf_remote_dir -from cfbs.commands import init_command from cfengine_cli.cfengine_wrapper.cfengine_commands import test as _cfengine_test from pydantic import ValidationError @@ -78,7 +81,7 @@ def init(args): rc = init_promise_type(name=None) return rc - return init_command() # --policy-set, cfbs init, default + return init_policy_set() # --policy-set, cfbs init, default def profile(args) -> int: diff --git a/src/cfengine_cli/initialize_project.py b/src/cfengine_cli/initialize_project.py index ec331b8..f036c3d 100644 --- a/src/cfengine_cli/initialize_project.py +++ b/src/cfengine_cli/initialize_project.py @@ -8,6 +8,7 @@ from cfbs.utils import write_json, canonify from cfengine_cli.utils import UserError from cfbs.git import git_commit, git_init +from cfbs.commands import init_command GITIGNORE = "out/\n*.tgz\n" @@ -127,6 +128,15 @@ def init_policy_module(name=None, with_input=False, non_interactive=False): return 0 +def init_policy_set(): + rc = init_command() + if rc == 0 and not os.path.exists(".gitignore"): + _write_file(".gitignore", GITIGNORE) + if CFBSConfig.get_instance().get("git"): + git_commit("Added .gitignore", scope=[".gitignore"]) + return rc + + def _require_uninitialized(): if os.path.exists("cfbs.json"): raise UserError("Already initialized - look at 'cfbs.json'") From e6d3ccd8ec7bafe246057c4f824af82da0b2711f Mon Sep 17 00:00:00 2001 From: Victor Moene Date: Thu, 5 Nov 2026 09:18:13 +0100 Subject: [PATCH 3/6] Namespaced main bundle in cfengine init --policy-module Ticket: ENT-14917 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Victor Moene --- src/cfengine_cli/initialize_project.py | 26 ++++++++++++++++++++++---- tests/unit/test_initialize_project.py | 11 +++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_initialize_project.py diff --git a/src/cfengine_cli/initialize_project.py b/src/cfengine_cli/initialize_project.py index f036c3d..1af1822 100644 --- a/src/cfengine_cli/initialize_project.py +++ b/src/cfengine_cli/initialize_project.py @@ -95,7 +95,9 @@ def init_policy_module(name=None, with_input=False, non_interactive=False): files = { os.path.join(module_name, "main.cf"): ( - _main_cf_with_input(canonified) if with_input else _main_cf(name) + _main_cf_with_input(canonified) + if with_input + else _main_cf(name, canonified) ), "README.md": _module_readme(name, canonified, with_input), ".gitignore": GITIGNORE, @@ -182,7 +184,7 @@ def _module_definition(module_name, canonified, with_input, extra_steps=None): steps = [ f"copy main.cf {target}", f"policy_files {target}", - f"bundles {canonified}:main" if with_input else "bundles main", + f"bundles {canonified}:main", ] steps.extend(extra_steps or []) @@ -265,8 +267,13 @@ def evaluate_promise(self, promiser, attributes, metadata): """ -def _main_cf(name): - return f"""bundle agent main +def _main_cf(name, namespace): + return f"""body file control +{{ + namespace => "{namespace}"; +}} + +bundle agent main {{ vars: "message" string => "Hello from the '{name}' module"; @@ -274,6 +281,17 @@ def _main_cf(name): reports: "$(message)"; }} + +body file control +{{ + namespace => "default"; +}} + +bundle agent __main__ +{{ + methods: + "{namespace}:main"; +}} """ diff --git a/tests/unit/test_initialize_project.py b/tests/unit/test_initialize_project.py new file mode 100644 index 0000000..550968c --- /dev/null +++ b/tests/unit/test_initialize_project.py @@ -0,0 +1,11 @@ +from cfengine_cli import initialize_project + + +def test_policy_module_main_bundle_is_namespaced(): + main_cf = initialize_project._main_cf("My-Module", "my_module") + assert 'namespace => "my_module";' in main_cf + assert '"my_module:main";' in main_cf + + module = initialize_project._module_definition("my-module", "my_module", False) + assert "bundles my_module:main" in module["steps"] + assert "bundles main" not in module["steps"] From 10d863b175500e4879726614de03ab125c297494 Mon Sep 17 00:00:00 2001 From: Victor Moene Date: Thu, 5 Nov 2026 09:18:14 +0100 Subject: [PATCH 4/6] Made cfengine run only connect to the chosen host Ticket: ENT-14917 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Victor Moene --- .../cfengine_wrapper/cfengine_utils.py | 35 ++++++++++++++- tests/unit/test_cfengine_wrapper.py | 43 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_cfengine_wrapper.py diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py index c0348c6..4abefd4 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py @@ -111,7 +111,17 @@ def _hosts_with_info(role_filter=None): yield host, aliases, data -_Id = namedtuple("_Id", "location aliases") +class _Id(namedtuple("_Id", "location aliases")): + @property + def label(self) -> str: + if self.aliases and self.location != "localhost": + return f"{self.aliases[0]} ({self.location})" + return self.location + + +def known_hub_locations() -> list[str]: + """ "user@ip" of every hub cf-remote knows about (without connecting).""" + return [host for host, _ in _known_hosts("hub")] def _identities(binary_name: str) -> Iterator[_Id]: @@ -216,6 +226,27 @@ def _select(candidates, description, target: str | None = None): return _prompt_choice(candidates, description) +def _select_without_connecting(name: str) -> Executable: + """Only connects to the chosen host. Hubs first, so Enter picks a hub.""" + hubs = set(known_hub_locations()) + remote = [i for i in _identities(name) if i.location != "localhost"] + local = [_Id("localhost", ["localhost", "local"])] if _find_local_path(name) else [] + ordered = ( + [i for i in remote if i.location in hubs] + + local + + [i for i in remote if i.location not in hubs] + ) + if not ordered: + raise UserError( + f"Could not find {name} locally or on any configured remote host." + ) + ident = ordered[0] if len(ordered) == 1 else _prompt_choice(ordered, name) + chosen = _resolve(name, ident) + if chosen is None: + raise UserError(f"{ident.label} is unreachable or lacks {name}.") + return chosen + + def require_executable(name: str, target: str | None = None) -> Executable: if isinstance(target, list): if len(target) > 1: @@ -244,7 +275,7 @@ def require_executable(name: str, target: str | None = None) -> Executable: candidates[0] if len(candidates) == 1 else _prompt_choice(candidates, name) ) else: - chosen = _select(_find_all(name), name) + chosen = _select_without_connecting(name) log.info( f"Using {'local' if chosen.is_local else 'remote'} installation of {name} ({chosen.label})" diff --git a/tests/unit/test_cfengine_wrapper.py b/tests/unit/test_cfengine_wrapper.py new file mode 100644 index 0000000..a6467e4 --- /dev/null +++ b/tests/unit/test_cfengine_wrapper.py @@ -0,0 +1,43 @@ +import pytest + +from cfengine_cli.cfengine_wrapper import cfengine_utils + +# --------------------------------------------------------------------------- +# Host selection +# --------------------------------------------------------------------------- + + +def test_select_without_connecting_lists_hubs_first(monkeypatch): + known = [ + ("root@2.2.2.2", ["client"]), + ("root@1.1.1.1", ["hub"]), + ] + monkeypatch.setattr( + cfengine_utils, + "_known_hosts", + lambda role=None: iter([k for k in known if role != "hub" or k[1] == ["hub"]]), + ) + monkeypatch.setattr(cfengine_utils, "_find_local_path", lambda name: None) + + def no_info(host): + raise AssertionError(f"connected to {host} before it was chosen") + + monkeypatch.setattr(cfengine_utils, "_host_info", no_info) + + shown = [] + + def choose_first(candidates, description): + shown.extend(c.location for c in candidates) + return candidates[0] + + monkeypatch.setattr(cfengine_utils, "_prompt_choice", choose_first) + resolved = [] + monkeypatch.setattr( + cfengine_utils, + "_resolve", + lambda name, ident: resolved.append(ident.location) or "agent", + ) + + assert cfengine_utils._select_without_connecting("cf-agent") == "agent" + assert shown == ["root@1.1.1.1", "root@2.2.2.2"] + assert resolved == ["root@1.1.1.1"] From 49a1593227a21b758ad6b59d237e27c4dcc9c913 Mon Sep 17 00:00:00 2001 From: Victor Moene Date: Thu, 5 Nov 2026 09:18:14 +0100 Subject: [PATCH 5/6] Made Enter select the first hub in cfengine run Ticket: ENT-14917 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Victor Moene --- src/cfengine_cli/cfengine_wrapper/cfengine_utils.py | 4 +++- tests/unit/test_cfengine_wrapper.py | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py index 4abefd4..6bbf3b8 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py @@ -163,11 +163,13 @@ def _find_all_paired() -> list[Installation]: def _prompt_choice(candidates, description): + """Numbered prompt; pressing Enter picks the first candidate.""" print(f"Multiple installations of {description} found:") for i, c in enumerate(candidates, 1): print(f" {i}) {c.label}") while True: - choice = input(f"Select which to use [1-{len(candidates)}]: ").strip() + choice = input(f"Select which to use [1-{len(candidates)}, default 1]: ") + choice = choice.strip() or "1" if choice.isdigit() and 1 <= int(choice) <= len(candidates): return candidates[int(choice) - 1] print("Invalid selection, try again.") diff --git a/tests/unit/test_cfengine_wrapper.py b/tests/unit/test_cfengine_wrapper.py index a6467e4..33bcb4c 100644 --- a/tests/unit/test_cfengine_wrapper.py +++ b/tests/unit/test_cfengine_wrapper.py @@ -1,12 +1,19 @@ import pytest from cfengine_cli.cfengine_wrapper import cfengine_utils +from cfengine_cli.cfengine_wrapper.cfengine_utils import _Id # --------------------------------------------------------------------------- # Host selection # --------------------------------------------------------------------------- +def test_prompt_choice_defaults_to_first(monkeypatch): + monkeypatch.setattr("builtins.input", lambda _: "") + candidates = [_Id("root@1.1.1.1", ["hub"]), _Id("root@2.2.2.2", ["client"])] + assert cfengine_utils._prompt_choice(candidates, "cf-agent") == candidates[0] + + def test_select_without_connecting_lists_hubs_first(monkeypatch): known = [ ("root@2.2.2.2", ["client"]), From d66fb5d29bdead6b4289714f7b252d3394feb93a Mon Sep 17 00:00:00 2001 From: Victor Moene Date: Thu, 5 Nov 2026 09:18:14 +0100 Subject: [PATCH 6/6] Added build and deploy prompt to cfengine run when policy changed Ticket: ENT-14917 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Victor Moene --- .../cfengine_wrapper/cfengine_commands.py | 28 +++++++++++ tests/unit/test_cfengine_wrapper.py | 47 ++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py index 6389db4..8120247 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py @@ -220,9 +220,37 @@ def setup_code(target: str | None = None) -> int: return hub.run("--new-setup-code") +def _policy_changed_since_build() -> bool: + """True if there is no build, or project files changed since the last one.""" + tarball = os.path.join("out", "masterfiles.tgz") + if not os.path.isfile(tarball): + return True + built_at = os.path.getmtime(tarball) + for root, dirs, files in os.walk("."): + # Skip build output and hidden entries (.git, editor state, ...) + dirs[:] = [d for d in dirs if d[0] != "." and (root, d) != (".", "out")] + for name in files: + path = os.path.join(root, name) + if name[0] != "." and os.path.getmtime(path) > built_at: + return True + return False + + def run(*args, target: str | None = None) -> int: agent = require_executable("cf-agent", target) if not args: + if ( + is_cfbs_repo() + and has_known_hubs() + and _policy_changed_since_build() + and prompt_yes_no( + "The policy set has changed since it was last built. " + "Build and deploy it before running?" + ) + ): + rc = build_command() or deploy_command(None, None) + if rc != 0: + return rc return agent.run("-KIf update.cf", "-KI") resolved = [] diff --git a/tests/unit/test_cfengine_wrapper.py b/tests/unit/test_cfengine_wrapper.py index 33bcb4c..3e5eb3b 100644 --- a/tests/unit/test_cfengine_wrapper.py +++ b/tests/unit/test_cfengine_wrapper.py @@ -1,8 +1,53 @@ +import os + import pytest -from cfengine_cli.cfengine_wrapper import cfengine_utils +from cfengine_cli.cfengine_wrapper import cfengine_commands, cfengine_utils from cfengine_cli.cfengine_wrapper.cfengine_utils import _Id +# --------------------------------------------------------------------------- +# Detecting changes since the last build +# --------------------------------------------------------------------------- + + +@pytest.fixture +def built_project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "cfbs.json").write_text("{}") + (tmp_path / "mymodule").mkdir() + (tmp_path / "mymodule" / "main.cf").write_text("bundle agent main {}") + (tmp_path / "out").mkdir() + (tmp_path / "out" / "masterfiles.tgz").write_bytes(b"built") + # Sources older than the build + for path in ["cfbs.json", "mymodule/main.cf"]: + os.utime(tmp_path / path, (1000, 1000)) + return tmp_path + + +def test_changed_since_build_without_tarball(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert cfengine_commands._policy_changed_since_build() + + +def test_changed_since_build_up_to_date(built_project): + assert not cfengine_commands._policy_changed_since_build() + + +def test_changed_since_build_after_policy_change(built_project): + (built_project / "mymodule" / "main.cf").write_text("changed") + os.utime(built_project / "mymodule" / "main.cf", None) + os.utime(built_project / "out" / "masterfiles.tgz", (2000, 2000)) + assert cfengine_commands._policy_changed_since_build() + + +def test_changed_since_build_ignores_out_and_hidden(built_project): + os.utime(built_project / "out" / "masterfiles.tgz", (2000, 2000)) + (built_project / ".git").mkdir() + (built_project / ".git" / "index").write_text("") + (built_project / ".gitignore").write_text("out/\n") + assert not cfengine_commands._policy_changed_since_build() + + # --------------------------------------------------------------------------- # Host selection # ---------------------------------------------------------------------------