Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/cfengine_cli/cfengine_wrapper/cfengine_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
3 changes: 2 additions & 1 deletion src/cfengine_cli/cfengine_wrapper/cfengine_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
39 changes: 36 additions & 3 deletions src/cfengine_cli/cfengine_wrapper/cfengine_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -153,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.")
Expand Down Expand Up @@ -216,6 +228,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:
Expand Down Expand Up @@ -244,7 +277,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})"
Expand Down
9 changes: 6 additions & 3 deletions src/cfengine_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
36 changes: 32 additions & 4 deletions src/cfengine_cli/initialize_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -94,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,
Expand Down Expand Up @@ -127,6 +130,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'")
Expand Down Expand Up @@ -172,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 [])

Expand Down Expand Up @@ -255,15 +267,31 @@ 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";

reports:
"$(message)";
}}

body file control
{{
namespace => "default";
}}

bundle agent __main__
{{
methods:
"{namespace}:main";
}}
"""


Expand Down
95 changes: 95 additions & 0 deletions tests/unit/test_cfengine_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import os

import pytest

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
# ---------------------------------------------------------------------------


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"]),
("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"]
11 changes: 11 additions & 0 deletions tests/unit/test_initialize_project.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading