feat: add local script support to hooks #9

Merged
moritz merged 3 commits from eCommons/alakazam:local-scripts into main 2026-08-31 16:33:15 +00:00
4 changed files with 335 additions and 35 deletions
+21 -2
View File
@@ -250,14 +250,14 @@ For each app/recipe the following `<app_configurations>` 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
- **`readiness-hooks`**: Commands that decide whether an app is usable yet, repeated until one succeeds. See [Readiness Hooks](#readiness-hooks).
- **`dependency`**: Names the apps that have to be set up before this one. See [Dependencies](#dependencies).
- **`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`)
@@ -277,6 +277,7 @@ nextcloud:
```
Alakazam builds a directed graph from those entries and processes the apps in topological order. For the configuration, the secrets and the deployment alike. Apps unrelated by a dependency keep their configuration order. [`setup`](#setup-and-clean-deploy) additionally groups them into levels: everything without a dependency forms the first level, apps depending only on those the second, and so on.
### Readiness Hooks
An app can be deployed and healthy while still not being usable — authentik accepts connections long before it has applied its blueprints. A readiness hook is an `abra.sh` command of the recipe that answers that question and exits zero once the app is ready:
@@ -294,6 +295,24 @@ authentik:
Readiness hooks run as part of [`setup`](#setup-and-clean-deploy) only, right after the app they belong to is deployed and before anything that depends on it. `deploy` does not run them, so it keeps returning as soon as the deployment is through.
### \*-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.
+95 -31
View File
@@ -93,6 +93,7 @@ SECRET_VARIANTS = ("conf", "secret-hooks", "exchange", "generate")
GROUP_CONFIG_RE = re.compile(r'^alaka(-.*)?\.ya?ml$')
# instance configuration files, named '<domain>.yml' after the instance they configure
INSTANCE_CONFIG_RE = re.compile(r'^(?:[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,6}(?:\.yaml|\.yml)$')
ROOT_PATH = None # resolved root path from alakazam.yml
class MySafeConstructor(SafeConstructor):
@@ -859,6 +860,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.
@@ -881,8 +899,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
@@ -1154,13 +1171,50 @@ def insert_secrets_from_conf(domain: str, app_config: Dict[str, Any]) -> None:
insert_secret(domain, secret_name, secret)
def run_secret_hooks(domain: str, app_config: Dict[str, Any], strict: bool = False) -> None:
def run_local_script(tokens: List[str], app_domain: str, server: str, instance_domain: str, dry_run: bool = False, strict: bool = False) -> None:
"""
Run local abra.sh commands to generate secrets.
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.
strict (bool): Abort on a non-executable script or a non-zero exit code instead of only logging it.
Raises:
click.ClickException: If the script is not executable or exits non-zero and strict is set
"""
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):
message = f"Script is not executable (run: chmod +x {script_path})"
if strict:
raise click.ClickException(f"script '{cmd_display}' failed for {app_domain}: {message}")
logging.error(message)
return
if dry_run:
return
result = subprocess.run([str(script_path)] + args, env=env)
if result.returncode != 0:
message = f"Script '{cmd_display}' exited with code {result.returncode}"
if strict:
raise click.ClickException(f"script '{cmd_display}' failed for {app_domain}: {message}")
logging.warning(message)
def run_secret_hooks(domain: str, app_config: Dict[str, Any], instance_domain: str = "", strict: bool = False) -> None:
"""
Run local abra.sh commands or local scripts 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.
strict (bool): Abort on a failing hook instead of only logging it.
Raises:
@@ -1169,11 +1223,15 @@ def run_secret_hooks(domain: str, app_config: Dict[str, Any], strict: bool = Fal
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}", flush=True)
try:
abra("app", "cmd", "--local", domain, cmd, ignore_error=not strict, stream=True)
except RuntimeError as e:
raise click.ClickException(f"secret hook '{cmd}' failed for {domain}: {e}")
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, strict=strict)
else:
print(f"Run '{cmd}' in {domain}", flush=True)
try:
abra("app", "cmd", "--local", domain, cmd, ignore_error=not strict, stream=True)
except RuntimeError as e:
raise click.ClickException(f"secret hook '{cmd}' failed for {domain}: {e}")
def unquote_strings(s: str) -> str:
@@ -1389,7 +1447,7 @@ def run_readiness_hooks(domain: str, app_config: Dict[str, Any]) -> None:
run_readiness_hook(domain, hook)
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, strict: 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, strict: 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.
@@ -1401,8 +1459,9 @@ 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
strict (bool): abort on a failing command instead of only logging it
instance_domain (str): The instance domain, used to set ALAKAZAM_INSTANCE_DOMAIN for script hooks.
Returns:
None
@@ -1411,6 +1470,7 @@ def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), ini
click.ClickException: If a command fails and strict is set
"""
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
@@ -1424,7 +1484,12 @@ def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), ini
if chaos:
chaos_flag = "-C"
for cmd in all_cmds:
print(f"Run '{cmd}' in {domain}:{cmd.split()[0]}", flush=True)
tokens = cmd.split()
container = tokens[0]
if container == "script" and len(tokens) >= 2 and resolve_path(tokens[1]).exists():
run_local_script(tokens, domain, server, instance_domain, dry_run, strict)
continue
print(f"Run '{cmd}' in {domain}:{container}", flush=True)
if dry_run:
continue
try:
@@ -1449,6 +1514,7 @@ def cli(loglevel: str, group_path: str, exclude:Tuple[str]) -> None:
global SETTINGS_PATH
global GROUP_PATH
global ABRA_DIR
global ROOT_PATH
if loglevel:
numeric_level = getattr(logging, loglevel.upper(), None)
if not isinstance(numeric_level, int):
@@ -1463,21 +1529,19 @@ 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 = 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)
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))
preflight_configs(get_relevant_config_paths(_root_path, _group_path, exclude_paths))
all_group_configs = merge_all_group_configs(_root_path)
config_sets = read_config(str(_root_path / "config-sets.yml"))
preflight_configs(get_relevant_config_paths(ROOT_PATH, _group_path, exclude_paths))
all_group_configs = merge_all_group_configs(ROOT_PATH)
config_sets = read_config(str(ROOT_PATH / "config-sets.yml"))
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)
fetch_recipes(INSTANCE_CONFIGS)
@@ -1561,8 +1625,8 @@ def create_secrets(recipes: Tuple[str], syncvalues: bool = False, variants: Opti
"""
if variants is None:
variants = set(SECRET_VARIANTS)
for instance, instance_config in INSTANCE_CONFIGS.items():
if instances and instance not in instances:
for instance_domain, instance_config in INSTANCE_CONFIGS.items():
if instances and instance_domain not in instances:
continue
instance_apps = instance_config.keys()
if recipes:
@@ -1579,7 +1643,7 @@ def create_secrets(recipes: Tuple[str], syncvalues: bool = False, variants: Opti
if "conf" in variants:
insert_secrets_from_conf(domain, app_config)
if "secret-hooks" in variants:
run_secret_hooks(domain, app_config, strict=strict)
run_secret_hooks(domain, app_config, instance_domain, strict=strict)
# Pass 2: exchange secrets between apps, then generate any remaining missing secrets.
for app in selected_apps:
app_config = instance_config[app]
@@ -1673,7 +1737,7 @@ def deploy_apps(instance_apps: Dict[str, List[List]], execute_hooks: bool = Fals
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()
@@ -1727,7 +1791,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))
if version_type == 'version':
release_note_cmd = upgrade_cmd.copy()
release_note_cmd.insert(1, '-r')
@@ -1742,7 +1806,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)
@@ -1754,7 +1818,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()
@@ -1800,7 +1864,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:
@@ -1812,7 +1876,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=commands, initial=initial, deploy=deploy, upgrade=upgrade, dry_run=list_cmds, chaos=chaos, instance_domain=instance_domain)
@cli.command()
+184
View File
@@ -0,0 +1,184 @@
"""Tests for local script functionality in hooks."""
import logging
import os
import stat
import sys
import click
import pytest
from click.testing import CliRunner
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import alakazam
from alakazam import (create_secrets, deploy_apps, execute_cmds, get_abra_dir,
resolve_path, run_local_script, run_secret_hooks)
class FakeProcess:
def __init__(self, returncode):
self.returncode = returncode
def make_script(tmp_path, name="script.sh", executable=True):
"""Write a script file under tmp_path, executable by default."""
path = tmp_path / name
path.write_text("#!/bin/sh\nexit 0\n")
if executable:
path.chmod(path.stat().st_mode | stat.S_IXUSR)
return path
class TestResolvePath:
def test_an_absolute_path_passes_through_unchanged(self, tmp_path):
absolute = tmp_path / "script.sh"
assert resolve_path(str(absolute)) == absolute
def test_a_tilde_path_expands_against_home(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
assert resolve_path("~/script.sh") == tmp_path / "script.sh"
def test_a_relative_path_resolves_against_the_given_base(self, tmp_path):
base = tmp_path / "instance"
assert resolve_path("scripts/script.sh", base) == base / "scripts/script.sh"
def test_a_relative_path_resolves_against_root_path_by_default(self, monkeypatch, tmp_path):
monkeypatch.setattr(alakazam, "ROOT_PATH", tmp_path)
assert resolve_path("scripts/script.sh") == tmp_path / "scripts/script.sh"
class TestRunLocalScript:
def test_the_script_receives_its_env_vars_and_arguments(self, monkeypatch, tmp_path):
script = make_script(tmp_path)
calls = []
monkeypatch.setattr(alakazam.subprocess, "run",
lambda cmd, env: calls.append((cmd, env)) or FakeProcess(0))
run_local_script(["script", str(script), "arg1"], "login.a.org", "a.org", "a.org")
[(cmd, env)] = calls
assert cmd == [str(script), "arg1"]
assert env["ALAKAZAM_APP_DOMAIN"] == "login.a.org"
assert env["ALAKAZAM_APP_SERVER"] == "a.org"
assert env["ALAKAZAM_INSTANCE_DOMAIN"] == "a.org"
def test_a_non_executable_script_is_not_run(self, monkeypatch, tmp_path, caplog):
script = make_script(tmp_path, executable=False)
monkeypatch.setattr(alakazam.subprocess, "run",
lambda *a, **k: pytest.fail("a non-executable script must not run"))
with caplog.at_level(logging.ERROR):
run_local_script(["script", str(script)], "login.a.org", "a.org", "a.org")
assert "not executable" in caplog.text
def test_dry_run_does_not_execute_the_script(self, monkeypatch, tmp_path, capsys):
script = make_script(tmp_path)
monkeypatch.setattr(alakazam.subprocess, "run",
lambda *a, **k: pytest.fail("dry_run must not execute anything"))
run_local_script(["script", str(script)], "login.a.org", "a.org", "a.org", dry_run=True)
assert "Run local script" in capsys.readouterr().out
def test_a_non_zero_exit_only_warns_by_default(self, monkeypatch, tmp_path, caplog):
script = make_script(tmp_path)
monkeypatch.setattr(alakazam.subprocess, "run", lambda *a, **k: FakeProcess(1))
with caplog.at_level(logging.WARNING):
run_local_script(["script", str(script)], "login.a.org", "a.org", "a.org")
assert "exited with code 1" in caplog.text
def test_a_non_zero_exit_aborts_in_strict_mode(self, monkeypatch, tmp_path):
script = make_script(tmp_path)
monkeypatch.setattr(alakazam.subprocess, "run", lambda *a, **k: FakeProcess(1))
with pytest.raises(click.ClickException) as excinfo:
run_local_script(["script", str(script)], "login.a.org", "a.org", "a.org", strict=True)
assert "exited with code 1" in excinfo.value.message
def test_a_non_executable_script_aborts_in_strict_mode(self, monkeypatch, tmp_path):
script = make_script(tmp_path, executable=False)
monkeypatch.setattr(alakazam.subprocess, "run",
lambda *a, **k: pytest.fail("a non-executable script must not run"))
with pytest.raises(click.ClickException) as excinfo:
run_local_script(["script", str(script)], "login.a.org", "a.org", "a.org", strict=True)
assert "not executable" in excinfo.value.message
class TestScriptHookDispatch:
def test_a_script_secret_hook_dispatches_to_run_local_script(self, monkeypatch, tmp_path):
script = make_script(tmp_path)
calls = []
monkeypatch.setattr(alakazam, "run_local_script", lambda *a, **k: calls.append((a, k)))
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: pytest.fail("should not call abra"))
run_secret_hooks("login.a.org", {"secret_hooks": [f"script {script}"], "server": "a.org"}, "a.org")
[(args, kwargs)] = calls
assert args == (["script", str(script)], "login.a.org", "a.org", "a.org")
def test_a_normal_secret_hook_goes_through_abra(self, monkeypatch):
calls = []
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: calls.append((a, k)) or "")
run_secret_hooks("login.a.org", {"secret_hooks": ["insert_cert"], "server": "a.org"})
assert calls, "a non-script hook must still run through abra"
def test_a_script_command_dispatches_to_run_local_script(self, monkeypatch, tmp_path):
script = make_script(tmp_path)
calls = []
monkeypatch.setattr(alakazam, "run_local_script", lambda *a, **k: calls.append((a, k)))
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: pytest.fail("should not call abra"))
execute_cmds({"app_domain": "login.a.org", "server": "a.org",
"initial-hooks": [f"script {script} arg1"]}, initial=True)
[(args, kwargs)] = calls
assert args == (["script", str(script), "arg1"], "login.a.org", "a.org", "", False, False)
def test_a_normal_command_goes_through_abra(self, monkeypatch):
calls = []
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: calls.append((a, k)) or "")
execute_cmds({"app_domain": "login.a.org", "server": "a.org",
"initial-hooks": ["app set_default_quota"]}, initial=True)
assert calls, "a non-script command must still run through abra"
def test_a_script_token_with_a_missing_file_falls_back_to_abra(self, monkeypatch, tmp_path):
"""A 'script' hook whose path does not exist is not a script hook at all, just an abra
command whose container happens to be named 'script'."""
missing = tmp_path / "does-not-exist.sh"
calls = []
monkeypatch.setattr(alakazam, "run_local_script",
lambda *a, **k: pytest.fail("should not run a missing script"))
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: calls.append((a, k)) or "")
execute_cmds({"app_domain": "login.a.org", "server": "a.org",
"initial-hooks": [f"script {missing}"]}, initial=True)
assert calls, "a missing script path must fall back to a normal abra command"
class TestInstanceDomainThreading:
"""create_secrets(), deploy_apps() and the 'cmd' CLI command each thread instance_domain
into run_secret_hooks()/execute_cmds() for script hooks."""
@pytest.fixture
def app_config(self, monkeypatch):
"""A single 'authentik' app on instance 'a.org', shared by tests below."""
config = {"app_domain": "login.a.org", "server": "a.org"}
monkeypatch.setattr(alakazam, "INSTANCE_CONFIGS", {"a.org": {"authentik": config}})
return config
def test_create_secrets_threads_the_instance_domain(self, monkeypatch, app_config):
calls = []
monkeypatch.setattr(alakazam, "run_secret_hooks", lambda *a, **k: calls.append((a, k)))
create_secrets(recipes=(), variants={"secret-hooks"})
[(args, kwargs)] = calls
assert args == ("login.a.org", app_config, "a.org")
def test_deploy_apps_threads_the_instance_domain(self, monkeypatch, app_config):
app_config["version"] = "1.0.0"
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: "")
calls = []
monkeypatch.setattr(alakazam, "execute_cmds", lambda app_config, **k: calls.append(k))
deploy_apps({"a.org": [["authentik", "login.a.org"]]}, execute_hooks=True)
[kwargs] = calls
assert kwargs["deploy"] is True
assert kwargs["instance_domain"] == "a.org"
def test_cmd_threads_the_instance_domain(self, monkeypatch, app_config):
monkeypatch.setattr(alakazam, "get_deployed_apps", lambda apps: {"login.a.org": "1.0.0"})
calls = []
monkeypatch.setattr(alakazam, "execute_cmds", lambda app_config, **k: calls.append(k))
result = CliRunner().invoke(alakazam.cmd, ["-i"], standalone_mode=False)
assert result.exception is None
[kwargs] = calls
assert kwargs["initial"] is True
assert kwargs["instance_domain"] == "a.org"
+35 -2
View File
@@ -1,6 +1,7 @@
"""Tests for the strict hook mode that lets a scratch build fail on a broken hook."""
import os
import stat
import sys
import click
@@ -12,6 +13,14 @@ import alakazam
from alakazam import execute_cmds, run_secret_hooks
def failing_script(tmp_path):
"""A tiny local script that exits non-zero, for testing 'script' hook failure handling."""
script = tmp_path / "fail.sh"
script.write_text("#!/bin/sh\nexit 1\n")
script.chmod(script.stat().st_mode | stat.S_IXUSR)
return script
class TestStrictHooks:
def test_a_secret_hook_failure_is_tolerated_by_default(self, monkeypatch):
"""'secrets' has always continued past a failing hook, that must not change."""
@@ -30,12 +39,36 @@ class TestStrictHooks:
def test_a_command_failure_is_tolerated_by_default(self, monkeypatch):
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: "")
execute_cmds({"app_domain": "login.a.org", "initial-hooks": ["app init"]}, initial=True)
execute_cmds({"app_domain": "login.a.org", "server": "a.org", "initial-hooks": ["app init"]}, initial=True)
def test_a_command_failure_aborts_in_strict_mode(self, monkeypatch):
def failing(*args, ignore_error=False, **kwargs):
raise RuntimeError("FATA container not found")
monkeypatch.setattr(alakazam, "abra", failing)
with pytest.raises(click.ClickException) as excinfo:
execute_cmds({"app_domain": "login.a.org", "initial-hooks": ["app init"]}, initial=True, strict=True)
execute_cmds({"app_domain": "login.a.org", "server": "a.org", "initial-hooks": ["app init"]}, initial=True, strict=True)
assert "command 'app init' failed" in excinfo.value.message
def test_a_script_secret_hook_failure_is_tolerated_by_default(self, tmp_path):
"""A failing local script must be tolerated by default too, same as a failing abra.sh hook."""
script = failing_script(tmp_path)
run_secret_hooks("login.a.org", {"secret_hooks": [f"script {script}"], "server": "a.org"})
def test_a_script_secret_hook_failure_aborts_in_strict_mode(self, tmp_path):
script = failing_script(tmp_path)
with pytest.raises(click.ClickException) as excinfo:
run_secret_hooks("login.a.org", {"secret_hooks": [f"script {script}"], "server": "a.org"}, strict=True)
assert str(script) in excinfo.value.message
assert "failed" in excinfo.value.message
def test_a_script_command_failure_is_tolerated_by_default(self, tmp_path):
script = failing_script(tmp_path)
execute_cmds({"app_domain": "login.a.org", "server": "a.org", "initial-hooks": [f"script {script}"]}, initial=True)
def test_a_script_command_failure_aborts_in_strict_mode(self, tmp_path):
script = failing_script(tmp_path)
with pytest.raises(click.ClickException) as excinfo:
execute_cmds({"app_domain": "login.a.org", "server": "a.org", "initial-hooks": [f"script {script}"]},
initial=True, strict=True)
assert str(script) in excinfo.value.message
assert "failed" in excinfo.value.message