Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39fe0f3e52 | ||
|
|
2f75306140 |
+3
-1
@@ -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 '*'
|
||||
|
||||
|
||||
@@ -185,6 +185,14 @@ 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
|
||||
```
|
||||
|
||||
### Run CMDs
|
||||
|
||||
Escaping can be akward:
|
||||
|
||||
+32
-1
@@ -551,6 +551,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.
|
||||
@@ -1526,9 +1552,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 +1596,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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user