feat: add local script support to hooks #9
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user