10 Commits
Author SHA1 Message Date
moritz b7ab146acc feat(combine): wire monitoring-ng's grafana stack to authentik
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2026-09-08 14:29:11 +02:00
moritz e856bafa1e feat(version): deploy the commit behind an '@' in a version
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-09-07 18:54:25 +02:00
moritz ecad7dd971 fix(secrets): withhold the output of local hooks when hiding secrets
continuous-integration/drone/push Build is passing
2026-09-07 18:40:37 +02:00
moritz 39fe0f3e52 build: add argon2 for recipes that hash secrets locally
continuous-integration/drone/tag Build is passing
2026-09-07 18:11:14 +02:00
moritz 2f75306140 feat(config): add --exclude-recipe to leave recipes out of a run
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-09-07 17:53:33 +02:00
moritz dfb15ef443 feat(secrets): add --hide-secrets to keep values out of the output
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-09-07 17:11:46 +02:00
moritz 034567fce2 chore: update abra
continuous-integration/drone/push Build is passing
2026-09-07 16:36:36 +02:00
moritz 19fa4e0ffe chore: update and patch abra
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-08-31 21:39:09 +02:00
moritz 6803b2f317 fix(secrets): tolerate a purge that had no secrets to remove
continuous-integration/drone/push Build is passing
2026-08-31 20:45:44 +02:00
moritz b77df7c5a6 Merge pull request 'adds kimai grouu as env to combine yml' (#13) from kimai_group into main
continuous-integration/drone/push Build is passing
Reviewed-on: #13
2026-08-31 16:36:22 +00:00
10 changed files with 562 additions and 16 deletions
+3 -1
View File
@@ -26,8 +26,10 @@ RUN python -m venv /opt/alakazam \
&& /opt/alakazam/bin/pip install --no-cache-dir -r /tmp/requirements.txt
FROM python:3.11-slim
# git: alakazam syncs the recipe repos itself. openssh-client: abra reaches the servers over
# ssh. argon2: recipes hash secrets locally in their abra.sh, vaultwarden's admin token does
RUN apt-get update \
&& apt-get install -y --no-install-recommends git openssh-client make \
&& apt-get install -y --no-install-recommends git openssh-client make argon2 \
&& rm -rf /var/lib/apt/lists/* \
&& git config --global --add safe.directory '*'
+18
View File
@@ -185,6 +185,22 @@ Without `-s` all secrets of the recipes selected by `-r` are purged.
alakazam example.com.yml ps --wait --timeout 300
```
### Excluding Recipes
`-er`/`--exclude-recipe` leaves single recipes out of a run entirely:
```
alakazam -er traefik example.com.yml clean-deploy -n
```
### Hiding Secrets
`secrets` and `setup` print the values they generate, which is right at a terminal and wrong in a pipeline whose logs are kept. `--hide-secrets` replaces them with `[hidden]`:
```
alakazam --hide-secrets example.com.yml clean-deploy -n
```
### Run CMDs
Escaping can be akward:
@@ -261,6 +277,8 @@ For each app/recipe the following `<app_configurations>` can be used:
- **`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`)
- a commit can be pinned with `<release>@<commit>`, for example `12.0.2+2026.5.2@be9ebb3`
- only the commit is deployed; the release in front of the `@` is there so that a dependency bot can follow the recipe's tags and offer patch updates
The `combine.yml`/`alaconnect.yml` configuration additionally contains:
+1 -1
Submodule abra updated: e1b10f6020...0531f30590
+120 -12
View File
@@ -57,6 +57,7 @@ ALL_CONFIGS = {}
SETTINGS = {}
SETTINGS_PATH = "" # path to the alakazam settings file
GROUP_PATH = None # path this run was invoked with, either an instance file or a group directory
HIDE_SECRETS = False # keep secret values out of the output, for runs whose logs are archived
ABRA_DIR = None # path to the abra data directory
FETCH_MAX_AGE = 600 # seconds; recipe repos are refetched at most once per hour
ABRA_RETRIES = 6 # number of attempts per abra command that failed to reach the server
@@ -75,8 +76,11 @@ CONNECTION_ERRORS = (
)
# marker in the abra output that indicates a secret which is not stored on the server
MISSING_SECRET_ERROR = "doesn't exist on server"
# marker in the abra output for a generate run that had nothing left to do, which abra exits 1 on
# abra subcommands that take a secret as an argument or return one
SECRET_COMMANDS = ("insert", "generate")
# markers in the abra output for a secret run that had nothing left to do, which abra exits 1 on
NO_SECRETS_GENERATED = "no secrets generated"
NO_SECRETS_TO_REMOVE = "no secrets to remove"
# container statuses that have not settled yet, matched against the abra output
PENDING_STATUS_RE = re.compile(r".*(starting|unknown|unhealthy).*")
# marker in a service name for a container that runs once and exits instead of becoming healthy
@@ -489,9 +493,41 @@ def merge_instance_configs(group_config: Dict[str, Any], instance_domain: str, i
if not merged_config[app].get('server'):
merged_config[app]['server'] = server
substitute_jinja_variable(merged_config, global_vars)
# after the substitution, so that a templated version is resolved too
for app, app_config in merged_config.items():
if app_config.get('version'):
app_config['version'] = resolve_version(app_config['version'], app)
return merged_config
def resolve_version(version: Any, app: str) -> Any:
"""
Reduces a configured version to the part that is deployed.
A version may name a commit behind an '@', as in '12.0.2+2026.5.2@be9ebb3'. What stands before
it is the release the commit is based on: it carries no meaning for the deployment and exists
so that a dependency bot can follow the recipe's tags and offer patch updates. Only the commit
is deployed. Everything without an '@' is passed through untouched.
Args:
version: The configured version, any type the configuration may hold
app (str): The app the version belongs to, for the error message
Returns:
The commit behind the '@', or the version unchanged
Raises:
click.ClickException: If the '@' is there but no commit follows it
"""
if not isinstance(version, str) or "@" not in version:
return version
base, _, commit = version.rpartition("@")
if not commit:
raise click.ClickException(f"version '{version}' of {app} ends in '@' without a commit")
logging.debug(f"{app}: deploying commit {commit}, based on {base}")
return commit
def map_subdomain(recipe: str, instance_domain: str, app_config: Dict[str, Any]) -> str:
"""
Maps a subdomain for an app based on the recipe, instance domain, and specific app configuration.
@@ -547,6 +583,32 @@ def get_merged_instance_configs(config_path: Path, group_configs: Dict[str, Any]
return instances
def exclude_from_configs(configs: Dict[str, Dict[str, Any]], recipes: Tuple[str]) -> Dict[str, Dict[str, Any]]:
"""
Removes the given recipes from every instance configuration.
An excluded recipe is not merely left undeployed, it is invisible for the whole run: it is
neither listed, nor configured, nor purged, nor waited for. That is what keeps a rebuild from
touching an app whose state has to survive it, such as the certificates of a reverse proxy.
Args:
configs (dict): Instances as keys and their app configurations as values
recipes (tuple): Recipe names to leave out, all recipes are kept if empty
Returns:
dict: The configurations without those recipes
"""
if not recipes:
return configs
for recipe in recipes:
if not any(recipe in apps for apps in configs.values()):
logging.warning(f"'{recipe}' is excluded but not configured for this path")
return {
instance: {name: config for name, config in apps.items() if name not in recipes}
for instance, apps in configs.items()
}
def merge_connection_configs(configs: Dict[str, Any]) -> Dict[str, Any]:
"""
Merge connection configurations from the 'combine.yml' to extend instance configurations with inter-app secrets and settings.
@@ -583,6 +645,19 @@ def extend_shared_secrets(connection_config: Dict[str, Any]) -> None:
target_conf['shared_secrets'] = {source_app: shared_secrets}
def is_secret_command(args: Tuple[str, ...]) -> bool:
"""
Checks whether an abra command carries a secret value in its arguments or in its output.
Args:
args (tuple): The arguments passed to abra()
Returns:
bool: True for the commands that insert or generate a secret
"""
return args[:2] == ("app", "secret") and len(args) > 2 and args[2] in SECRET_COMMANDS
def is_connection_error(output: str) -> bool:
"""
Checks whether the output of an abra command indicates that the server could not be reached.
@@ -619,7 +694,7 @@ def run_streamed(command: List[str]) -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(command, process.returncode, b"".join(lines), b"")
def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, stream: bool = False) -> Union[str,Dict]:
def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, stream: bool = False, secret: bool = False) -> Union[str,Dict]:
"""
Execute the 'abra' command with the specified arguments. This function acts as a wrapper around the 'abra' CLI tool. It allows for capturing the output and optionally returning it as machine-readable JSON.
@@ -628,6 +703,7 @@ def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, s
machine_output (bool): If True, expects the output in JSON format and parses it before returning.
ignore_error (bool): If True, suppresses the raising of errors on non-zero return codes, otherwise an exception is raised.
stream (bool): If True, echoes the output while the command runs instead of only returning it afterwards. Cannot be combined with machine_output, which needs clean JSON on stdout.
secret (bool): If True, the output of this command may carry secret values, which HIDE_SECRETS then keeps out of the stream, the log and the error message.
Returns:
str or dict: Returns the output from the 'abra' command. If machine_output is True, returns a dictionary, otherwise returns raw output as a string.
@@ -642,7 +718,14 @@ def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, s
command = [arg for arg in command if arg]
if machine_output:
command.append("-m")
logging.debug(f"run command: {' '.join(command)}")
# the command line of 'app secret insert' ends in the secret itself, and the output of
# 'app secret generate' contains the generated values
quiet = HIDE_SECRETS and (secret or is_secret_command(args))
if quiet:
# echoing it live would put the very values that are being hidden into the output
stream = False
if not quiet:
logging.debug(f"run command: {' '.join(command)}")
for attempt in range(1, ABRA_RETRIES + 1):
process = run_streamed(command) if stream else subprocess.run(command, capture_output=True)
if not process.returncode or attempt == ABRA_RETRIES:
@@ -654,12 +737,15 @@ def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, s
delay = ABRA_RETRY_DELAY * 2 ** (attempt - 1)
logging.warning(f"attempt {attempt}/{ABRA_RETRIES} of '{' '.join(command)}' failed to reach the server, retry in {delay}s")
sleep(delay)
if process.stderr and ignore_error:
if process.stderr and ignore_error and not quiet:
logging.warning(process.stderr.decode())
if process.stdout and not stream:
if process.stdout and not stream and not quiet:
logging.debug(process.stdout.decode())
if process.returncode and not ignore_error:
#breakpoint()
if quiet:
raise RuntimeError(
f'{" ".join(args[:3])} failed, its output is withheld by --hide-secrets')
raise RuntimeError(
f'{" ".join(command)} \n STDOUT: \n {process.stdout.decode()} \n STDERR: {process.stderr.decode()}')
if machine_output:
@@ -857,7 +943,8 @@ def generate_all_secrets(domain: str) -> None:
return
print(f"secrets for {domain} generated")
for gen_sec in generated_secrets:
print(f"\t {gen_sec['name']}: {gen_sec['value']}")
value = "[hidden]" if HIDE_SECRETS else gen_sec['value']
print(f"\t {gen_sec['name']}: {value}")
def resolve_path(path_str: str, base: Optional[Path] = None) -> Path:
@@ -1199,7 +1286,8 @@ def run_local_script(tokens: List[str], app_domain: str, server: str, instance_d
return
if dry_run:
return
result = subprocess.run([str(script_path)] + args, env=env)
# the script may print a secret it just created, so its output is captured and dropped
result = subprocess.run([str(script_path)] + args, env=env, capture_output=HIDE_SECRETS)
if result.returncode != 0:
message = f"Script '{cmd_display}' exited with code {result.returncode}"
if strict:
@@ -1211,6 +1299,9 @@ def run_secret_hooks(domain: str, app_config: Dict[str, Any], instance_domain: s
"""
Run local abra.sh commands or local scripts to generate secrets.
A secret hook may print the value it just created, which some recipes do on purpose because
only a hash of it is stored. Under HIDE_SECRETS that output is withheld, failures included.
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.
@@ -1229,7 +1320,7 @@ def run_secret_hooks(domain: str, app_config: Dict[str, Any], instance_domain: s
else:
print(f"Run '{cmd}' in {domain}", flush=True)
try:
abra("app", "cmd", "--local", domain, cmd, ignore_error=not strict, stream=True)
abra("app", "cmd", "--local", domain, cmd, ignore_error=not strict, stream=True, secret=True)
except RuntimeError as e:
raise click.ClickException(f"secret hook '{cmd}' failed for {domain}: {e}")
@@ -1266,7 +1357,10 @@ def insert_secret(domain: str, secret_name: str, secret: str) -> None:
"""
# Fix extra quotes around secrets
secret = unquote_strings(secret)
logging.debug(f"Insert secret {secret_name}: {secret} into {domain}")
if HIDE_SECRETS:
logging.debug(f"Insert secret {secret_name} into {domain}")
else:
logging.debug(f"Insert secret {secret_name}: {secret} into {domain}")
abra("app", "secret", "insert", domain, secret_name, "v1", secret)
@@ -1501,8 +1595,10 @@ def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), ini
@click.group(context_settings={"help_option_names": ['-h', '--help']})
@click.option('-l', '--log', 'loglevel', help='Desired logging level ("debug", "info", "warning", "error", "critical")')
@click.option('-e', '--exclude', help='Path to a directory that contains a group of instance configurations to be excluded.', multiple=True, type=click.Path(exists=True))
@click.option('exclude_recipes', '-er', '--exclude-recipe', multiple=True, metavar='<RecipeName>', help='Leave these recipes untouched, this option can be specified multiple times.')
@click.option('hide_secrets', '--hide-secrets', is_flag=True, help='Keep secret values out of the output, for runs whose logs are kept.')
@click.argument('group_path', type=click.Path(exists=True))
def cli(loglevel: str, group_path: str, exclude:Tuple[str]) -> None:
def cli(loglevel: str, group_path: str, exclude: Tuple[str], exclude_recipes: Tuple[str], hide_secrets: bool) -> None:
"""
Alakazam is a meta-configuration app-connector and an abra wrapper, designed as a proof-of-concept to simplify the management of environment configuration files across multiple instances.
@@ -1513,8 +1609,10 @@ def cli(loglevel: str, group_path: str, exclude:Tuple[str]) -> None:
global SETTINGS
global SETTINGS_PATH
global GROUP_PATH
global HIDE_SECRETS
global ABRA_DIR
global ROOT_PATH
HIDE_SECRETS = hide_secrets
if loglevel:
numeric_level = getattr(logging, loglevel.upper(), None)
if not isinstance(numeric_level, int):
@@ -1541,6 +1639,10 @@ def cli(loglevel: str, group_path: str, exclude:Tuple[str]) -> None:
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)
# dropping the recipes here rather than at every filter is what makes the exclusion complete:
# get_apps(), create_secrets(), configure_apps() and the rest all read INSTANCE_CONFIGS.
# ALL_CONFIGS stays whole, it is the cross-instance view that backup looks up its bot in
INSTANCE_CONFIGS = exclude_from_configs(INSTANCE_CONFIGS, exclude_recipes)
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)
@@ -2196,7 +2298,7 @@ def parse_secret_variants(only: Tuple[str]) -> Set[str]:
def purge_app_secrets(recipe_secrets: Dict[str, List[str]]) -> None:
"""
Removes the given secrets from every app of the given recipes.
A secret that is not stored on the server is skipped, so an aborted run can simply be repeated.
A secret that is not stored on the server is skipped, and so is an app that has none at all, so an aborted run can simply be repeated.
All other errors are not suppressed: a secret that could not be removed would silently keep its old value on a later recreation.
Args:
@@ -2207,7 +2309,13 @@ def purge_app_secrets(recipe_secrets: Dict[str, List[str]]) -> None:
for _, domain in app_domains:
logging.info(f'purge secrets of {domain}')
if not secret_names:
abra("app", "secret", "rm", "-a", domain)
try:
abra("app", "secret", "rm", "-a", domain)
except RuntimeError as e:
if NO_SECRETS_TO_REMOVE not in str(e):
raise
print(f"{domain} has no secrets on its server, skip")
continue
print(f"Secrets for {domain} purged")
continue
for secret_name in secret_names:
+24
View File
@@ -62,6 +62,8 @@ authentik:
- SECRET_MONITORING_ID_VERSION
- SECRET_MONITORING_SECRET_VERSION
- monitoring.svg
secrets:
monitoring_id: monitoring
outline:
uncomment:
- compose.outline.yml
@@ -300,3 +302,25 @@ mila:
- SECRET_OIDC_CLIENT_SECRET_VERSION
shared_secrets:
mila_secret: oidc_client_secret
monitoring-ng:
authentik:
env:
GF_SERVER_ROOT_URL: https://monitoring-ng.example.com
OIDC_CLIENT_ID: monitoring
OIDC_AUTH_URL: https://authentik.example.com/application/o/authorize/
OIDC_API_URL: https://authentik.example.com/application/o/userinfo/
OIDC_TOKEN_URL: https://authentik.example.com/application/o/token/
uncomment:
- compose.prometheus.yml
- PROMETHEUS_RETENTION_TIME
- compose.loki.yml
- LOKI_RETENTION_PERIOD
- LOKI_STORAGE_FILESYSTEM
- compose.grafana.yml
- SECRET_GF_ADMINPASSWD_VERSION
- compose.grafana-oidc.yml
- OIDC_ENABLED
- SECRET_GF_OIDC_SECRET_VERSION
shared_secrets:
monitoring_secret: gf_oidc_secret
dependency: [authentik]
+89
View File
@@ -0,0 +1,89 @@
"""Tests for leaving single recipes out of a run."""
import logging
import os
import sys
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 exclude_from_configs
CONFIGS = {
"a.org": {"authentik": {"app_domain": "login.a.org"}, "traefik": {"app_domain": "a.org"}},
"b.org": {"traefik": {"app_domain": "b.org"}, "nextcloud": {"app_domain": "cloud.b.org"}},
}
class TestExcludeFromConfigs:
def test_nothing_excluded_returns_the_configs_unchanged(self):
assert exclude_from_configs(CONFIGS, ()) is CONFIGS
def test_the_recipe_is_gone_from_every_instance(self):
result = exclude_from_configs(CONFIGS, ("traefik",))
assert sorted(result["a.org"]) == ["authentik"]
assert sorted(result["b.org"]) == ["nextcloud"]
def test_several_recipes(self):
result = exclude_from_configs(CONFIGS, ("traefik", "nextcloud"))
assert result["b.org"] == {}
def test_the_original_is_not_modified(self):
exclude_from_configs(CONFIGS, ("traefik",))
assert "traefik" in CONFIGS["a.org"]
def test_an_unknown_recipe_is_reported(self, caplog):
"""A typo would otherwise exclude nothing and look like it worked."""
with caplog.at_level(logging.WARNING):
exclude_from_configs(CONFIGS, ("treafik",))
assert "'treafik' is excluded but not configured" in caplog.text
def test_a_known_recipe_is_not_reported(self, caplog):
with caplog.at_level(logging.WARNING):
exclude_from_configs(CONFIGS, ("traefik",))
assert caplog.text == ""
class TestExcludeReachesTheCommands:
"""One filter in cli() has to cover every consumer of INSTANCE_CONFIGS."""
@pytest.fixture
def env(self, tmp_path, monkeypatch):
root = tmp_path / "root"
(root / "group").mkdir(parents=True)
(root / "alaka.yml").write_text(
"authentik:\n version: 1.0.0\ntraefik:\n version: 2.0.0\n")
(root / "group" / "example.com.yml").write_text("authentik:\ntraefik:\n")
(root / "alakazam.yml").write_text(f"root: {root}\n")
monkeypatch.setattr(alakazam, "get_settings_path", lambda: str(root / "alakazam.yml"))
monkeypatch.setattr(alakazam, "get_abra_dir", lambda: root / ".abra")
monkeypatch.setattr(alakazam, "fetch_recipes", lambda *a, **k: None)
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: "")
return root
def invoke(self, root, args):
return CliRunner().invoke(alakazam.cli, args + [str(root / "group"), "ls"])
def test_without_the_flag_both_recipes_are_listed(self, env):
result = self.invoke(env, [])
assert result.exit_code == 0, result.output
assert "authentik" in result.output
assert "traefik" in result.output
def test_the_excluded_recipe_disappears(self, env):
result = self.invoke(env, ["-er", "traefik"])
assert result.exit_code == 0, result.output
assert "authentik" in result.output
assert "traefik" not in result.output
def test_the_long_option_works_too(self, env):
result = self.invoke(env, ["--exclude-recipe", "traefik"])
assert "traefik" not in result.output
def test_it_can_be_given_more_than_once(self, env):
result = self.invoke(env, ["-er", "traefik", "-er", "authentik"])
assert "traefik" not in result.output
assert "authentik" not in result.output
+186
View File
@@ -0,0 +1,186 @@
"""Tests for keeping secret values out of the output of a run whose logs are kept."""
import logging
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import alakazam
from alakazam import generate_all_secrets, insert_secret, is_secret_command
VALUE = "hunter2-do-not-log-me"
@pytest.fixture(params=[False, True], ids=["visible", "hidden"])
def hide(request, monkeypatch):
monkeypatch.setattr(alakazam, "HIDE_SECRETS", request.param)
return request.param
class TestIsSecretCommand:
@pytest.mark.parametrize("args", [
("app", "secret", "insert", "login.a.org", "db_password", "v1", VALUE),
("app", "secret", "generate", "-a", "login.a.org"),
])
def test_commands_carrying_a_value(self, args):
assert is_secret_command(args)
@pytest.mark.parametrize("args", [
("app", "secret", "ls", "login.a.org"),
("app", "secret", "rm", "login.a.org", "db_password"),
("app", "ls"),
("app", "secret"),
])
def test_commands_without_a_value(self, args):
assert not is_secret_command(args)
class TestGeneratedValues:
def install(self, monkeypatch, created=False):
def abra(*args, **kwargs):
if args[:3] == ("app", "secret", "ls"):
return [{"name": "db_password", "created on server": str(created).lower()}]
if args[:3] == ("app", "secret", "generate"):
return [{"name": "db_password", "value": VALUE}]
raise AssertionError(f"unexpected: {args}")
monkeypatch.setattr(alakazam, "abra", abra)
def test_the_name_is_always_reported(self, hide, monkeypatch, capsys):
self.install(monkeypatch)
generate_all_secrets("login.a.org")
assert "db_password" in capsys.readouterr().out
def test_the_value_follows_the_switch(self, hide, monkeypatch, capsys):
self.install(monkeypatch)
generate_all_secrets("login.a.org")
out = capsys.readouterr().out
assert (VALUE in out) is not hide
assert ("[hidden]" in out) is hide
class TestDebugLog:
"""-l debug is the level a pipeline reaches for when something breaks."""
def run_abra(self, monkeypatch, args):
class Process:
returncode = 0
stdout = f'[{{"name":"db_password","value":"{VALUE}"}}]'.encode()
stderr = b""
monkeypatch.setattr(alakazam.subprocess, "run", lambda cmd, capture_output: Process())
return alakazam.abra(*args)
def test_the_command_line_is_not_logged_when_hidden(self, monkeypatch, caplog):
monkeypatch.setattr(alakazam, "HIDE_SECRETS", True)
with caplog.at_level(logging.DEBUG):
self.run_abra(monkeypatch, ("app", "secret", "insert", "login.a.org", "db_password", "v1", VALUE))
assert VALUE not in caplog.text
def test_the_generated_output_is_not_logged_when_hidden(self, monkeypatch, caplog):
monkeypatch.setattr(alakazam, "HIDE_SECRETS", True)
with caplog.at_level(logging.DEBUG):
self.run_abra(monkeypatch, ("app", "secret", "generate", "-a", "login.a.org"))
assert VALUE not in caplog.text
def test_other_commands_are_still_logged(self, monkeypatch, caplog):
"""Hiding secrets must not turn the debug log off altogether."""
monkeypatch.setattr(alakazam, "HIDE_SECRETS", True)
with caplog.at_level(logging.DEBUG):
self.run_abra(monkeypatch, ("app", "ls"))
assert "run command" in caplog.text
def test_insert_logs_the_name_but_not_the_value(self, monkeypatch, caplog):
monkeypatch.setattr(alakazam, "HIDE_SECRETS", True)
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: "")
with caplog.at_level(logging.DEBUG):
insert_secret("login.a.org", "db_password", VALUE)
assert "db_password" in caplog.text
assert VALUE not in caplog.text
class TestLocalHookOutput:
"""A recipe may print the secret it created, vaultwarden's admin token does exactly that."""
TOKEN = "vaultwarden-admin-token-in-plain"
def install(self, monkeypatch, fail=False):
seen = {}
class Process:
returncode = 1 if fail else 0
stdout = TestLocalHookOutput.TOKEN.encode()
stderr = b""
def run(cmd, capture_output=False, **kwargs):
seen["capture_output"] = capture_output
return Process()
monkeypatch.setattr(alakazam.subprocess, "run", run)
def streamed(cmd):
seen["streamed"] = True
return Process()
monkeypatch.setattr(alakazam, "run_streamed", streamed)
return seen
def hook(self, strict=False):
alakazam.run_secret_hooks("login.a.org", {"secret_hooks": ["insert_admin_token"], "server": "a.org"}, strict=strict)
def test_the_hook_is_streamed_when_not_hiding(self, monkeypatch):
monkeypatch.setattr(alakazam, "HIDE_SECRETS", False)
seen = self.install(monkeypatch)
self.hook()
assert seen.get("streamed")
def test_the_hook_is_not_streamed_when_hiding(self, monkeypatch, capsys):
monkeypatch.setattr(alakazam, "HIDE_SECRETS", True)
seen = self.install(monkeypatch)
self.hook()
assert not seen.get("streamed")
assert self.TOKEN not in capsys.readouterr().out
def test_a_failing_hook_does_not_leak_through_the_error(self, monkeypatch):
"""The captured output lands in the exception, which is where it would escape."""
monkeypatch.setattr(alakazam, "HIDE_SECRETS", True)
self.install(monkeypatch, fail=True)
with pytest.raises(alakazam.click.ClickException) as excinfo:
self.hook(strict=True)
assert self.TOKEN not in excinfo.value.message
assert "withheld by --hide-secrets" in excinfo.value.message
def test_a_failing_hook_still_reports_its_output_when_not_hiding(self, monkeypatch):
monkeypatch.setattr(alakazam, "HIDE_SECRETS", False)
self.install(monkeypatch, fail=True)
with pytest.raises(alakazam.click.ClickException) as excinfo:
self.hook(strict=True)
assert self.TOKEN in excinfo.value.message
class TestLocalScriptOutput:
def install(self, monkeypatch, tmp_path):
script = tmp_path / "hook.sh"
script.write_text("#!/bin/sh\necho secret\n")
script.chmod(0o755)
seen = {}
class Result:
returncode = 0
monkeypatch.setattr(alakazam, "resolve_path", lambda p, base=None: script)
monkeypatch.setattr(alakazam.subprocess, "run",
lambda cmd, **kw: seen.update(kw) or Result())
return seen
def test_the_script_output_is_captured_when_hiding(self, monkeypatch, tmp_path):
monkeypatch.setattr(alakazam, "HIDE_SECRETS", True)
seen = self.install(monkeypatch, tmp_path)
alakazam.run_local_script(["script", "hook.sh"], "login.a.org", "a.org", "a.org")
assert seen["capture_output"] is True
def test_the_script_output_is_passed_through_when_not_hiding(self, monkeypatch, tmp_path):
monkeypatch.setattr(alakazam, "HIDE_SECRETS", False)
seen = self.install(monkeypatch, tmp_path)
alakazam.run_local_script(["script", "hook.sh"], "login.a.org", "a.org", "a.org")
assert seen["capture_output"] is False
+1 -1
View File
@@ -53,7 +53,7 @@ class TestRunLocalScript:
script = make_script(tmp_path)
calls = []
monkeypatch.setattr(alakazam.subprocess, "run",
lambda cmd, env: calls.append((cmd, env)) or FakeProcess(0))
lambda cmd, env, **kwargs: 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"]
+41 -1
View File
@@ -9,7 +9,7 @@ import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import alakazam
from alakazam import purge_apps
from alakazam import purge_app_secrets, purge_apps
INSTANCE_APPS = {"a.org": [["authentik", "login.a.org"]]}
CONFIG = {"a.org": {"authentik": {"app_domain": "login.a.org", "server": "a.org"}}}
@@ -50,3 +50,43 @@ class TestPurgeApps:
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: calls.append(a) or "")
purge_apps(INSTANCE_APPS)
assert calls == []
class TestPurgeAppSecrets:
@pytest.fixture
def config(self, monkeypatch):
monkeypatch.setattr(alakazam, "INSTANCE_CONFIGS", CONFIG)
def install(self, monkeypatch, error=None):
calls = []
def abra(*args, **kwargs):
calls.append(args)
if error:
raise RuntimeError(error)
return ""
monkeypatch.setattr(alakazam, "abra", abra)
return calls
def test_all_secrets_of_a_recipe_are_removed(self, config, monkeypatch, capsys):
calls = self.install(monkeypatch)
purge_app_secrets({"authentik": []})
assert calls == [("app", "secret", "rm", "-a", "login.a.org")]
assert "purged" in capsys.readouterr().out
def test_an_app_without_secrets_is_not_a_failure(self, config, monkeypatch, capsys):
"""abra exits non-zero when it found nothing to remove, which says the job is already done."""
self.install(monkeypatch, error="FATA no secrets to remove?")
purge_app_secrets({"authentik": []})
assert "has no secrets on its server" in capsys.readouterr().out
def test_a_real_failure_still_propagates(self, config, monkeypatch):
self.install(monkeypatch, error="FATA error during connect: no route to host")
with pytest.raises(RuntimeError):
purge_app_secrets({"authentik": []})
def test_a_named_secret_that_is_absent_is_skipped(self, config, monkeypatch, capsys):
self.install(monkeypatch, error="FATA email_pass doesn't exist on server?")
purge_app_secrets({"authentik": ["email_pass"]})
assert "is not stored on the server" in capsys.readouterr().out
+79
View File
@@ -0,0 +1,79 @@
"""Tests for a version that pins a commit while naming the release it is based on."""
import logging
import os
import sys
import click
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import alakazam
from alakazam import classify_version, merge_instance_configs, resolve_version
class TestResolveVersion:
def test_a_pinned_commit_wins_over_the_release(self):
assert resolve_version("12.0.2+2026.5.2@be9ebb3", "nextcloud") == "be9ebb3"
@pytest.mark.parametrize("version", ["1.2.3", "12.0.2+2026.5.2", "chaos", "latest", "be9ebb3"])
def test_a_version_without_a_commit_is_untouched(self, version):
assert resolve_version(version, "nextcloud") == version
@pytest.mark.parametrize("version", [None, 1.2, True])
def test_non_strings_are_untouched(self, version):
"""The configuration is user supplied and does not have to hold a string."""
assert resolve_version(version, "nextcloud") is version
def test_a_trailing_at_is_rejected(self):
"""Silently deploying the release instead of the intended commit would be worse."""
with pytest.raises(click.ClickException) as excinfo:
resolve_version("12.0.2@", "nextcloud")
assert "without a commit" in excinfo.value.message
assert "nextcloud" in excinfo.value.message
def test_the_release_is_kept_in_the_debug_log(self, caplog):
"""It is the only place the base release survives, and CI failures are read there."""
with caplog.at_level(logging.DEBUG):
resolve_version("12.0.2+2026.5.2@be9ebb3", "nextcloud")
assert "based on 12.0.2+2026.5.2" in caplog.text
def test_the_last_at_separates(self):
assert resolve_version("a@b@be9ebb3", "nextcloud") == "be9ebb3"
class TestClassification:
def test_the_resolved_commit_classifies_as_a_hash(self):
"""upgrade branches on this, a release would take the wrong abra command."""
assert classify_version(resolve_version("12.0.2+2026.5.2@be9ebb3", "nextcloud")) == "hash"
def test_an_unpinned_release_still_classifies_as_a_version(self):
assert classify_version(resolve_version("12.0.2+2026.5.2", "nextcloud")) == "version"
class TestMergedConfig:
"""The resolution has to happen once, where the configuration is built."""
def merge(self, version):
return merge_instance_configs(
{}, "example.com", {"nextcloud": {"version": version}}, {})
def test_the_merged_config_carries_the_commit(self):
assert self.merge("12.0.2+2026.5.2@be9ebb3")["nextcloud"]["version"] == "be9ebb3"
def test_a_plain_version_survives_the_merge(self):
assert self.merge("12.0.2")["nextcloud"]["version"] == "12.0.2"
def test_an_app_without_a_version(self):
assert "version" not in self.merge(None)["nextcloud"] or \
self.merge(None)["nextcloud"]["version"] is None
def test_a_templated_version_is_resolved_after_substitution(self):
"""Jinja runs first, so a version coming from GLOBALS is pinned as well."""
merged = merge_instance_configs(
{"GLOBALS": {"pin": "12.0.2+2026.5.2@be9ebb3"}},
"example.com",
{"nextcloud": {"version": "{{pin}}"}},
{})
assert merged["nextcloud"]["version"] == "be9ebb3"