diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 8925f44..4ba10f4 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -51,4 +51,4 @@ jobs: name: release-dists path: dist/ - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@v1.14.1 + uses: pypa/gh-action-pypi-publish@v1.14.2 diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index fd8cb56..553f2c1 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout uses: actions/checkout@v7.0.1 - name: Self-hosted Renovate - uses: renovatebot/github-action@v46.1.20 + uses: renovatebot/github-action@v46.2.0 with: configurationFile: .github/renovate-config.js token: ${{ secrets.RENOVATE_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 0f8800c..cc6ee65 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,7 +15,7 @@ jobs: issues: read steps: - - uses: actions/stale@v10 + - uses: actions/stale@v11 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-pr-message: "Marking this PR as stale due to inactivity; it will be closed in 7 days." diff --git a/.github/workflows/update-syntax-description.yml b/.github/workflows/update-syntax-description.yml index 806d536..a5ac3c7 100644 --- a/.github/workflows/update-syntax-description.yml +++ b/.github/workflows/update-syntax-description.yml @@ -24,6 +24,7 @@ jobs: uses: actions/checkout@v7 with: ref: "main" + persist-credentials: false # prevents duplicate Authorization header error with create-pull-request - name: Set up Python 3.12 uses: actions/setup-python@v7 with: diff --git a/src/cfengine_cli/cfengine_wrapper/arg_parse.py b/src/cfengine_cli/cfengine_wrapper/arg_parse.py index a7b6871..5292b29 100644 --- a/src/cfengine_cli/cfengine_wrapper/arg_parse.py +++ b/src/cfengine_cli/cfengine_wrapper/arg_parse.py @@ -35,10 +35,16 @@ def parse_wrapper_args(subp: argparse._SubParsersAction): default=None, ) - subp.add_parser("build", help="Build a policy set from a CFEngine Build project\n\ -A wrapper arount the cfbs `build`-function.") - sp = subp.add_parser("deploy", help="Deploy policy-set (masterfiles) to hub\n\ -A wrapper around the cf-remote `deploy`-function with some added niceties.") + subp.add_parser( + "build", + help="""Build a policy set from a CFEngine Build project. +A wrapper around the cfbs `build`-function.""", + ) + sp = subp.add_parser( + "deploy", + help="""Deploy policy-set (masterfiles) to hub. +A wrapper around the cf-remote `deploy`-function with some added niceties.""", + ) sp.add_argument("--hub", help="Hub(s) to deploy to", type=str) sp.add_argument( "masterfiles", diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py index ba4bba4..a688739 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py @@ -1,7 +1,7 @@ import os -import logging from cfbs.commands import build_command +from cf_remote import log from cf_remote.commands import deploy as deploy_command from cf_remote.commands import destroy as destroy_command from cf_remote.commands import save as save_command @@ -68,65 +68,67 @@ def _remote_home_dir(location: str) -> str: return "/root" if user == "root" else f"/home/{user}" -def _resolve_file_remote(location: str, command: str, file_arg: str) -> str: - local_exists = os.path.isfile(file_arg) - +def _remote_path_for(location: str, file_arg: str) -> str: local_home = os.path.expanduser("~") remote_home = _remote_home_dir(location) if file_arg.startswith(local_home + os.sep): - # e.g. "~/update.cf", shell expands to /home/{user}/update.cf rel = file_arg[len(local_home) + 1 :] - remote_path = f"{remote_home}/{rel}" - elif file_arg.startswith("/"): - remote_path = file_arg - else: - remote_path = f"{_DEFAULT_CFENGINE_INPUTS_DIR}/{file_arg}" + return f"{remote_home}/{rel}" + if file_arg.startswith("/"): + return file_arg + return f"{_DEFAULT_CFENGINE_INPUTS_DIR}/{file_arg}" - remote_exists = _remote_path_exists(location, remote_path) - if not local_exists and not remote_exists: - raise UserError( - f"Could not find '{file_arg}' locally or on {location} (checked {remote_path})." - ) +def _resolve_file_remote( + location: str, command: str, file_arg: str +) -> tuple[str, str | None]: - if local_exists and remote_exists: - choice = prompt_two_options( - f"'{file_arg}' exists both locally and on {location} (at {remote_path}).\nUse the:", - "local copy (uploads it, possibly overwriting the remote copy)", - f"copy already on {location}, unchanged", - ) - elif local_exists: - choice = "a" - else: - choice = "b" + remote_path = _remote_path_for(location, file_arg) + exists_in_inputs = _remote_path_exists(location, remote_path) - if choice == "b": - return _replace_file_token(command, file_arg, remote_path) + if os.path.isfile(file_arg): + remote_home = _remote_home_dir(location) + uploaded_path = f"{remote_home}/{os.path.basename(file_arg)}" + if exists_in_inputs: + log.warning( + f"File `{file_arg}` also exists in `{remote_path}`, consider renaming `{file_arg}` in the future." + ) + log.info(f"Uploading {file_arg} to {location}:{uploaded_path}") + transfer_file(location, file_arg) + return _replace_file_token(command, file_arg, uploaded_path), uploaded_path - uploaded_path = f"{remote_home}/{os.path.basename(file_arg)}" - logging.warning(f"Uploading {file_arg} to {location}:{uploaded_path}") - transfer_file(location, file_arg) - return _replace_file_token(command, file_arg, uploaded_path) + if not exists_in_inputs: + raise UserError( + f"Could not find '{file_arg}' locally or on {location} (checked {remote_path})." + ) + return _replace_file_token(command, file_arg, remote_path), None -def _resolve_command_for_agent(agent: Executable, command: str) -> str: +def _resolve_command_for_agent( + agent: Executable, command: str +) -> tuple[str, str | None]: if agent.name != "cf-agent": - return command + return command, None command = ensure_default_agent_flags(command) file_arg = extract_agent_file(command) if not file_arg: - return command + return command, None if agent.is_local: if "/" in file_arg: - return command # no ambiguity for a local target - return _resolve_bare_filename_local(command, file_arg) + return command, None # no ambiguity for a local target + return _resolve_bare_filename_local(command, file_arg), None return _resolve_file_remote(agent.location, command, file_arg) +def _remove_remote_file(location: str, path: str) -> None: + if run_command(location, f"rm -f {path}", sudo=False) is None: + log.warning(f"Failed to remove uploaded file '{path}' from {location}") + + # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- @@ -140,7 +142,7 @@ def _refresh_agent(agent: Executable) -> int: try: return agent.run("-KIf update.cf", "-KI") except (Exception, SystemExit) as e: - logging.error(f"Skipping {agent.label}: {e}") + log.warning(f"Skipping {agent.label}: {e}") return 1 @@ -155,7 +157,7 @@ def _query_hub_delta(hub: Executable, client_ips: list[str]) -> int: ] return hub.run(*queries) except (Exception, SystemExit) as e: - logging.error(f"Skipping hub {hub.label}: {e}") + log.warning(f"Skipping hub {hub.label}: {e}") return 1 @@ -172,28 +174,26 @@ def report( rc = _refresh_agent(hub.agent) hub_agent_failed[hub.location] = rc != 0 if rc != 0: - logging.error(f"Agent run failed on {hub.agent.label}") + log.error(f"Agent run failed on {hub.agent.label}") errors += 1 for agent in clients: rc = _refresh_agent(agent) if rc != 0: - logging.error(f"Refresh failed on {agent.label})") + log.error(f"Refresh failed on {agent.label})") errors += 1 for hub in hubs: if run_agent and hub_agent_failed[hub.location]: - logging.warning( - f"Agent run failed for {hub.location}, some data may be stale." - ) + log.warning(f"Agent run failed for {hub.location}, some data may be stale.") client_ips = [client.location.split("@", 1)[1] for client in clients] rc = _query_hub_delta(hub.hub, client_ips) if rc != 0: - logging.error(f"Hub refresh failed on {hub.label})") + log.error(f"Hub refresh failed on {hub.label})") errors += 1 if errors > 0: - logging.error(f"Encountered {errors}.") + log.error(f"Encountered {errors}.") return errors @@ -206,8 +206,20 @@ def run(*args, target: str | None = None) -> int: agent = require_executable("cf-agent", target) if not args: return agent.run("-KIf update.cf", "-KI") - resolved = [_resolve_command_for_agent(agent, command) for command in args] - return agent.run(*resolved) + + resolved = [] + cleanup_paths = [] + for command in args: + resolved_command, cleanup_path = _resolve_command_for_agent(agent, command) + resolved.append(resolved_command) + if cleanup_path: + cleanup_paths.append(cleanup_path) + + try: + return agent.run(*resolved) + finally: + for path in cleanup_paths: + _remove_remote_file(agent.location, path) def destroy(groupname, del_all=False) -> int: diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py b/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py index 5ac5b9e..0a34abc 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py @@ -1,7 +1,7 @@ from dataclasses import dataclass +from cf_remote import log from cf_remote.remote import run_command import subprocess -import logging import os @@ -73,7 +73,7 @@ def _run_one(self, command: str) -> int: return result.returncode full_command = f"{self.path} {command}" - logging.warning(f"Executing command {full_command} on {self.location}") + log.info(f"Executing command {full_command} on {self.location}") output = run_command(self.location, full_command, sudo=True) if ( output is None diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py index 510e6aa..4b16ee0 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py @@ -1,11 +1,11 @@ import os import shutil -import logging import random from collections.abc import Iterator from functools import lru_cache +from cf_remote import log from cf_remote.remote import get_info from cfengine_cli.paths import bin from cfengine_cli.utils import UserError @@ -92,7 +92,7 @@ def _host_info(host: str): try: return get_info(host) or None except (Exception, SystemExit) as e: - logging.warning(f"Skipping {host}: {e}") + log.warning(f"Skipping {host}: {e}") return None @@ -196,7 +196,7 @@ def _select(candidates, description, target: str | None = None): def require_executable(name: str, target: str | None = None) -> Executable: chosen = _select(_find_all(name), name, target) - logging.warning( + log.info( f"Using {'local' if chosen.is_local else 'remote'} installation of {name} ({chosen.label})" ) return chosen @@ -249,7 +249,7 @@ def select_report_targets( return installations, other_agents sampled_agents = random.sample(other_agents, budget) - logging.warning( + log.info( f"{len(other_agents)} additional host(s) found; refreshing a random " f"{budget} of them (plus {len(installations)} hub(s)) to keep this fast. " ) diff --git a/src/cfengine_cli/commands.py b/src/cfengine_cli/commands.py index e10e423..310184f 100644 --- a/src/cfengine_cli/commands.py +++ b/src/cfengine_cli/commands.py @@ -9,7 +9,9 @@ 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 cf_remote.paths import cf_remote_dir +from cfbs.commands import init_command from pydantic import ValidationError @@ -49,6 +51,24 @@ def dev(subcommand, args) -> int: return dispatch_dev_subcommand(subcommand, args) +def init(args): + if args.with_input and not args.policy_module: + raise UserError("--with-input can only be used together with --policy-module") + + if args.policy_module: + rc = init_policy_module( + name=None, + with_input=args.with_input, + non_interactive=args.non_interactive, + ) + return rc + if args.promise_type: + rc = init_promise_type(name=None) + return rc + + return init_command() # --policy-set, cfbs init, default + + def profile(args) -> int: data = None with open(args.profiling_input, "r") as f: diff --git a/src/cfengine_cli/initialize_project.py b/src/cfengine_cli/initialize_project.py new file mode 100644 index 0000000..ec331b8 --- /dev/null +++ b/src/cfengine_cli/initialize_project.py @@ -0,0 +1,432 @@ +import json +import logging +import os +from typing import Any + +from cfbs.validate import validate_config_raise_exceptions, validate_module_name_content +from cfbs.cfbs_config import CFBSConfig +from cfbs.utils import write_json, canonify +from cfengine_cli.utils import UserError +from cfbs.git import git_commit, git_init + +GITIGNORE = "out/\n*.tgz\n" + + +def init_promise_type(name=None, non_interactive=False): + _require_uninitialized() + + name = name or _prompt_for_name( + non_interactive, + validate=False, + hint="""Try to follow typical naming conventions e.g. +- promise-type-groups +- promise-type-docker-compose +""", + ) + module_name = name.lower() + canonified = canonify(module_name) + + files = { + os.path.join(module_name, "main.cf"): _main_cf_custom_promise_type( + name, canonified + ), + os.path.join(module_name, f"{canonified}.py"): _python_custom_promise_type( + canonified + ), + os.path.join(module_name, "enable.cf"): _enable_cf(name, canonified), + ".gitignore": GITIGNORE, + } + + module = _module_definition(module_name, canonified, False) + module["steps"] = [ + f"copy {canonified}.py modules/promises/{canonified}.py", + "append enable.cf services/init.cf", + ] + + config = _scaffold( + name=module_name, + description=f"Project for developing the '{name}' promise-type.", + project_type="policy-set", + provides={module_name: module}, + files=files, + ) + + _add_to_build(config, "masterfiles") + _add_to_build(config, "library-for-promise-types-in-python", level=logging.warning) + + _add_local_module( + module_name, + _module_definition( + module_name, + canonified, + False, + extra_steps=[ + "append enable.cf services/init.cf", + f"copy {canonified}.py modules/promises/{canonified}.py", + ], + ), + display_name=name, + ) + + config.save() + git_commit( + f"Initialized a new CFEngine Build project for promise type '{name}'", + scope=["cfbs.json"] + sorted(files), + ) + return 0 + + +def init_policy_module(name=None, with_input=False, non_interactive=False): + _require_uninitialized() + + name = name or _prompt_for_name( + non_interactive, + validate=False, + hint="""Try to follow existing naming conventions, for example: +- compliance-report-lynis: Adds the lynis compliance report +- delete-files: Deletes files specified by the user +- inventory-etc-hosts: Adds inventory information based on the /etc/hosts file +- library-sshd-config: A library for working with sshd config, intended to be used by other modules.""", + ) + module_name = name.lower() + validate_module_name_content(module_name) + canonified = canonify(module_name) + + files = { + os.path.join(module_name, "main.cf"): ( + _main_cf_with_input(canonified) if with_input else _main_cf(name) + ), + "README.md": _module_readme(name, canonified, with_input), + ".gitignore": GITIGNORE, + } + if with_input: + # Pre-fill so cfengine/cfbs build will work + files[os.path.join(module_name, "input.json")] = _example_input_json(canonified) + + module = _module_definition(module_name, canonified, with_input) + + config = _scaffold( + name=module_name, + description=f"Project for developing the '{name}' policy module.", + project_type="module", + provides={module_name: module}, + files=files, + ) + + _add_to_build(config, "masterfiles") + + config.save() + git_commit( + f"Initialized a new CFEngine Build project for module '{name}'", + scope=["cfbs.json"] + sorted(files), + ) + + _add_local_module(module_name, module, display_name=name) + + _print_next_steps(name, module_name, with_input) + return 0 + + +def _require_uninitialized(): + if os.path.exists("cfbs.json"): + raise UserError("Already initialized - look at 'cfbs.json'") + + +def _scaffold(name, description, project_type, provides, files): + """Write the project files and cfbs.json, then set up git. + + Returns the validated CFBSConfig instance for the new project. + """ + for path, content in files.items(): + _write_file(path, content) + + write_json( + "cfbs.json", + { + "name": name, + "description": description, + "type": project_type, + "git": True, + "provides": provides, + "build": [], + }, + ) + + git_init() + + config = CFBSConfig.get_instance() + validate_config_raise_exceptions(config, empty_build_list_ok=True) + return config + + +def _add_to_build(config, module_name, level=logging.error): + if config.add_command([module_name], "cfbs add", None, None).return_code != 0: + level( + f"Failed to add `{module_name}` to build. " + f"Can be added manually using `cfbs add {module_name}` at a later stage" + ) + + +def _module_definition(module_name, canonified, with_input, extra_steps=None): + target = f"services/cfbs/modules/{module_name}/main.cf" + steps = [ + f"copy main.cf {target}", + f"policy_files {target}", + f"bundles {canonified}:main" if with_input else "bundles main", + ] + steps.extend(extra_steps or []) + + module = { + "description": "An example policy module.", + "subdirectory": module_name, + "steps": steps, + } + if with_input: + module["steps"].append("input ./input.json def.json") + module["input"] = _input_spec(canonified) + return module + + +def _add_local_module(module_name, module, display_name=None): + + entry: dict[str, Any] = {"name": f"./{module_name}/"} + entry.update({k: v for k, v in module.items() if k != "subdirectory"}) + entry["description"] = ( + f"Local copy of '{display_name or module_name}', for building and testing." + ) + entry["tags"] = ["local"] + entry["added_by"] = "cfbs add" + + config = CFBSConfig.get_instance() + config["build"].append(entry) + config.save() + + git_commit(f"Added module './{module_name}/'", scope=["cfbs.json"]) + + +# --------------------------------------------------------------------------- +# Templates +# --------------------------------------------------------------------------- + + +def _enable_cf(name, canonified): + return f"""promise agent {canonified} +# @brief Define {name} promise type +{{ + path => "/var/cfengine/modules/promises/{canonified}.py"; + interpreter => "/usr/bin/python3"; +}} +""" + + +def _main_cf_custom_promise_type(name, canonified): + return f"""bundle agent main +{{ + {canonified}: + "promiser_name" wanted_attribute => "attribute_value"; + + reports: + "Hello from '{name}'"; +}} +""" + + +def _python_custom_promise_type(canonified): + return f"""import os +from cfengine_module_library import PromiseModule, ValidationError, Result + + +class {canonified}PromiseTypeModule(PromiseModule): + def __init__(self): + super().__init__("{canonified}_promise_module", "0.0.0") + + def validate_promise(self, promiser, attributes, metadata): + if not promiser == "promiser_name": + raise ValidationError(f"`{{promiser}}' does not match 'promiser_name'") + if "wanted_attribute" not in attributes: + raise ValidationError(f"Attribute 'wanted_attribute' is required") + + def evaluate_promise(self, promiser, attributes, metadata): + return Result.KEPT + + +if __name__ == "__main__": + {canonified}PromiseTypeModule().start() +""" + + +def _main_cf(name): + return f"""bundle agent main +{{ + vars: + "message" string => "Hello from the '{name}' module"; + + reports: + "$(message)"; +}} +""" + + +def _main_cf_with_input(namespace): + return f"""body file control +{{ + namespace => "{namespace}"; +}} + +bundle agent main +{{ + vars: + "keys" slist => getindices("list_variable_name"); + + reports: + "$(variable_name): $(list_variable_name[$(keys)])"; +}} + +body file control +{{ + namespace => "default"; +}} + +bundle agent __main__ +{{ + methods: + "{namespace}:main"; +}} +""" + + +def _input_spec(namespace): + return [ + { + "type": "string", + "variable": "variable_name", + "namespace": namespace, + "bundle": "main", + "label": "Variable name", + "question": "What variable should this module use in policy?", + }, + { + "type": "list", + "variable": "list_variable_name", + "namespace": namespace, + "bundle": "main", + "label": "Name of list-variable", + "subtype": [ + { + "key": "key1", + "type": "string", + "label": "Key1-label", + "question": "Short description", + "default": "default-value", + }, + { + "key": "key2", + "type": "string", + "label": "Key2-label", + "question": "Short description", + "default": "any", + }, + ], + "while": "Do you want to specify more inputs?", + }, + ] + + +def _example_input_json(namespace="example"): + spec = _input_spec(namespace) + spec[0]["response"] = "Example string" + spec[1]["response"] = [ + {"key1": "Value1", "key2": "Value2"}, + ] + return json.dumps(spec, indent=2) + "\n" + + +def _module_readme(name, module_name, with_input): + input_section = ( + f""" +## Module input + +This module accepts input, declared under `"input"` in `cfbs.json`. `input.json` +ships with example responses so `cfbs build` works out of the box; replace them: + + cfbs input {module_name} + +Responses are converted to augments and merged into `out/masterfiles/def.json` +by the `input ./input.json def.json` build step. Keep `namespace` and `bundle` +in the input spec matching the namespace and bundle in `main.cf`, or the +variables won't resolve. +""" + if with_input + else "" + ) + return f"""# {name} + +The module is named `{module_name}` in `cfbs.json` - module names must be +lowercase - while `{name}` is used where it is only read by humans. + +## Files + +- `main.cf` - the policy. This is where you do your work. +- `../cfbs.json` - `provides` defines the module for consumers + (`cfbs add `); `build` is a local policy set for testing it. + +## Try it + + cfbs build + sudo cfbs install # on a hub +{input_section} +## Before publishing + +1. Update the descriptions in `cfbs.json`. +2. Add `repo` and `by` URLs inside `provides`. +3. Test the consumer path from a scratch directory: + `cfbs init && cfbs add ` + +## Renaming + +Update the `provides` key, the local module's `name` and `steps` in `build`, +`subdirectory`, the directory itself, and the `{module_name}:main` references. +""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _prompt_for_name(non_interactive, validate=True, hint=""): + default = os.path.basename(os.getcwd()) + if non_interactive: + return default + if hint: + print(hint) + name = input(f"Name of module [{default}]: ").strip() + name = name if name else default + if validate: + validate_module_name_content(name) + return name + + +def _write_file(path, content): + if os.path.exists(path): + raise UserError(f"Refusing to overwrite existing file '{path}'") + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + with open(path, "w") as f: + f.write(content) + + +def _print_next_steps(name, module_name, with_input): + print("") + print(f"Initialized a project for developing the policy module '{name}'") + print("") + print("To build and test a policy set with your module:") + print(" `cfbs build` and `cf-remote deploy`") + print(" or `cfengine build`") + if with_input: + print("") + print("To change the module's input:") + print(f" cfbs input {module_name}") + print("") + print(f"See {module_name}/README.md for what to edit before publishing.") diff --git a/src/cfengine_cli/main.py b/src/cfengine_cli/main.py index 00823b9..4dc83d5 100644 --- a/src/cfengine_cli/main.py +++ b/src/cfengine_cli/main.py @@ -162,6 +162,36 @@ def _get_arg_parser(): metavar="GIT_ARG", help="Commit range [other optional args], e.g. 3.27.0..origin/3.27.x", ) + + inip = subp.add_parser( + "init", help="Initialize a [policy-set, policy-module, promise-type] example." + ) + init_type = inip.add_mutually_exclusive_group() + init_type.add_argument( + "--policy-set", + help="Initializes a Build project for working on your policy set on top of the default masterfiles", + action="store_true", + ) + init_type.add_argument( + "--promise-type", + help="Initializes a Build project for working on a new custom promise type in python", + action="store_true", + ) + init_type.add_argument( + "--policy-module", + help="Initializes a Build project for working on a module for build.cfengine.com (or internal use)", + action="store_true", + ) + inip.add_argument( + "--with-input", + help="Adds input data to the --policy-module project, for working on a module which takes input.", + action="store_true", + ) + inip.add_argument( + "--non-interactive", + help="Non-interactive mode (picks the default for all prompts)", + action="store_true", + ) return ap @@ -181,6 +211,8 @@ def run_command_with_args(args) -> int: # The real commands: if args.command == "save": return cfengine_commands.save(hosts=args.hosts, role=args.role, name=args.name) + if args.command == "init": + return commands.init(args) if args.command == "build": return cfengine_commands.build() if args.command == "deploy":