From 1239655d6424e09fec4e85d7634649abb3201eb6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 18 Aug 2026 03:04:44 +0200 Subject: [PATCH] feat(secrets): add --only flag to select secret source --- README.md | 7 +++++++ alakazam.py | 50 +++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9df25a8..ffaf4fd 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,13 @@ The command undeploys the affected apps, removes the listed secrets, creates the - the `-e` flag executes post-deployment hooks after each deployment - a secret that is shared between apps has to be listed for every app holding a copy of it, otherwise the old value is copied back from the app that still has it +Secrets are created in four variants: `conf` (values from the `secrets` configuration), `secret-hooks` (local `abra.sh` commands), `exchange` (`shared_secrets` between apps) and `generate` (everything left over, generated by abra). +By default all four run; `-o`/`--only` limits them, both for `reinsert-secrets` and for `secrets`: + +``` +alakazam example.com.yml secrets --only conf,generate +``` + To remove secrets without recreating them, `purge-secrets` uses the same notation: ``` diff --git a/alakazam.py b/alakazam.py index e0548de..29cc4cd 100755 --- a/alakazam.py +++ b/alakazam.py @@ -74,6 +74,8 @@ 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" +# the variants create_secrets() runs, in the order they are applied +SECRET_VARIANTS = ("conf", "secret-hooks", "exchange", "generate") class MySafeConstructor(SafeConstructor): @@ -1177,22 +1179,26 @@ def config(recipes: Tuple[str]) -> None: @cli.command() @click.option('recipes', '-r', '--recipe', multiple=True, metavar='', help='Filter for selcted recipes, this option can be specified multiple times.') @click.option('--syncvalues', is_flag=True, default=False, help='Synchronize secret values from source to target when they differ. Without this flag, value mismatches are only reported as warnings.') -def secrets(recipes: Tuple[str], syncvalues: bool) -> None: +@click.option('only', '-o', '--only', multiple=True, metavar='', help='Only run the listed secret variants, all of them by default. Comma separated or specified multiple times.') +def secrets(recipes: Tuple[str], syncvalues: bool, only: Tuple[str]) -> None: """ Generates and inserts secrets for specified apps. This function handles secrets defined in the configuration, secrets produced by secret hooks, shared secrets exchanged between apps and finally all remaining secrets generated by abra. """ - create_secrets(recipes, syncvalues=syncvalues) + create_secrets(recipes, syncvalues=syncvalues, variants=parse_secret_variants(only)) -def create_secrets(recipes: Tuple[str], syncvalues: bool = False) -> None: +def create_secrets(recipes: Tuple[str], syncvalues: bool = False, variants: Optional[Set[str]] = None) -> None: """ Creates all secrets that are missing on the server for the selected apps Args: recipes (list): Filter for selected recipes; all apps if empty. syncvalues (bool): Synchronize shared secret values from source to target when they differ. + variants (set): The variants to run, see SECRET_VARIANTS. Defaults to all of them. """ + if variants is None: + variants = set(SECRET_VARIANTS) for _, instance_config in INSTANCE_CONFIGS.items(): instance_apps = instance_config.keys() if recipes: @@ -1205,14 +1211,18 @@ def create_secrets(recipes: Tuple[str], syncvalues: bool = False) -> None: app_config = instance_config[app] domain = app_config['app_domain'] print(f"Create secrets for {domain}") - insert_secrets_from_conf(domain, app_config) - run_secret_hooks(domain, app_config) + if "conf" in variants: + insert_secrets_from_conf(domain, app_config) + if "secret-hooks" in variants: + run_secret_hooks(domain, app_config) # Pass 2: exchange secrets between apps, then generate any remaining missing secrets. for app in selected_apps: app_config = instance_config[app] domain = app_config['app_domain'] - exchange_secrets(app, instance_config, instance_apps, syncvalues=syncvalues) - generate_all_secrets(domain) + if "exchange" in variants: + exchange_secrets(app, instance_config, instance_apps, syncvalues=syncvalues) + if "generate" in variants: + generate_all_secrets(domain) def get_deployed_apps(apps: Tuple[str]) -> Dict[str, str]: @@ -1646,6 +1656,26 @@ def parse_recipe_secrets(secrets: Tuple[str]) -> Dict[str, List[str]]: return recipe_secrets +def parse_secret_variants(only: Tuple[str]) -> Set[str]: + """ + Parses the --only values into the set of secret variants create_secrets() should run. + Every argument may hold several comma separated variants and the option may be repeated, so '--only conf,generate' and '--only conf --only generate' are equivalent. + + Args: + only (list): The raw --only arguments; all variants are run if empty. + + Returns: + set: The names of the variants to run. + + Raises: + click.BadParameter: If a variant is not part of SECRET_VARIANTS. + """ + variants = {variant.strip() for value in only for variant in value.split(",") if variant.strip()} + if unknown := variants - set(SECRET_VARIANTS): + raise click.BadParameter(f"unknown secret variant(s) {', '.join(sorted(unknown))}, choose from {', '.join(SECRET_VARIANTS)}") + return variants or set(SECRET_VARIANTS) + + def purge_app_secrets(recipe_secrets: Dict[str, List[str]]) -> None: """ Removes the given secrets from every app of the given recipes. @@ -1713,14 +1743,16 @@ def format_recipe_secrets(recipe_secrets: Dict[str, List[str]]) -> str: @click.option('secrets', '-s', '--secret', multiple=True, required=True, metavar=':', help='Secrets to be rotated, assigned to the recipe they belong to. Comma separated or specified multiple times.') @click.option('-e', '--execute-hooks', is_flag=True, help='run post-deployment commands.') @click.option('-c', '--converge-checks', is_flag=True, help='perform convergence checks during deployment.') +@click.option('only', '-o', '--only', multiple=True, metavar='', help='Only run the listed secret variants when creating the secrets anew, all of them by default. Comma separated or specified multiple times.') @click.option('noninteractive', '-n', '--non-interactive', is_flag=True, help='Run this command non-interactively') -def reinsert_secrets(secrets: Tuple[str], execute_hooks: bool, converge_checks: bool, noninteractive: bool) -> None: +def reinsert_secrets(secrets: Tuple[str], execute_hooks: bool, converge_checks: bool, only: Tuple[str], noninteractive: bool) -> None: """ Rotates secrets: undeploys the affected apps, removes the given secrets, creates them anew and redeploys the apps that were deployed before. A secret that is shared between apps has to be listed for every app holding a copy of it, otherwise the old value is copied back from the app that still has it. """ recipe_secrets = parse_recipe_secrets(secrets) + variants = parse_secret_variants(only) recipes = tuple(recipe_secrets) deployed_apps = get_apps_by_deployment(recipes, deployed=True) print_all_apps(get_apps(recipes)) @@ -1728,7 +1760,7 @@ def reinsert_secrets(secrets: Tuple[str], execute_hooks: bool, converge_checks: return undeploy_apps(deployed_apps) purge_app_secrets(recipe_secrets) - create_secrets(recipes) + create_secrets(recipes, variants=variants) deploy_apps(deployed_apps, execute_hooks=execute_hooks, converge_checks=converge_checks)