8 Commits
Author SHA1 Message Date
moritz df1c50174f fix(combine): use monitoring-ng 1.x names for the grafana stack
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-09-09 21:24:04 +02:00
moritz e91abd5a60 feat(upgrade): create the secrets a new recipe version added
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-09-09 16:46:37 +02:00
moritz 9d0d6f1f3a fix(combine): uncomment kimai's SSO_ADMIN_GROUP_NAME
continuous-integration/drone/push Build is passing
2026-09-08 15:21:14 +02:00
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
9 changed files with 490 additions and 10 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:
+104 -8
View File
@@ -493,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.
@@ -551,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.
@@ -636,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.
@@ -645,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.
@@ -661,7 +720,10 @@ def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, s
command.append("-m")
# 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 is_secret_command(args)
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):
@@ -675,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 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:
@@ -851,6 +916,23 @@ def update_configs(path: Path, config: Dict[str, Any]) -> None:
dotenv.set_key(path, key, value, quote_mode="never")
def get_missing_secrets(domain: str) -> List[str]:
"""
Lists the secrets a recipe declares that the server does not hold.
This is the same condition abra checks before it deploys, where a missing secret ends the run
with "secret not generated". Reading it beforehand is what lets a caller fill the gap instead.
Args:
domain (str): The app domain to check
Returns:
list: The names of the missing secrets, empty when the app is complete
"""
stored_secrets = abra("app", "secret", "ls", domain, machine_output=True)
return [s['name'] for s in stored_secrets or [] if not str2bool(s['created on server'])]
def generate_all_secrets(domain: str) -> None:
"""
Generates all secrets for the app specified by its domain using the 'abra' command.
@@ -1221,7 +1303,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:
@@ -1233,6 +1316,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.
@@ -1251,7 +1337,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}")
@@ -1526,9 +1612,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], hide_secrets: bool) -> 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.
@@ -1569,6 +1656,10 @@ def cli(loglevel: str, group_path: str, exclude: Tuple[str], hide_secrets: bool)
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)
@@ -1822,7 +1913,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, instance))
upgrade_cmds.append((app, app_config, upgrade_cmd, instance))
if version_type == 'version':
release_note_cmd = upgrade_cmd.copy()
release_note_cmd.insert(1, '-r')
@@ -1837,8 +1928,13 @@ 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, instance_domain in upgrade_cmds:
for app, app_config, upgrade_cmd, instance_domain in upgrade_cmds:
app_domain = app_config.get('app_domain')
# a recipe can bring a new secret along, which abra refuses to deploy without. The app
# is running, so nothing else can be missing: it had all of them when it was deployed
if missing := get_missing_secrets(app_domain):
print(f"{app_domain} is missing {len(missing)} secret(s): {', '.join(sorted(missing))}")
create_secrets((app,), instances=(instance_domain,))
if redeploy:
upgrade_cmd.pop(0)
upgrade_cmd.insert(0, 'deploy')
+25
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
@@ -122,6 +124,7 @@ kimai:
- SSO_PROVIDER_URL
- SSO_SAML_URL
- SSO_LOGOUT_URL
- SSO_ADMIN_GROUP_NAME
secret_hooks:
- insert_authentik_certificate
dependency: [authentik]
@@ -300,3 +303,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
- OIDC_ENABLED
- SECRET_GRAFANA_ADMIN_PASSWORD_VERSION
- SECRET_GRAFANA_OIDC_CLIENT_SECRET_VERSION
- SECRET_GRAFANA_SMTP_PASSWORD_VERSION
shared_secrets:
monitoring_secret: grafana_oidc_client_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
+86
View File
@@ -98,3 +98,89 @@ class TestDebugLog:
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"]
+85
View File
@@ -0,0 +1,85 @@
"""Tests for filling in secrets a recipe added, before the upgrade that would fail on them."""
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 get_missing_secrets
CONFIG = {"a.org": {"nextcloud": {"app_domain": "cloud.a.org", "server": "a.org", "version": "2.0.0"}}}
class TestGetMissingSecrets:
def install(self, monkeypatch, stored):
monkeypatch.setattr(alakazam, "abra", lambda *a, **k: stored)
def test_a_complete_app_reports_nothing(self, monkeypatch):
self.install(monkeypatch, [{"name": "db_password", "created on server": "true"}])
assert get_missing_secrets("cloud.a.org") == []
def test_the_missing_names_are_returned(self, monkeypatch):
self.install(monkeypatch, [
{"name": "db_password", "created on server": "true"},
{"name": "oidc_secret", "created on server": "false"},
])
assert get_missing_secrets("cloud.a.org") == ["oidc_secret"]
def test_an_app_without_any_secrets(self, monkeypatch):
"""abra answers with an empty document rather than a list when nothing is stored."""
self.install(monkeypatch, {})
assert get_missing_secrets("cloud.a.org") == []
class TestUpgradeFillsThemIn:
@pytest.fixture
def steps(self, monkeypatch):
steps = []
monkeypatch.setattr(alakazam, "INSTANCE_CONFIGS", CONFIG)
monkeypatch.setattr(alakazam, "sleep", lambda s: None)
monkeypatch.setattr(alakazam, "get_apps_by_deployment",
lambda recipes, deployed=True: {"a.org": [["nextcloud", "cloud.a.org", "1.0.0"]]})
monkeypatch.setattr(alakazam, "create_secrets",
lambda recipes, **kw: steps.append(f"secrets:{recipes[0]}"))
return steps
def install_abra(self, monkeypatch, steps, missing):
def abra(*args, **kwargs):
if args[:3] == ("app", "secret", "ls"):
return [{"name": n, "created on server": str(n not in missing).lower()}
for n in ("db_password", "oidc_secret")]
steps.append(" ".join(a for a in args if a))
return ""
monkeypatch.setattr(alakazam, "abra", abra)
def invoke(self):
return CliRunner().invoke(alakazam.upgrade, ["-n"], standalone_mode=False)
def test_a_missing_secret_is_created_before_the_upgrade(self, monkeypatch, steps):
self.install_abra(monkeypatch, steps, missing={"oidc_secret"})
result = self.invoke()
assert result.exit_code == 0, result.exception
# the planning phase fetches release notes with the same command plus -r, skip that one
upgrade = next(i for i, s in enumerate(steps)
if s.startswith("app upgrade") and " -r " not in s)
assert steps.index("secrets:nextcloud") < upgrade
def test_the_missing_name_is_reported(self, monkeypatch, steps):
"""Silently repairing secrets would hide which one the recipe added."""
self.install_abra(monkeypatch, steps, missing={"oidc_secret"})
assert "missing 1 secret(s): oidc_secret" in self.invoke().output
def test_a_complete_app_does_not_run_the_secrets(self, monkeypatch, steps):
self.install_abra(monkeypatch, steps, missing=set())
self.invoke()
assert not any(s.startswith("secrets:") for s in steps)
def test_a_dry_run_creates_nothing(self, monkeypatch, steps):
"""The check sits behind the confirmation, so --dry-run must not reach it."""
self.install_abra(monkeypatch, steps, missing={"oidc_secret"})
CliRunner().invoke(alakazam.upgrade, ["-n", "--dry-run"], standalone_mode=False)
assert not any(s.startswith("secrets:") for s in steps)
+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"