From 18069e88ae39127c14822735d82768a3b75ad4db Mon Sep 17 00:00:00 2001 From: Danny Groenewegen Date: Mon, 1 Jun 2026 20:45:35 +0200 Subject: [PATCH] feat: add local script support to hooks Allow hooks to run local scripts in addition to abra.sh commands, for custom actions that aren't generic enough to be implemented as abra.sh commands. Scripts receive app, server, and instance domain as environment variables. --- README.md | 22 +++++++++- alakazam.py | 119 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 106 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9494293..27c76d4 100644 --- a/README.md +++ b/README.md @@ -154,12 +154,12 @@ For each app/recipe the following `` can be used: - it matches against parts of the line (i.E. `compose.smtp.yml`) - this is useful for env variables that are used multiple times like `COMPOSE_FILE` - **`env`**: Sets values for environment variables. -- **`*-hooks`**: Specifies `abra.sh` commands to run at specific stages. +- **`*-hooks`**: Specifies `abra.sh` commands or local scripts to run at specific stages. - **`initial-hooks`**: commands for initialisation - **`deploy-hooks`**: commands that should be run after each deployment - **`upgrade-hooks`**: commands that should be run after each upgrade - **`secrets`**: Inserts specific values (i.E. smtp passwords) into secrets; future updates will support encrypted file usage. -- **`secret-hooks`**: Run `abra.sh` commands locally for secrets that need to be generated. +- **`secret-hooks`**: Run `abra.sh` commands locally or local scripts for secrets that need to be generated. - **`subdomain`**: Specifies the subdomain scheme for individual recipes and apps. (not available in `combine.yml`/`alaconnect.yml`) - i.e. `cloud.example.com` for nextcloud - **`version`**: Controls the recipe version to deploy; if unspecified, the latest version is used. (not available in `combine.yml`/`alaconnect.yml`) @@ -169,6 +169,24 @@ The `combine.yml`/`alaconnect.yml` configuration additionally contains: - **`shared_secrets`**: Specifies secret sharing between apps. - `:` +#### \*-Hooks Command Formats + +**Abra command** — runs an abra.sh command inside a container: + +```yaml +initial-hooks: + - app set_default_quota +``` + +**Local script** — runs a script on the local machine (for custom actions that aren't generic enough to be implemented as abra.sh commands): + +```yaml +initial-hooks: + - script ./scripts/script.sh arg1 +``` + +Relative paths resolve from the `root` path. The script receives `ALAKAZAM_APP_DOMAIN`, `ALAKAZAM_APP_SERVER`, and `ALAKAZAM_INSTANCE_DOMAIN` as environment variables. + ### Configuration Structure Configuration can be simplified into a single `example.com.yml` or expanded into multiple layered `alaka.yml`/`alaka-*.yml` files for complex deployments. This allows for easy maintenance of multiple instances or groups. diff --git a/alakazam.py b/alakazam.py index 4b316b1..2243a4c 100755 --- a/alakazam.py +++ b/alakazam.py @@ -57,6 +57,7 @@ ALL_CONFIGS = {} SETTINGS = {} SETTINGS_PATH = "" # path to the alakazam settings file ABRA_DIR = None # path to the abra data directory +ROOT_PATH = None # resolved root path from alakazam.yml class MySafeConstructor(SafeConstructor): @@ -510,6 +511,23 @@ def generate_all_secrets(domain: str) -> None: print(f"\t {gen_sec['name']}: {gen_sec['value']}") +def resolve_path(path_str: str, base: Optional[Path] = None) -> Path: + """ + Resolve a path string to an absolute Path, expanding ~ and resolving relative paths against a base directory. + + Args: + path_str (str): The path string to resolve. May be absolute, relative, or start with ~. + base (Path): The base directory for resolving relative paths. Defaults to ROOT_PATH if not provided. + + Returns: + Path: The resolved absolute path. + """ + p = Path(path_str).expanduser() + if p.is_absolute(): + return p + return ((base or ROOT_PATH) / p).absolute() + + def get_abra_dir() -> Path: """ Resolve the abra directory path. @@ -532,8 +550,7 @@ def get_abra_dir() -> Path: if config_file.exists(): abra_config = read_config(str(config_file)) if abra_dir := abra_config.get("abraDir"): - p = Path(abra_dir) - return (current / p).resolve() if not p.is_absolute() else p + return resolve_path(abra_dir, current) if current == home: break current = current.parent @@ -798,26 +815,57 @@ def insert_secrets_from_conf(domain: str, app_config: Dict[str, Any]) -> None: for secret_name, secret in secrets.items(): try: secret_is_stored = stored_secrets[secret_name] - except keyerror: + except KeyError: logging.error(f"{domain} does not contain secret {secret_name}") continue if not secret_is_stored: insert_secret(domain, secret_name, secret) -def run_secret_hooks(domain: str, app_config: Dict[str, Any]) -> None: +def run_local_script(tokens: List[str], app_domain: str, server: str, instance_domain: str, dry_run: bool = False) -> None: + """ + Run a local script hook. Relative paths are resolved against ROOT_PATH for execution. Logs an error and returns early if the script is not executable. + + Args: + tokens (list): The hook entry split on whitespace, with tokens[0] confirmed to be 'script', tokens[1] the script path, and tokens[2:] positional arguments. + app_domain (str): The app domain, passed as ALAKAZAM_APP_DOMAIN to the script environment. + server (str): The server name, passed as ALAKAZAM_APP_SERVER to the script environment. + instance_domain (str): The instance domain, passed as ALAKAZAM_INSTANCE_DOMAIN to the script environment. + dry_run (bool): If True, prints the command but does not execute it. + """ + script_path = resolve_path(tokens[1]) + args = tokens[2:] + env = {**os.environ, "ALAKAZAM_APP_DOMAIN": app_domain, "ALAKAZAM_APP_SERVER": server, "ALAKAZAM_INSTANCE_DOMAIN": instance_domain} + cmd_display = " ".join(tokens[1:]) + print(f"Run local script '{cmd_display}' for {app_domain}") + if not os.access(script_path, os.X_OK): + logging.error(f"Script is not executable (run: chmod +x {script_path})") + return + if dry_run: + return + result = subprocess.run([str(script_path)] + args, env=env) + if result.returncode != 0: + logging.warning(f"Script '{cmd_display}' exited with code {result.returncode}") + + +def run_secret_hooks(domain: str, app_config: Dict[str, Any], instance_domain: str = "") -> None: """ Run local abra.sh commands to generate secrets. Args: domain (str): The app domain into which the secrets are to be inserted. app_config (dict): A dictionary containing the secrets hooks and their corresponding values to insert. + instance_domain (str): The instance domain, used to set ALAKAZAM_INSTANCE_DOMAIN for script hooks. """ logging.info(f"Run secret hooks for {domain}") if secret_hooks := app_config.get("secret_hooks"): for cmd in secret_hooks: - print(f"Run '{cmd}' in {domain}") - print(abra("app", "cmd", "--local", domain, cmd, ignore_error=True)) + tokens = cmd.split() + if tokens[0] == "script" and len(tokens) >= 2 and resolve_path(tokens[1]).exists(): + run_local_script(tokens, domain, app_config['server'], instance_domain) + else: + print(f"Run '{cmd}' in {domain}") + print(abra("app", "cmd", "--local", domain, cmd, ignore_error=True)) def unquote_strings(s: str) -> str: @@ -958,7 +1006,7 @@ def replace_domains(path: Path, old_domain: str, new_domain: str) -> None: file.write(content) -def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), initial: bool = False, deploy: bool = False, upgrade: bool = False, dry_run: bool = False, chaos: bool = False) -> None: +def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), initial: bool = False, deploy: bool = False, upgrade: bool = False, dry_run: bool = False, chaos: bool = False, instance_domain: str = "") -> None: """ Execute post-deployment commands for an application based on the provided configuration. This can include running scripts or commands inside the application's environment. @@ -970,12 +1018,14 @@ def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), ini initial (bool): execute initial-hooks deploy (bool): execute deploy-hooks upgrade (bool): execute upgrade-hooks - dry-run(bool): only show cmds, don't execute them + dry_run (bool): only show cmds, don't execute them + instance_domain (str): The instance domain, used to set ALAKAZAM_INSTANCE_DOMAIN for script hooks. Returns: None """ domain = app_config['app_domain'] + server = app_config['server'] all_cmds = [] if initial and (initial_hooks:= app_config.get('initial-hooks')): all_cmds = all_cmds + initial_hooks @@ -989,15 +1039,19 @@ def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), ini if chaos: chaos_flag = "-C" for cmd in all_cmds: - container = cmd.split()[0] - cmd = ['--'] + cmd.split()[1:] - print(f"Run '{cmd}' in {domain}:{container}") - if dry_run: - continue - if container == "local": - print(abra("app", "cmd", "--local", chaos_flag, domain, *cmd, ignore_error=True)) + tokens = cmd.split() + container = tokens[0] + rest = ['--'] + tokens[1:] + if container == "script" and len(tokens) >= 2 and resolve_path(tokens[1]).exists(): + run_local_script(tokens, domain, server, instance_domain, dry_run) else: - print(abra("app", "cmd", "-T", chaos_flag, domain, container, *cmd, ignore_error=True)) + print(f"Run '{rest}' in {domain}:{container}") + if dry_run: + continue + if container == "local": + print(abra("app", "cmd", "--local", chaos_flag, domain, *rest, ignore_error=True)) + else: + print(abra("app", "cmd", "-T", chaos_flag, domain, container, *rest, ignore_error=True)) @click.group(context_settings={"help_option_names": ['-h', '--help']}) @@ -1015,6 +1069,7 @@ def cli(loglevel: str, group_path: str, exclude:Tuple[str]) -> None: global SETTINGS global SETTINGS_PATH global ABRA_DIR + global ROOT_PATH SETTINGS_PATH = get_settings_path() SETTINGS = read_config(SETTINGS_PATH) ABRA_DIR = get_abra_dir() @@ -1023,20 +1078,18 @@ def cli(loglevel: str, group_path: str, exclude:Tuple[str]) -> None: root_path = os.getcwd() logging.warning(f"There is no 'root' path defined in '{SETTINGS_PATH}', use current path '{root_path}'instead") _group_path = Path(group_path).expanduser().absolute() - _root_path = Path(root_path).expanduser() - if not _root_path.is_absolute(): - _root_path = (settings_dir / _root_path).absolute() - if not str(_group_path).startswith(str(_root_path)): - logging.error(f"{_root_path} does not contain {_group_path}?") + ROOT_PATH = resolve_path(root_path, settings_dir) + if not str(_group_path).startswith(str(ROOT_PATH)): + logging.error(f"{ROOT_PATH} does not contain {_group_path}?") exit(1) - all_group_configs = merge_all_group_configs(_root_path) - config_sets = read_config(str(_root_path / "config-sets.yml")) + all_group_configs = merge_all_group_configs(ROOT_PATH) + config_sets = read_config(str(ROOT_PATH / "config-sets.yml")) exclude_paths = list(map(lambda p: str(Path(p).absolute()), exclude)) - if ABRA_DIR.is_relative_to(_root_path) and str(ABRA_DIR) not in exclude_paths: + if ABRA_DIR.is_relative_to(ROOT_PATH) and str(ABRA_DIR) not in exclude_paths: exclude_paths.append(str(ABRA_DIR)) instance_configs = get_merged_instance_configs(_group_path, all_group_configs, exclude_paths, config_sets) INSTANCE_CONFIGS = merge_connection_configs(instance_configs) - all_configs = get_merged_instance_configs(_root_path, all_group_configs, exclude_paths, config_sets) + all_configs = get_merged_instance_configs(ROOT_PATH, all_group_configs, exclude_paths, config_sets) ALL_CONFIGS = merge_connection_configs(all_configs) if loglevel: numeric_level = getattr(logging, loglevel.upper(), None) @@ -1097,7 +1150,7 @@ def secrets(recipes: Tuple[str], syncvalues: bool) -> None: Generates and inserts secrets for specified apps. This function handles the generation of new secrets, the syncronisation of shared secrets and the insertion of existing secrets from the configuration into the appropriate locations for each application. """ - for _, instance_config in INSTANCE_CONFIGS.items(): + for instance_domain, instance_config in INSTANCE_CONFIGS.items(): instance_apps = instance_config.keys() if recipes: selected_apps = [app for app in recipes if app in instance_config.keys()] @@ -1110,7 +1163,7 @@ def secrets(recipes: Tuple[str], syncvalues: bool) -> None: domain = app_config['app_domain'] print(f"Create secrets for {domain}") insert_secrets_from_conf(domain, app_config) - run_secret_hooks(domain, app_config) + run_secret_hooks(domain, app_config, instance_domain) # Pass 2: exchange secrets between apps, then generate any remaining missing secrets. for app in selected_apps: app_config = instance_config[app] @@ -1185,7 +1238,7 @@ def deploy(recipes: Tuple[str], execute_hooks: bool, force: bool, converge_check print(abra("app", *cmd)) if execute_hooks: logging.info(f'execute commands for {domain}') - execute_cmds(app_config, deploy=True) + execute_cmds(app_config, deploy=True, instance_domain=instance) @cli.command() @@ -1230,7 +1283,7 @@ def upgrade(recipes: Tuple[str], execute_hooks: bool, dry_run: bool, redeploy: b app_details.append(upgrade_version) upgrade_apps.append(app_details) logging.info(f'upgrade {app}: {domain} from version {deployed_version} to version "{upgrade_version}"') - upgrade_cmds.append((app_config, upgrade_cmd)) + upgrade_cmds.append((app_config, upgrade_cmd, instance)) release_note_cmd = upgrade_cmd.copy() release_note_cmd.insert(1, '-r') release_note = abra("app", *release_note_cmd, ignore_error=True) @@ -1244,7 +1297,7 @@ def upgrade(recipes: Tuple[str], execute_hooks: bool, dry_run: bool, redeploy: b print(app) print(note) if not dry_run and noninteractive or input(f"Do you really want to upgrade these apps? Type YES: ") == "YES": - for app_config, upgrade_cmd in upgrade_cmds: + for app_config, upgrade_cmd, instance_domain in upgrade_cmds: app_domain = app_config.get('app_domain') if redeploy: upgrade_cmd.pop(0) @@ -1256,7 +1309,7 @@ def upgrade(recipes: Tuple[str], execute_hooks: bool, dry_run: bool, redeploy: b print(abra("app", *upgrade_cmd)) if execute_hooks: logging.info(f'execute commands for {app_domain}') - execute_cmds(app_config, upgrade=True) + execute_cmds(app_config, upgrade=True, instance_domain=instance_domain) @cli.command() @@ -1299,7 +1352,7 @@ def cmd(recipes: Tuple[str], commands: Tuple[str], initial: bool, deploy: bool, Execute commands for all specified applications based on the provided configuration. """ deployed_domains = get_deployed_apps(recipes) - for _, instance_config in INSTANCE_CONFIGS.items(): + for instance_domain, instance_config in INSTANCE_CONFIGS.items(): if recipes: selected_apps = [app for app in recipes if app in instance_config.keys()] else: @@ -1311,7 +1364,7 @@ def cmd(recipes: Tuple[str], commands: Tuple[str], initial: bool, deploy: bool, print(f"{domain} is not deployed") continue logging.info(f'execute commands for {domain}') - execute_cmds(app_config, commands, initial, deploy, upgrade, list_cmds, chaos) + execute_cmds(app_config, commands, initial, deploy, upgrade, list_cmds, chaos, instance_domain) @cli.command() -- 2.49.0