From cab4b1573cd641a2a95d08d50256093869b1b7ef Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 9 Sep 2026 21:48:41 +0200 Subject: [PATCH] feat(config)!: replace combine.yml with auto-applying config-sets BREAKING CHANGE: the integrations alakazam shipped as combine.yml are gone from the repository. Copy examples/config-sets-combine.yml into the root path to keep them, where they can be pinned and adjusted along with the versions. An integration is now a config-set carrying 'apply-when', which names the apps whose presence makes it apply. Naming the condition rather than deriving it from the configured apps is what lets a set configure only one side, as four of the seventeen pairs in combine.yml did. --- Dockerfile | 2 +- README.md | 67 +++--- alakazam.py | 144 ++++++----- combine.yml | 327 ------------------------- examples/config-sets-combine.yml | 395 +++++++++++++++++++++++++++++++ tests/test_config_sets.py | 166 +++++++++++++ tests/test_preflight.py | 6 +- 7 files changed, 688 insertions(+), 419 deletions(-) delete mode 100644 combine.yml create mode 100644 examples/config-sets-combine.yml create mode 100644 tests/test_config_sets.py diff --git a/Dockerfile b/Dockerfile index 9d22f4a..220f44c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ RUN apt-get update \ COPY --from=abra /out/abra /usr/local/bin/abra COPY --from=deps /opt/alakazam /opt/alakazam -COPY alakazam.py combine.yml /opt/alakazam/ +COPY alakazam.py /opt/alakazam/ RUN printf '#!/bin/sh\nexec /opt/alakazam/bin/python /opt/alakazam/alakazam.py "$@"\n' \ > /usr/local/bin/alakazam \ && chmod +x /usr/local/bin/alakazam diff --git a/README.md b/README.md index 22bac72..934de25 100644 --- a/README.md +++ b/README.md @@ -227,12 +227,7 @@ Configuration files support templating with Jinja2 and global variables, facilit 1. **`alaka.yml`**/**`alaka-*.yml`**: Contains global configurations for a hierarchical layer, affecting all subordinate apps. 2. **`example.com.yml`** (`.yml`): A minimalist configuration for each instance, specifying the apps to be installed, the instance domain and optionally instance specific configurations. - an **instance** means a collection of apps that are integrated with each other (one server / vm can contain multiple instances) -3. **`combine.yml`**: Manages configurations required to integrate multiple apps with each other. - - This configuration should not be touched by the operator. - - At the moment it's part of the `alakazam` repository. - - In future it should be split into the recipes repositories and maintained by the recipe maintainer. - - We started to move each app entry as `alaconnect.yml` into the respective repositories. -4. **`config-sets.yml`**: Optional file placed at the `root` path. Defines named sets of per-app configurations that can be activated or deactivated per instance or group, avoiding duplication across instance files. See [examples/config-sets.yml](./examples/config-sets.yml). +3. **`config-sets.yml`**/**`config-sets-*.yml`**: Optional files at the `root` path. They define named sets of per-app configurations, either switched on per instance or group, or applying by themselves as an integration between apps. See [Config-Sets](#config-sets). ### Global Settings @@ -259,7 +254,7 @@ uptime_kuma: ### App Configuration -`alaka.yml`/`alaka-*.yml`, `example.com.yml` and `combine.yml`/`alaconnect.yml` contain a similar configuration structure. +`alaka.yml`/`alaka-*.yml`, `example.com.yml` and the config-sets contain a similar configuration structure. For each app/recipe the following `` can be used: - **`uncomment`/`comment`**: Manages the active status of lines in configuration files, such as uncommenting needed variables . @@ -274,16 +269,45 @@ For each app/recipe the following `` can be used: - **`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 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`) +- **`shared_secrets`**: Shares a secret between two apps, as `: {: }`. +- **`subdomain`**: Specifies the subdomain scheme for individual recipes and apps. (not available in config-sets) - 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`) +- **`version`**: Controls the recipe version to deploy; if unspecified, the latest version is used. (not available in config-sets) - a commit can be pinned with `@`, 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: +### Config-Sets -- **`shared_secrets`**: Specifies secret sharing between apps. - - `:` +A config-set is a named bundle of per-app configuration, defined in `config-sets.yml` or any `config-sets-*.yml` at the `root` path. The files are merged in the order `config-sets.yml`, then the `config-sets-*.yml` alphabetically, the same precedence the `alaka*.yml` of a directory have. + +A set applies in one of two ways. + +**Switched on per instance or group**, via `CONFIG-SETS` in any `alaka.yml` or `example.com.yml`: + +```yaml +CONFIG-SETS: + bbb: true +``` + +**By itself**, when it carries `apply-when` and every app named there is part of the instance: + +```yaml +integrate-authentik-nextcloud: + apply-when: [authentik, nextcloud] + authentik: + uncomment: [compose.nextcloud.yml] + nextcloud: + uncomment: [compose.authentik.yml] + shared_secrets: + authentik: + nextcloud_secret: authentik_secret +``` + +That is what an integration between two apps is: configuration belonging to both of them that is pointless unless both are there. `apply-when` names the condition rather than deriving it from the configured apps, so an integration can configure only one side: `[matrix-synapse, traefik]` while configuring `traefik` alone. An instance can still switch such a set off with `CONFIG-SETS: {integrate-authentik-nextcloud: false}`. + +Merge priority: automatic sets < group < sets switched on explicitly < instance. An integration is the lowest layer of all, so a group configuration can adjust it — the precedence the `combine.yml` it replaces had. Its list entries land behind the ones an app configures for itself, which is what keeps an integration hook such as `app set_authentik` running after the `app core_install` that sets the app up in the first place. + +[examples/config-sets-combine.yml](./examples/config-sets-combine.yml) holds the integrations alakazam used to ship as `combine.yml`. Copy it into your `root` path to keep them. ### Dependencies @@ -346,22 +370,9 @@ These configurations are designed to modularize and simplify the management of a ``` -2. **`combine.yml`** - - This file orchestrates the integration between multiple apps within the same instance (`example.com.yml`), applying `` to ensure seamless functionality between connected applications. - - The configuration for each app combination is structured as follows, again `` is following the guidelines set in the [App Configuration](#app-configuration) section: - - ``` - : - : - - ``` - - - In future each `` entry will be placed in the `alaconnect.yml` inside the `` folder. - -3. **`config-sets.yml`** - - Optional file at the `root` path that defines named sets of per-app configurations. Each top-level key is a config-set name; its value is a map of `` to ``. - - Activate or disable sets per instance or group via `CONFIG-SETS` in any `alaka.yml` or `example.com.yml`. Merge priority: group < config-set < instance. - - See [examples/config-sets.yml](./examples/config-sets.yml). +2. **`config-sets.yml`**/**`config-sets-*.yml`** + - Optional files at the `root` path. Each top-level key is a config-set name; its value is a map of `` to ``. + - See [Config-Sets](#config-sets). ### Templating Configurations diff --git a/alakazam.py b/alakazam.py index 8aa7959..a316f86 100755 --- a/alakazam.py +++ b/alakazam.py @@ -4,7 +4,7 @@ import os import json import logging from pathlib import Path -from typing import List, Dict, Any, Iterator, Optional, Union, Set, Tuple +from typing import List, Dict, Any, Iterable, Iterator, Optional, Union, Set, Tuple import subprocess import re @@ -23,8 +23,9 @@ from packaging import version from uptime_kuma_api import UptimeKumaApi, MonitorType from time import sleep, time -COMBINE_PATH = os.path.dirname(os.path.realpath(__file__)) + "/combine.yml" NON_APP_KEYS = {'CONFIG-SETS', 'GLOBALS'} +# key of a config-set that names the apps whose presence makes the set apply by itself +APPLY_WHEN = 'apply-when' # INSTANCE_CONFIGS: dict: contains all app organized by recipe names and instance domains # The structure of the dictionary is as follows: # { @@ -95,6 +96,8 @@ READINESS_INITIAL_DELAY = 0 # seconds to wait before the first attempt SECRET_VARIANTS = ("conf", "secret-hooks", "exchange", "generate") # group configuration files, inherited down the directory hierarchy GROUP_CONFIG_RE = re.compile(r'^alaka(-.*)?\.ya?ml$') +# config-set files, read from the root path only +CONFIG_SET_RE = re.compile(r'^config-sets(-.*)?\.ya?ml$') # instance configuration files, named '.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 @@ -190,7 +193,7 @@ def get_relevant_config_paths(root_path: Path, group_path: Path, exclude_paths: Returns: list: The configuration files to validate, without duplicates """ - paths = [Path(COMBINE_PATH), root_path / "config-sets.yml"] + paths = [root_path / file for file in os.listdir(root_path) if CONFIG_SET_RE.match(file)] directory = group_path if group_path.is_dir() else group_path.parent for ancestor in reversed([directory, *directory.parents]): if ancestor != root_path and root_path not in ancestor.parents: @@ -404,31 +407,79 @@ def merge_all_group_configs(root_path: Path) -> Dict[str, Dict[str, Any]]: -def get_config_set_app_configs( - active_config_sets: Dict[str, Any], config_sets: Dict[str, Any] -) -> Dict[str, Any]: +def read_config_sets(root_path: Path) -> Dict[str, Any]: """ - Merges per-app configurations from all active config-sets into a single dictionary. - Config-sets disabled with False are skipped; unknown set names log a warning. - When multiple active sets define config for the same app, later sets take precedence. + Reads and merges every 'config-sets*.yml' of the root path. + + 'config-sets.yml' comes first as the base, the 'config-sets-*.yml' specialisations follow in + alphabetical order, the same precedence the 'alaka*.yml' of a directory have. Config-sets live + in the root path only, so that an integration reads the same for every instance below it. + + Args: + root_path (Path): The alakazam root path + + Returns: + dict: The merged config-set definitions + """ + files = sorted((f for f in os.listdir(root_path) if CONFIG_SET_RE.match(f)), + key=lambda f: (not f.startswith("config-sets."), f)) + merged: Dict[str, Any] = {} + defined_by: Dict[Tuple[str, ...], Tuple[str, Any]] = {} + for file in files: + config = read_config(str(root_path / file)) or {} + warn_on_key_collisions(config, defined_by, file, str(root_path)) + merged = merge_dict(merged, config) + return merged + + +def get_config_set_app_configs( + active_config_sets: Dict[str, Any], config_sets: Dict[str, Any], instance_apps: Iterable[str] +) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """ + Merges per-app configurations from all applying config-sets into a single dictionary. + + A set applies either because the instance enabled it under CONFIG-SETS, or by itself when it + carries an 'apply-when' and every app named there is part of the instance. That is what an + integration between two apps is: configuration that belongs to both of them and is pointless + unless both are there. Setting such a set to False for an instance still turns it off. + + The two kinds are returned apart because they sit at different heights: an integration is the + lowest layer of all, below even the group configuration, so that a group can adjust it. That is + the precedence the combine.yml this replaces had. Among the explicit ones, later sets win. Args: active_config_sets (dict): Merged CONFIG-SETS dict (set name -> True or False). - config_sets (dict): Full config-set definitions loaded from config-sets.yml. + config_sets (dict): Full config-set definitions from the root path. + instance_apps (iterable): The app names configured for this instance. Returns: - dict: A merged dictionary of per-app configurations from all active config-sets. + tuple: The per-app configurations of the automatic sets and of the explicitly enabled ones """ + automatic_app_configs: Dict[str, Any] = {} config_set_app_configs: Dict[str, Any] = {} + automatic = set() + for name, definition in config_sets.items(): + if not (apply_when := (definition or {}).get(APPLY_WHEN)): + continue + automatic.add(name) + if active_config_sets.get(name) is False: + logging.debug(f"config-set '{name}' applies but is switched off for this instance") + continue + if not all(app in instance_apps for app in apply_when): + continue + logging.debug(f"config-set '{name}' applies, {', '.join(apply_when)} are all configured") + apps = {app: config for app, config in definition.items() if app != APPLY_WHEN} + automatic_app_configs = merge_dict(automatic_app_configs, apps) for config_set_name, config_set_enabled in active_config_sets.items(): - if config_set_enabled: - if config_set_name not in config_sets: - logging.warning( - f"Config-set '{config_set_name}' is enabled but not defined in config-sets.yml. Skipping." - ) - else: - config_set_app_configs = merge_dict(config_set_app_configs, config_sets[config_set_name] or {}) - return config_set_app_configs + if not config_set_enabled or config_set_name in automatic: + continue + if config_set_name not in config_sets: + logging.warning( + f"Config-set '{config_set_name}' is enabled but not defined in the root path. Skipping." + ) + continue + config_set_app_configs = merge_dict(config_set_app_configs, config_sets[config_set_name] or {}) + return automatic_app_configs, config_set_app_configs def substitute_jinja_variable(jinja_dict, subs_dict) -> None: @@ -474,8 +525,8 @@ def merge_instance_configs(group_config: Dict[str, Any], instance_domain: str, i group_config.get('CONFIG-SETS') or {}, instance_config.get('CONFIG-SETS') or {}, ) - config_set_app_configs = get_config_set_app_configs(active_config_sets, config_sets) instance_apps = {k: v for k, v in instance_config.items() if k not in NON_APP_KEYS} + automatic_app_configs, config_set_app_configs = get_config_set_app_configs(active_config_sets, config_sets, instance_apps) # Warn about config-set entries that target apps not listed in the instance config. for config_set_app in set(config_set_app_configs) - set(instance_apps): logging.warning( @@ -484,11 +535,18 @@ def merge_instance_configs(group_config: Dict[str, Any], instance_domain: str, i ) merged_config = {} for app in instance_apps: + automatic_app_config = automatic_app_configs.get(app) or {} group_app_config = group_config.get(app) or {} config_set_app_config = config_set_app_configs.get(app) or {} instance_app_config = instance_apps[app] or {} - # Priority: group < config-set < instance - merged_config[app] = merge_dict(merge_dict(group_app_config, config_set_app_config), instance_app_config) + # Priority: automatic set < group < config-set < instance. The automatic sets are merged + # last so that everything else outranks them, and with the list order reversed so that + # their entries land behind the ones the app configures for itself. An integration that + # adds 'set_authentik' must run after the 'core_install' that sets the app up at all. + merged_config[app] = merge_dict( + automatic_app_config, + merge_dict(merge_dict(group_app_config, config_set_app_config), instance_app_config), + reverse_list_order=True) merged_config[app]['app_domain'] = map_subdomain(app, instance_domain, merged_config[app]) if not merged_config[app].get('server'): merged_config[app]['server'] = server @@ -609,42 +667,6 @@ def exclude_from_configs(configs: Dict[str, Dict[str, Any]], recipes: Tuple[str] } -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. - This involves integrating shared secrets and other connection-specific settings between applications within the same instance. - - Args: - configs (dict): The initial instance configurations before integrating connection-specific adjustments. - - Returns: - dict: The updated instance configurations after applying the connection settings. - """ - connection_config = read_config(COMBINE_PATH) - extend_shared_secrets(connection_config) - merged_configs = configs.copy() - for _, instance_config in merged_configs.items(): - for target_app, source_apps in connection_config.items(): - for source_app, target_conf in source_apps.items(): - if target_app in instance_config and source_app in instance_config: - instance_config[target_app] = merge_dict(target_conf, instance_config[target_app], reverse_list_order=True) - return merged_configs - - -def extend_shared_secrets(connection_config: Dict[str, Any]) -> None: - """ - Extends connection configurations by embedding source app details into the shared secrets configuration. - This modifies the existing connection configurations in place, adding a layer of source app information to shared secrets. - - Args: - connection_config (dict): Connection configurations which involve shared secrets. - """ - for _, source_apps in connection_config.items(): - for source_app, target_conf in source_apps.items(): - if shared_secrets:= target_conf.get('shared_secrets'): - 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. @@ -1653,15 +1675,15 @@ def cli(loglevel: str, group_path: str, exclude: Tuple[str], exclude_recipes: Tu 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")) + config_sets = read_config_sets(ROOT_PATH) instance_configs = get_merged_instance_configs(_group_path, all_group_configs, exclude_paths, config_sets) - INSTANCE_CONFIGS = merge_connection_configs(instance_configs) + INSTANCE_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) + ALL_CONFIGS = all_configs fetch_recipes(INSTANCE_CONFIGS) diff --git a/combine.yml b/combine.yml deleted file mode 100644 index 0994b70..0000000 --- a/combine.yml +++ /dev/null @@ -1,327 +0,0 @@ -authentik: - nextcloud: - uncomment: - - compose.nextcloud.yml - - NEXTCLOUD_DOMAIN - - SECRET_NEXTCLOUD_ID_VERSION - - SECRET_NEXTCLOUD_SECRET_VERSION - - nextcloud.png - wordpress: - uncomment: - - compose.wordpress.yml - - WORDPRESS_DOMAIN - - WORDPRESS_GROUP - - SECRET_WORDPRESS_ID_VERSION - - SECRET_WORDPRESS_SECRET_VERSION - - wordpress.png - matrix-synapse: - uncomment: - - compose.matrix.yml - - ELEMENT_DOMAIN - - MATRIX_DOMAIN - - SECRET_MATRIX_ID_VERSION - - SECRET_MATRIX_SECRET_VERSION - - matrix.svg - secrets: - matrix_id: matrix - wekan: - uncomment: - - compose.wekan.yml - - WEKAN_DOMAIN - - SECRET_WEKAN_ID_VERSION - - SECRET_WEKAN_SECRET_VERSION - - wekan.png - secrets: - wekan_id: wekan - vikunja: - uncomment: - - compose.vikunja.yml - - VIKUNJA_DOMAIN - - SECRET_VIKUNJA_ID_VERSION - - SECRET_VIKUNJA_SECRET_VERSION - - vikunja.svg - secrets: - vikunja_id: vikunja - kimai: - uncomment: - - compose.kimai.yml - - KIMAI_DOMAIN - - SECRET_KIMAI_ID_VERSION - - SECRET_KIMAI_SECRET_VERSION - - kimai_logo.png - - KIMAI_GROUP - zammad: - uncomment: - - compose.zammad.yml - - ZAMMAD_DOMAIN - - zammad.svg - monitoring-ng: - uncomment: - - compose.monitoring.yml - - MONITORING_DOMAIN - - SECRET_MONITORING_ID_VERSION - - SECRET_MONITORING_SECRET_VERSION - - monitoring.svg - secrets: - monitoring_id: monitoring - outline: - uncomment: - - compose.outline.yml - - OUTLINE_DOMAIN - - SECRET_OUTLINE_ID_VERSION - - SECRET_OUTLINE_SECRET_VERSION - - outline.png - secrets: - outline_id: outline - rallly: - uncomment: - - compose.rallly.yml - - RALLLY_DOMAIN - - SECRET_RALLLY_ID_VERSION - - SECRET_RALLLY_SECRET_VERSION - - rallly.png - secrets: - rallly_id: rallly - hedgedoc: - uncomment: - - compose.hedgedoc.yml - - HEDGEDOC_DOMAIN - - SECRET_HEDGEDOC_ID_VERSION - - SECRET_HEDGEDOC_SECRET_VERSION - - hedgedoc.png - secrets: - hedgedoc_id: hedgedoc - pretix: - env: - APPLICATIONS: - Pretix: - url: https://pretix.example.com/control/ - group: - EXTRA_ICONS: - Pretix: ~/.abra/recipes/authentik/icons/pretix.svg - vaultwarden: - env: - APPLICATIONS: - Vaultwarden: - url: https://vaultwarden.example.com/ - group: - EXTRA_ICONS: - Vaultwarden: ~/.abra/recipes/authentik/icons/vaultwarden.svg - mila: - uncomment: - - compose.mila.yml - - MILA_DOMAIN - - MILA_GROUP - - SECRET_MILA_ID_VERSION - - SECRET_MILA_SECRET_VERSION - - mila.svg - secrets: - mila_id: mila -kimai: - authentik: - uncomment: - - SSO_ENABLED - - SSO_PROVIDER_URL - - SSO_SAML_URL - - SSO_LOGOUT_URL - - SSO_ADMIN_GROUP_NAME - secret_hooks: - - insert_authentik_certificate - dependency: [authentik] -zammad: - authentik: - uncomment: - - SSO_PROVIDER_DOMAIN - - IDP_SSO_TARGET_URL - - IDP_SLO_SERVICE_URL - initial-hooks: - - local enable_authentik_sso - dependency: [authentik] -nextcloud: - authentik: - uncomment: - - compose.authentik.yml - - AUTHENTIK_USER_PREFIX - - AUTHENTIK_DOMAIN - - SECRET_AUTHENTIK_SECRET_VERSION - - SECRET_AUTHENTIK_ID_VERSION - initial-hooks: - - app set_authentik - shared_secrets: - nextcloud_secret: authentik_secret - nextcloud_id: authentik_id - onlyoffice: - uncomment: - - compose.onlyoffice.yml - - ONLYOFFICE_URL - - SECRET_ONLYOFFICE_JWT_VERSION - initial-hooks: - - app install_onlyoffice - collabora: - uncomment: - - COLLABORA_URL - initial-hooks: - - app install_collabora -onlyoffice: - nextcloud: - uncomment: - - compose.jwt.yml - - SECRET_JWT_SECRET_VERSION - shared_secrets: - onlyoffice_jwt: jwt_secret -outline: - authentik: - env: - OIDC_CLIENT_ID: outline - OIDC_AUTH_URI: https://authentik.example.com/application/o/authorize/ - OIDC_TOKEN_URI: https://authentik.example.com/application/o/token/ - OIDC_USERINFO_URI: https://authentik.example.com/application/o/userinfo/ - OIDC_DISPLAY_NAME: "Authentik" - uncomment: - - compose.oidc.yml - - OIDC_ENABLED - - OIDC_USERNAME_CLAIM - - OIDC_SCOPES - - SECRET_OIDC_CLIENT_SECRET_VERSION - shared_secrets: - outline_secret: oidc_client_secret -wordpress: - authentik: - uncomment: - - compose.authentik.yml - - AUTHENTIK_DOMAIN - - SECRET_AUTHENTIK_SECRET_VERSION - - SECRET_AUTHENTIK_ID_VERSION - - LOGIN_TYPE - initial-hooks: - - app set_authentik - shared_secrets: - wordpress_secret: authentik_secret - wordpress_id: authentik_id -vikunja: - authentik: - env: - OAUTH_NAME: authentik - OAUTH_URL: https://authentik.example.com/application/o/vikunja/ - OAUTH_LOGOUT_URL: https://authentik.example.com/application/o/vikunja/end-session/ - # TODO: set CLIENT_ID as secret - OAUTH_CLIENT_ID: vikunja - uncomment: - - compose.oauth.yml - - OAUTH_ENABLED - - SECRET_OAUTH_SECRET_VERSION - shared_secrets: - #vikunja_id: oauth_id - vikunja_secret: oauth_secret -matrix-synapse: - authentik: - env: - KEYCLOAK_ID: authentik - KEYCLOAK_NAME: sso - KEYCLOAK_URL: https://authentik.example.com/application/o/matrix/ - # TODO: correct client domain? - KEYCLOAK_CLIENT_DOMAIN: https://element-web.example.com - KEYCLOAK_ALLOW_EXISTING_USERS: "true" - # TODO: set CLIENT_ID as secret - KEYCLOAK_CLIENT_ID: matrix - uncomment: - - compose.keycloak.yml - - KEYCLOAK_ENABLED - - KEYCLOAK_CLIENT_ID - - SECRET_KEYCLOAK_CLIENT_SECRET_VERSION - shared_secrets: - matrix_secret: keycloak_client_secret - dependency: [authentik] -traefik: - matrix-synapse: - uncomment: - - compose.matrix.yml - - MATRIX_FEDERATION_ENABLED -rallly: - authentik: - env: - OIDC_NAME: "Authentik" - OIDC_DISCOVERY_URL: "https://authentik.example.com/application/o/rallly/.well-known/openid-configuration" - OIDC_ISSUER_URL: "https://authentik.example.com/application/o/rallly/" - OIDC_CLIENT_ID: rallly - uncomment: - - compose.oidc.yml - - SECRET_OIDC_CLIENT_SECRET_VERSION - - OIDC_ENABLED - shared_secrets: - rallly_secret: oidc_client_secret -wekan: - authentik: - env: - OAUTH2_ENABLED: "true" - OAUTH2_SERVER_URL: https://authentik.example.com - # TODO: set CLIENT_ID as secret - OAUTH2_CLIENT_ID: wekan - uncomment: - - OAUTH2_LOGIN_STYLE - - OAUTH2_AUTH_ENDPOINT - - OAUTH2_USERINFO_ENDPOINT - - OAUTH2_TOKEN_ENDPOINT - - OAUTH2_REQUEST_PERMISSIONS - - OAUTH2_ID_MAP - - OAUTH2_USERNAME_MAP - - OAUTH2_FULLNAME_MAP - - OAUTH2_EMAIL_MAP - - PROPAGATE_OIDC_DATA - - OIDC_REDIRECTION_ENABLED - shared_secrets: - wekan_secret: oauth2_secret -hedgedoc: - authentik: - env: - CMD_OAUTH2_USER_PROFILE_URL: https://authentik.example.com/application/o/userinfo/ - CMD_OAUTH2_USER_PROFILE_USERNAME_ATTR: preferred_username - CMD_OAUTH2_USER_PROFILE_DISPLAY_NAME_ATTR: name - CMD_OAUTH2_USER_PROFILE_EMAIL_ATTR: email - CMD_OAUTH2_TOKEN_URL: https://authentik.example.com/application/o/token/ - CMD_OAUTH2_AUTHORIZATION_URL: https://authentik.example.com/application/o/authorize/ - # TODO: set CLIENT_ID as secret - CMD_OAUTH2_CLIENT_ID: hedgedoc - CMD_OAUTH2_PROVIDERNAME: Authentik - uncomment: - - compose.oauth.yml - - SECRET_OAUTH_KEY_VERSION - shared_secrets: - hedgedoc_secret: oauth_key - dependency: [authentik] -mila: - authentik: - env: - OIDC_CLIENT_ID: mila - OIDC_BASE_URL: https://authentik.example.com/application/o/mila - OIDC_REDIRECT_URI: https://mila.example.com/auth/user/oidc/callback - OIDC_ADMIN_GROUP_NAME: mv_admin - OIDC_GROUPS_CLAIM: groups - OIDC_ONLY: "true" - uncomment: - - compose.oidc.yml - - 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] diff --git a/examples/config-sets-combine.yml b/examples/config-sets-combine.yml new file mode 100644 index 0000000..05e18dc --- /dev/null +++ b/examples/config-sets-combine.yml @@ -0,0 +1,395 @@ +# Integrations, converted from the combine.yml that alakazam used to ship. +# A set applies as soon as every app of its 'apply-when' is part of an instance. +# Copy this file into your alakazam root path, next to alaka-versions.yml. + +integrate-authentik-nextcloud: + apply-when: + - authentik + - nextcloud + authentik: + uncomment: + - compose.nextcloud.yml + - NEXTCLOUD_DOMAIN + - SECRET_NEXTCLOUD_ID_VERSION + - SECRET_NEXTCLOUD_SECRET_VERSION + - nextcloud.png + nextcloud: + uncomment: + - compose.authentik.yml + - AUTHENTIK_USER_PREFIX + - AUTHENTIK_DOMAIN + - SECRET_AUTHENTIK_SECRET_VERSION + - SECRET_AUTHENTIK_ID_VERSION + initial-hooks: + - app set_authentik + shared_secrets: + authentik: + nextcloud_secret: authentik_secret + nextcloud_id: authentik_id +integrate-authentik-wordpress: + apply-when: + - authentik + - wordpress + authentik: + uncomment: + - compose.wordpress.yml + - WORDPRESS_DOMAIN + - WORDPRESS_GROUP + - SECRET_WORDPRESS_ID_VERSION + - SECRET_WORDPRESS_SECRET_VERSION + - wordpress.png + wordpress: + uncomment: + - compose.authentik.yml + - AUTHENTIK_DOMAIN + - SECRET_AUTHENTIK_SECRET_VERSION + - SECRET_AUTHENTIK_ID_VERSION + - LOGIN_TYPE + initial-hooks: + - app set_authentik + shared_secrets: + authentik: + wordpress_secret: authentik_secret + wordpress_id: authentik_id +integrate-authentik-matrix-synapse: + apply-when: + - authentik + - matrix-synapse + authentik: + uncomment: + - compose.matrix.yml + - ELEMENT_DOMAIN + - MATRIX_DOMAIN + - SECRET_MATRIX_ID_VERSION + - SECRET_MATRIX_SECRET_VERSION + - matrix.svg + secrets: + matrix_id: matrix + matrix-synapse: + env: + KEYCLOAK_ID: authentik + KEYCLOAK_NAME: sso + KEYCLOAK_URL: https://authentik.example.com/application/o/matrix/ + # TODO: correct client domain? + KEYCLOAK_CLIENT_DOMAIN: https://element-web.example.com + KEYCLOAK_ALLOW_EXISTING_USERS: "true" + # TODO: set CLIENT_ID as secret + KEYCLOAK_CLIENT_ID: matrix + uncomment: + - compose.keycloak.yml + - KEYCLOAK_ENABLED + - KEYCLOAK_CLIENT_ID + - SECRET_KEYCLOAK_CLIENT_SECRET_VERSION + shared_secrets: + authentik: + matrix_secret: keycloak_client_secret + dependency: [authentik] +integrate-authentik-wekan: + apply-when: + - authentik + - wekan + authentik: + uncomment: + - compose.wekan.yml + - WEKAN_DOMAIN + - SECRET_WEKAN_ID_VERSION + - SECRET_WEKAN_SECRET_VERSION + - wekan.png + secrets: + wekan_id: wekan + wekan: + env: + OAUTH2_ENABLED: "true" + OAUTH2_SERVER_URL: https://authentik.example.com + # TODO: set CLIENT_ID as secret + OAUTH2_CLIENT_ID: wekan + uncomment: + - OAUTH2_LOGIN_STYLE + - OAUTH2_AUTH_ENDPOINT + - OAUTH2_USERINFO_ENDPOINT + - OAUTH2_TOKEN_ENDPOINT + - OAUTH2_REQUEST_PERMISSIONS + - OAUTH2_ID_MAP + - OAUTH2_USERNAME_MAP + - OAUTH2_FULLNAME_MAP + - OAUTH2_EMAIL_MAP + - PROPAGATE_OIDC_DATA + - OIDC_REDIRECTION_ENABLED + shared_secrets: + authentik: + wekan_secret: oauth2_secret +integrate-authentik-vikunja: + apply-when: + - authentik + - vikunja + authentik: + uncomment: + - compose.vikunja.yml + - VIKUNJA_DOMAIN + - SECRET_VIKUNJA_ID_VERSION + - SECRET_VIKUNJA_SECRET_VERSION + - vikunja.svg + secrets: + vikunja_id: vikunja + vikunja: + env: + OAUTH_NAME: authentik + OAUTH_URL: https://authentik.example.com/application/o/vikunja/ + OAUTH_LOGOUT_URL: https://authentik.example.com/application/o/vikunja/end-session/ + # TODO: set CLIENT_ID as secret + OAUTH_CLIENT_ID: vikunja + uncomment: + - compose.oauth.yml + - OAUTH_ENABLED + - SECRET_OAUTH_SECRET_VERSION + shared_secrets: + #vikunja_id: oauth_id + authentik: + vikunja_secret: oauth_secret +integrate-authentik-kimai: + apply-when: + - authentik + - kimai + authentik: + uncomment: + - compose.kimai.yml + - KIMAI_DOMAIN + - SECRET_KIMAI_ID_VERSION + - SECRET_KIMAI_SECRET_VERSION + - kimai_logo.png + - KIMAI_GROUP + kimai: + uncomment: + - SSO_ENABLED + - SSO_PROVIDER_URL + - SSO_SAML_URL + - SSO_LOGOUT_URL + - SSO_ADMIN_GROUP_NAME + secret_hooks: + - insert_authentik_certificate + dependency: [authentik] +integrate-authentik-zammad: + apply-when: + - authentik + - zammad + authentik: + uncomment: + - compose.zammad.yml + - ZAMMAD_DOMAIN + - zammad.svg + zammad: + uncomment: + - SSO_PROVIDER_DOMAIN + - IDP_SSO_TARGET_URL + - IDP_SLO_SERVICE_URL + initial-hooks: + - local enable_authentik_sso + dependency: [authentik] +integrate-authentik-monitoring-ng: + apply-when: + - authentik + - monitoring-ng + authentik: + uncomment: + - compose.monitoring.yml + - MONITORING_DOMAIN + - SECRET_MONITORING_ID_VERSION + - SECRET_MONITORING_SECRET_VERSION + - monitoring.svg + secrets: + monitoring_id: monitoring + monitoring-ng: + 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: + authentik: + monitoring_secret: grafana_oidc_client_secret + dependency: [authentik] +integrate-authentik-outline: + apply-when: + - authentik + - outline + authentik: + uncomment: + - compose.outline.yml + - OUTLINE_DOMAIN + - SECRET_OUTLINE_ID_VERSION + - SECRET_OUTLINE_SECRET_VERSION + - outline.png + secrets: + outline_id: outline + outline: + env: + OIDC_CLIENT_ID: outline + OIDC_AUTH_URI: https://authentik.example.com/application/o/authorize/ + OIDC_TOKEN_URI: https://authentik.example.com/application/o/token/ + OIDC_USERINFO_URI: https://authentik.example.com/application/o/userinfo/ + OIDC_DISPLAY_NAME: "Authentik" + uncomment: + - compose.oidc.yml + - OIDC_ENABLED + - OIDC_USERNAME_CLAIM + - OIDC_SCOPES + - SECRET_OIDC_CLIENT_SECRET_VERSION + shared_secrets: + authentik: + outline_secret: oidc_client_secret +integrate-authentik-rallly: + apply-when: + - authentik + - rallly + authentik: + uncomment: + - compose.rallly.yml + - RALLLY_DOMAIN + - SECRET_RALLLY_ID_VERSION + - SECRET_RALLLY_SECRET_VERSION + - rallly.png + secrets: + rallly_id: rallly + rallly: + env: + OIDC_NAME: "Authentik" + OIDC_DISCOVERY_URL: "https://authentik.example.com/application/o/rallly/.well-known/openid-configuration" + OIDC_ISSUER_URL: "https://authentik.example.com/application/o/rallly/" + OIDC_CLIENT_ID: rallly + uncomment: + - compose.oidc.yml + - SECRET_OIDC_CLIENT_SECRET_VERSION + - OIDC_ENABLED + shared_secrets: + authentik: + rallly_secret: oidc_client_secret +integrate-authentik-hedgedoc: + apply-when: + - authentik + - hedgedoc + authentik: + uncomment: + - compose.hedgedoc.yml + - HEDGEDOC_DOMAIN + - SECRET_HEDGEDOC_ID_VERSION + - SECRET_HEDGEDOC_SECRET_VERSION + - hedgedoc.png + secrets: + hedgedoc_id: hedgedoc + hedgedoc: + env: + CMD_OAUTH2_USER_PROFILE_URL: https://authentik.example.com/application/o/userinfo/ + CMD_OAUTH2_USER_PROFILE_USERNAME_ATTR: preferred_username + CMD_OAUTH2_USER_PROFILE_DISPLAY_NAME_ATTR: name + CMD_OAUTH2_USER_PROFILE_EMAIL_ATTR: email + CMD_OAUTH2_TOKEN_URL: https://authentik.example.com/application/o/token/ + CMD_OAUTH2_AUTHORIZATION_URL: https://authentik.example.com/application/o/authorize/ + # TODO: set CLIENT_ID as secret + CMD_OAUTH2_CLIENT_ID: hedgedoc + CMD_OAUTH2_PROVIDERNAME: Authentik + uncomment: + - compose.oauth.yml + - SECRET_OAUTH_KEY_VERSION + shared_secrets: + authentik: + hedgedoc_secret: oauth_key + dependency: [authentik] +integrate-authentik-pretix: + apply-when: + - authentik + - pretix + authentik: + env: + APPLICATIONS: + Pretix: + url: https://pretix.example.com/control/ + group: + EXTRA_ICONS: + Pretix: ~/.abra/recipes/authentik/icons/pretix.svg +integrate-authentik-vaultwarden: + apply-when: + - authentik + - vaultwarden + authentik: + env: + APPLICATIONS: + Vaultwarden: + url: https://vaultwarden.example.com/ + group: + EXTRA_ICONS: + Vaultwarden: ~/.abra/recipes/authentik/icons/vaultwarden.svg +integrate-authentik-mila: + apply-when: + - authentik + - mila + authentik: + uncomment: + - compose.mila.yml + - MILA_DOMAIN + - MILA_GROUP + - SECRET_MILA_ID_VERSION + - SECRET_MILA_SECRET_VERSION + - mila.svg + secrets: + mila_id: mila + mila: + env: + OIDC_CLIENT_ID: mila + OIDC_BASE_URL: https://authentik.example.com/application/o/mila + OIDC_REDIRECT_URI: https://mila.example.com/auth/user/oidc/callback + OIDC_ADMIN_GROUP_NAME: mv_admin + OIDC_GROUPS_CLAIM: groups + OIDC_ONLY: "true" + uncomment: + - compose.oidc.yml + - SECRET_OIDC_CLIENT_SECRET_VERSION + shared_secrets: + authentik: + mila_secret: oidc_client_secret +integrate-nextcloud-onlyoffice: + apply-when: + - nextcloud + - onlyoffice + nextcloud: + uncomment: + - compose.onlyoffice.yml + - ONLYOFFICE_URL + - SECRET_ONLYOFFICE_JWT_VERSION + initial-hooks: + - app install_onlyoffice + onlyoffice: + uncomment: + - compose.jwt.yml + - SECRET_JWT_SECRET_VERSION + shared_secrets: + nextcloud: + onlyoffice_jwt: jwt_secret +integrate-collabora-nextcloud: + apply-when: + - collabora + - nextcloud + nextcloud: + uncomment: + - COLLABORA_URL + initial-hooks: + - app install_collabora +integrate-matrix-synapse-traefik: + apply-when: + - matrix-synapse + - traefik + traefik: + uncomment: + - compose.matrix.yml + - MATRIX_FEDERATION_ENABLED diff --git a/tests/test_config_sets.py b/tests/test_config_sets.py new file mode 100644 index 0000000..2bd5f4c --- /dev/null +++ b/tests/test_config_sets.py @@ -0,0 +1,166 @@ +"""Tests for config-sets, in particular the ones that apply by themselves.""" + +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 get_config_set_app_configs, merge_instance_configs, read_config_sets + +INTEGRATION = { + "integrate-authentik-nextcloud": { + "apply-when": ["authentik", "nextcloud"], + "authentik": {"uncomment": ["compose.nextcloud.yml"]}, + "nextcloud": {"uncomment": ["compose.authentik.yml"], + "shared_secrets": {"authentik": {"nextcloud_secret": "authentik_secret"}}}, + } +} + + +def apply(config_sets, instance_apps, active=None): + """The effective configuration, with the automatic sets below the explicit ones.""" + automatic, explicit = get_config_set_app_configs(active or {}, config_sets, instance_apps) + return alakazam.merge_dict(automatic, explicit) + + +class TestApplyWhen: + def test_it_applies_when_every_app_is_there(self): + result = apply(INTEGRATION, ["authentik", "nextcloud", "traefik"]) + assert result["authentik"]["uncomment"] == ["compose.nextcloud.yml"] + assert result["nextcloud"]["shared_secrets"] == {"authentik": {"nextcloud_secret": "authentik_secret"}} + + def test_it_stays_out_when_one_app_is_missing(self): + assert apply(INTEGRATION, ["authentik", "traefik"]) == {} + + def test_apply_when_is_not_passed_on_as_an_app(self): + """It is a condition, not configuration, and would end up in an .env otherwise.""" + assert "apply-when" not in apply(INTEGRATION, ["authentik", "nextcloud"]) + + def test_an_instance_can_switch_it_off(self): + assert apply(INTEGRATION, ["authentik", "nextcloud"], + active={"integrate-authentik-nextcloud": False}) == {} + + def test_a_one_sided_integration_configures_only_one_app(self): + """4 of the 17 pairs in the shipped combine.yml were one-sided.""" + sets = {"integrate-matrix-synapse-traefik": { + "apply-when": ["matrix-synapse", "traefik"], + "traefik": {"uncomment": ["compose.matrix.yml"]}}} + assert list(apply(sets, ["matrix-synapse", "traefik"])) == ["traefik"] + assert apply(sets, ["traefik"]) == {} + + +class TestExplicitSets: + SET = {"bbb": {"authentik": {"env": {"A": "b"}}}} + + def test_an_enabled_set_applies(self): + assert apply(self.SET, ["authentik"], active={"bbb": True})["authentik"]["env"] == {"A": "b"} + + def test_a_set_that_is_not_enabled_does_not_apply(self): + assert apply(self.SET, ["authentik"]) == {} + + def test_an_unknown_name_is_reported(self, caplog): + with caplog.at_level(logging.WARNING): + apply(self.SET, ["authentik"], active={"typo": True}) + assert "'typo' is enabled but not defined" in caplog.text + + def test_an_explicit_set_refines_an_automatic_one(self): + """Automatic integrations are the base, an explicit set may still override them.""" + sets = dict(INTEGRATION) + sets["override"] = {"authentik": {"env": {"SOURCE": "explicit"}}} + result = apply(sets, ["authentik", "nextcloud"], active={"override": True}) + assert result["authentik"]["env"] == {"SOURCE": "explicit"} + assert result["authentik"]["uncomment"] == ["compose.nextcloud.yml"] + + +class TestReadConfigSets: + def write(self, root, name, text): + (root / name).write_text(text) + + def test_the_base_file_is_read(self, tmp_path): + self.write(tmp_path, "config-sets.yml", "bbb:\n authentik:\n env:\n A: b\n") + assert read_config_sets(tmp_path)["bbb"]["authentik"]["env"] == {"A": "b"} + + def test_further_files_are_merged(self, tmp_path): + self.write(tmp_path, "config-sets.yml", "bbb:\n authentik: {}\n") + self.write(tmp_path, "config-sets-authentik.yml", "integrate:\n apply-when: [a, b]\n") + assert sorted(read_config_sets(tmp_path)) == ["bbb", "integrate"] + + def test_the_base_file_is_merged_first(self, tmp_path): + self.write(tmp_path, "config-sets.yml", "bbb:\n authentik:\n env:\n A: base\n") + self.write(tmp_path, "config-sets-later.yml", "bbb:\n authentik:\n env:\n A: later\n") + assert read_config_sets(tmp_path)["bbb"]["authentik"]["env"]["A"] == "later" + + def test_a_root_without_config_sets(self, tmp_path): + assert read_config_sets(tmp_path) == {} + + def test_a_key_defined_twice_is_reported(self, tmp_path, caplog): + self.write(tmp_path, "config-sets.yml", "bbb:\n authentik:\n env:\n A: base\n") + self.write(tmp_path, "config-sets-later.yml", "bbb:\n authentik:\n env:\n A: later\n") + with caplog.at_level(logging.WARNING): + read_config_sets(tmp_path) + assert "bbb.authentik.env.A" in caplog.text + + +class TestIntegrationReachesTheMergedConfig: + """The instance config is what every command reads, the integration has to arrive there.""" + + def merge(self, apps): + return merge_instance_configs({}, "example.com", {app: None for app in apps}, INTEGRATION) + + def test_both_apps_get_their_side(self): + merged = self.merge(["authentik", "nextcloud"]) + assert merged["authentik"]["uncomment"] == ["compose.nextcloud.yml"] + assert merged["nextcloud"]["uncomment"] == ["compose.authentik.yml"] + + def test_a_lone_app_gets_nothing(self): + assert "uncomment" not in self.merge(["authentik"])["authentik"] + + +class TestPrecedence: + """An integration is the lowest layer, as combine.yml was before it.""" + + def test_the_group_configuration_beats_an_integration(self): + group = {"nextcloud": {"env": {"SOURCE": "group"}}} + sets = {"integrate": {"apply-when": ["authentik", "nextcloud"], + "nextcloud": {"env": {"SOURCE": "integration"}}}} + merged = merge_instance_configs(group, "example.com", {"authentik": None, "nextcloud": None}, sets) + assert merged["nextcloud"]["env"]["SOURCE"] == "group" + + def test_the_instance_beats_everything(self): + group = {"nextcloud": {"env": {"SOURCE": "group"}}} + sets = {"integrate": {"apply-when": ["authentik", "nextcloud"], + "nextcloud": {"env": {"SOURCE": "integration"}}}} + merged = merge_instance_configs( + group, "example.com", {"authentik": None, "nextcloud": {"env": {"SOURCE": "instance"}}}, sets) + assert merged["nextcloud"]["env"]["SOURCE"] == "instance" + + def test_an_integration_still_adds_what_nobody_else_sets(self): + group = {"nextcloud": {"env": {"OTHER": "group"}}} + sets = {"integrate": {"apply-when": ["authentik", "nextcloud"], + "nextcloud": {"env": {"SOURCE": "integration"}}}} + merged = merge_instance_configs(group, "example.com", {"authentik": None, "nextcloud": None}, sets) + assert merged["nextcloud"]["env"] == {"SOURCE": "integration", "OTHER": "group"} + + +class TestHookOrder: + """An integration adds to an app that already configures itself, and must come after it.""" + + SETS = {"integrate-authentik-wordpress": { + "apply-when": ["authentik", "wordpress"], + "wordpress": {"initial-hooks": ["app set_authentik"]}}} + + def test_the_integration_hook_runs_last(self): + """set_authentik configures SSO in a WordPress that core_install has to create first.""" + group = {"wordpress": {"initial-hooks": ["app core_install", "app enable_auto_updates"]}} + merged = merge_instance_configs(group, "example.com", {"authentik": None, "wordpress": None}, self.SETS) + assert merged["wordpress"]["initial-hooks"] == [ + "app core_install", "app enable_auto_updates", "app set_authentik"] + + def test_an_instance_hook_also_comes_first(self): + instance = {"authentik": None, "wordpress": {"initial-hooks": ["app from_instance"]}} + merged = merge_instance_configs({}, "example.com", instance, self.SETS) + assert merged["wordpress"]["initial-hooks"] == ["app from_instance", "app set_authentik"] diff --git a/tests/test_preflight.py b/tests/test_preflight.py index e9b8b10..268adad 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -109,9 +109,11 @@ class TestRelevantConfigPaths: assert config_root / "group" / "alaka-versions.yml" in paths assert config_root / "group" / "example.com.yml" in paths - def test_covers_the_connection_configuration(self, config_root): + def test_covers_every_config_set_file(self, config_root): + (config_root / "config-sets-authentik.yml").write_text("integrate:\n apply-when: [a]\n") paths = get_relevant_config_paths(config_root, config_root / "group", []) - assert alakazam.Path(alakazam.COMBINE_PATH) in paths + assert config_root / "config-sets.yml" in paths + assert config_root / "config-sets-authentik.yml" in paths def test_a_single_instance_file_pulls_in_its_ancestors(self, config_root): paths = get_relevant_config_paths(config_root, config_root / "group" / "example.com.yml", []) -- 2.54.0