feat: add local script support to hooks #9

Open
dannygroenewegen wants to merge 1 commits from eCommons/alakazam:local-scripts into main
2 changed files with 106 additions and 35 deletions

View File

@ -154,12 +154,12 @@ For each app/recipe the following `<app_configurations>` can be used:
- it matches against parts of the line (i.E. `compose.smtp.yml`)
- this is useful for env variables that are used multiple times like `COMPOSE_FILE`
- **`env`**: Sets values for environment variables.
- **`*-hooks`**: Specifies `abra.sh` commands to run at specific stages.
- **`*-hooks`**: Specifies `abra.sh` commands or local scripts to run at specific stages.
- **`initial-hooks`**: commands for initialisation
- **`deploy-hooks`**: commands that should be run after each deployment
- **`upgrade-hooks`**: commands that should be run after each upgrade
- **`secrets`**: Inserts specific values (i.E. smtp passwords) into secrets; future updates will support encrypted file usage.
- **`secret-hooks`**: Run `abra.sh` commands locally for secrets that need to be generated.
- **`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`)
- 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`)
@ -169,6 +169,24 @@ The `combine.yml`/`alaconnect.yml` configuration additionally contains:
- **`shared_secrets`**: Specifies secret sharing between apps.
- `<source_secret_name>:<target_secret_name>`
#### \*-Hooks Command Formats
**Abra command** — runs an abra.sh command inside a container:
```yaml
initial-hooks:
- app set_default_quota
```
**Local script** — runs a script on the local machine (for custom actions that aren't generic enough to be implemented as abra.sh commands):
```yaml
initial-hooks:
- script ./scripts/script.sh arg1
```
Relative paths resolve from the `root` path. The script receives `ALAKAZAM_APP_DOMAIN`, `ALAKAZAM_APP_SERVER`, and `ALAKAZAM_INSTANCE_DOMAIN` as environment variables.
### Configuration Structure
Configuration can be simplified into a single `example.com.yml` or expanded into multiple layered `alaka.yml`/`alaka-*.yml` files for complex deployments. This allows for easy maintenance of multiple instances or groups.

View File

@ -57,6 +57,7 @@ ALL_CONFIGS = {}
SETTINGS = {}
SETTINGS_PATH = "" # path to the alakazam settings file
ABRA_DIR = None # path to the abra data directory
ROOT_PATH = None # resolved root path from alakazam.yml
class MySafeConstructor(SafeConstructor):
@ -510,6 +511,23 @@ def generate_all_secrets(domain: str) -> None:
print(f"\t {gen_sec['name']}: {gen_sec['value']}")
def resolve_path(path_str: str, base: Optional[Path] = None) -> Path:
"""
Resolve a path string to an absolute Path, expanding ~ and resolving relative paths against a base directory.
Args:
path_str (str): The path string to resolve. May be absolute, relative, or start with ~.
base (Path): The base directory for resolving relative paths. Defaults to ROOT_PATH if not provided.
Returns:
Path: The resolved absolute path.
"""
p = Path(path_str).expanduser()
if p.is_absolute():
return p
return ((base or ROOT_PATH) / p).absolute()
def get_abra_dir() -> Path:
"""
Resolve the abra directory path.
@ -532,8 +550,7 @@ def get_abra_dir() -> Path:
if config_file.exists():
abra_config = read_config(str(config_file))
if abra_dir := abra_config.get("abraDir"):
p = Path(abra_dir)
return (current / p).resolve() if not p.is_absolute() else p
return resolve_path(abra_dir, current)
if current == home:
break
current = current.parent
@ -798,26 +815,57 @@ def insert_secrets_from_conf(domain: str, app_config: Dict[str, Any]) -> None:
for secret_name, secret in secrets.items():
try:
secret_is_stored = stored_secrets[secret_name]
except keyerror:
except KeyError:
logging.error(f"{domain} does not contain secret {secret_name}")
continue
if not secret_is_stored:
insert_secret(domain, secret_name, secret)
def run_secret_hooks(domain: str, app_config: Dict[str, Any]) -> None:
def run_local_script(tokens: List[str], app_domain: str, server: str, instance_domain: str, dry_run: bool = False) -> None:
"""
Run a local script hook. Relative paths are resolved against ROOT_PATH for execution. Logs an error and returns early if the script is not executable.
Args:
tokens (list): The hook entry split on whitespace, with tokens[0] confirmed to be 'script', tokens[1] the script path, and tokens[2:] positional arguments.
app_domain (str): The app domain, passed as ALAKAZAM_APP_DOMAIN to the script environment.
server (str): The server name, passed as ALAKAZAM_APP_SERVER to the script environment.
instance_domain (str): The instance domain, passed as ALAKAZAM_INSTANCE_DOMAIN to the script environment.
dry_run (bool): If True, prints the command but does not execute it.
"""
script_path = resolve_path(tokens[1])
args = tokens[2:]
env = {**os.environ, "ALAKAZAM_APP_DOMAIN": app_domain, "ALAKAZAM_APP_SERVER": server, "ALAKAZAM_INSTANCE_DOMAIN": instance_domain}
cmd_display = " ".join(tokens[1:])
print(f"Run local script '{cmd_display}' for {app_domain}")
if not os.access(script_path, os.X_OK):
logging.error(f"Script is not executable (run: chmod +x {script_path})")
return
if dry_run:
return
result = subprocess.run([str(script_path)] + args, env=env)
if result.returncode != 0:
logging.warning(f"Script '{cmd_display}' exited with code {result.returncode}")
def run_secret_hooks(domain: str, app_config: Dict[str, Any], instance_domain: str = "") -> None:
"""
Run local abra.sh commands to generate secrets.
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.
instance_domain (str): The instance domain, used to set ALAKAZAM_INSTANCE_DOMAIN for script hooks.
"""
logging.info(f"Run secret hooks for {domain}")
if secret_hooks := app_config.get("secret_hooks"):
for cmd in secret_hooks:
print(f"Run '{cmd}' in {domain}")
print(abra("app", "cmd", "--local", domain, cmd, ignore_error=True))
tokens = cmd.split()
if tokens[0] == "script" and len(tokens) >= 2 and resolve_path(tokens[1]).exists():
run_local_script(tokens, domain, app_config['server'], instance_domain)
else:
print(f"Run '{cmd}' in {domain}")
print(abra("app", "cmd", "--local", domain, cmd, ignore_error=True))
def unquote_strings(s: str) -> str:
@ -958,7 +1006,7 @@ def replace_domains(path: Path, old_domain: str, new_domain: str) -> None:
file.write(content)
def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), initial: bool = False, deploy: bool = False, upgrade: bool = False, dry_run: bool = False, chaos: bool = False) -> None:
def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), initial: bool = False, deploy: bool = False, upgrade: bool = False, dry_run: bool = False, chaos: bool = False, instance_domain: str = "") -> None:
"""
Execute post-deployment commands for an application based on the provided configuration.
This can include running scripts or commands inside the application's environment.
@ -970,12 +1018,14 @@ def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), ini
initial (bool): execute initial-hooks
deploy (bool): execute deploy-hooks
upgrade (bool): execute upgrade-hooks
dry-run(bool): only show cmds, don't execute them
dry_run (bool): only show cmds, don't execute them
instance_domain (str): The instance domain, used to set ALAKAZAM_INSTANCE_DOMAIN for script hooks.
Returns:
None
"""
domain = app_config['app_domain']
server = app_config['server']
all_cmds = []
if initial and (initial_hooks:= app_config.get('initial-hooks')):
all_cmds = all_cmds + initial_hooks
@ -989,15 +1039,19 @@ def execute_cmds(app_config: Dict[str, Any], commands: Tuple[str] = tuple(), ini
if chaos:
chaos_flag = "-C"
for cmd in all_cmds:
container = cmd.split()[0]
cmd = ['--'] + cmd.split()[1:]
print(f"Run '{cmd}' in {domain}:{container}")
if dry_run:
continue
if container == "local":
print(abra("app", "cmd", "--local", chaos_flag, domain, *cmd, ignore_error=True))
tokens = cmd.split()
container = tokens[0]
rest = ['--'] + tokens[1:]
if container == "script" and len(tokens) >= 2 and resolve_path(tokens[1]).exists():
run_local_script(tokens, domain, server, instance_domain, dry_run)
else:
print(abra("app", "cmd", "-T", chaos_flag, domain, container, *cmd, ignore_error=True))
print(f"Run '{rest}' in {domain}:{container}")
if dry_run:
continue
if container == "local":
print(abra("app", "cmd", "--local", chaos_flag, domain, *rest, ignore_error=True))
else:
print(abra("app", "cmd", "-T", chaos_flag, domain, container, *rest, ignore_error=True))
@click.group(context_settings={"help_option_names": ['-h', '--help']})
@ -1015,6 +1069,7 @@ def cli(loglevel: str, group_path: str, exclude:Tuple[str]) -> None:
global SETTINGS
global SETTINGS_PATH
global ABRA_DIR
global ROOT_PATH
SETTINGS_PATH = get_settings_path()
SETTINGS = read_config(SETTINGS_PATH)
ABRA_DIR = get_abra_dir()
@ -1023,20 +1078,18 @@ def cli(loglevel: str, group_path: str, exclude:Tuple[str]) -> None:
root_path = os.getcwd()
logging.warning(f"There is no 'root' path defined in '{SETTINGS_PATH}', use current path '{root_path}'instead")
_group_path = Path(group_path).expanduser().absolute()
_root_path = Path(root_path).expanduser()
if not _root_path.is_absolute():
_root_path = (settings_dir / _root_path).absolute()
if not str(_group_path).startswith(str(_root_path)):
logging.error(f"{_root_path} does not contain {_group_path}?")
ROOT_PATH = resolve_path(root_path, settings_dir)
if not str(_group_path).startswith(str(ROOT_PATH)):
logging.error(f"{ROOT_PATH} does not contain {_group_path}?")
exit(1)
all_group_configs = merge_all_group_configs(_root_path)
config_sets = read_config(str(_root_path / "config-sets.yml"))
all_group_configs = merge_all_group_configs(ROOT_PATH)
config_sets = read_config(str(ROOT_PATH / "config-sets.yml"))
exclude_paths = list(map(lambda p: str(Path(p).absolute()), exclude))
if ABRA_DIR.is_relative_to(_root_path) and str(ABRA_DIR) not in exclude_paths:
if ABRA_DIR.is_relative_to(ROOT_PATH) and str(ABRA_DIR) not in exclude_paths:
exclude_paths.append(str(ABRA_DIR))
instance_configs = get_merged_instance_configs(_group_path, all_group_configs, exclude_paths, config_sets)
INSTANCE_CONFIGS = merge_connection_configs(instance_configs)
all_configs = get_merged_instance_configs(_root_path, all_group_configs, exclude_paths, config_sets)
all_configs = get_merged_instance_configs(ROOT_PATH, all_group_configs, exclude_paths, config_sets)
ALL_CONFIGS = merge_connection_configs(all_configs)
if loglevel:
numeric_level = getattr(logging, loglevel.upper(), None)
@ -1097,7 +1150,7 @@ def secrets(recipes: Tuple[str], syncvalues: bool) -> None:
Generates and inserts secrets for specified apps.
This function handles the generation of new secrets, the syncronisation of shared secrets and the insertion of existing secrets from the configuration into the appropriate locations for each application.
"""
for _, instance_config in INSTANCE_CONFIGS.items():
for instance_domain, instance_config in INSTANCE_CONFIGS.items():
instance_apps = instance_config.keys()
if recipes:
selected_apps = [app for app in recipes if app in instance_config.keys()]
@ -1110,7 +1163,7 @@ def secrets(recipes: Tuple[str], syncvalues: bool) -> None:
domain = app_config['app_domain']
print(f"Create secrets for {domain}")
insert_secrets_from_conf(domain, app_config)
run_secret_hooks(domain, app_config)
run_secret_hooks(domain, app_config, instance_domain)
# Pass 2: exchange secrets between apps, then generate any remaining missing secrets.
for app in selected_apps:
app_config = instance_config[app]
@ -1185,7 +1238,7 @@ def deploy(recipes: Tuple[str], execute_hooks: bool, force: bool, converge_check
print(abra("app", *cmd))
if execute_hooks:
logging.info(f'execute commands for {domain}')
execute_cmds(app_config, deploy=True)
execute_cmds(app_config, deploy=True, instance_domain=instance)
@cli.command()
@ -1230,7 +1283,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))
upgrade_cmds.append((app_config, upgrade_cmd, instance))
release_note_cmd = upgrade_cmd.copy()
release_note_cmd.insert(1, '-r')
release_note = abra("app", *release_note_cmd, ignore_error=True)
@ -1244,7 +1297,7 @@ 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 in upgrade_cmds:
for app_config, upgrade_cmd, instance_domain in upgrade_cmds:
app_domain = app_config.get('app_domain')
if redeploy:
upgrade_cmd.pop(0)
@ -1256,7 +1309,7 @@ def upgrade(recipes: Tuple[str], execute_hooks: bool, dry_run: bool, redeploy: b
print(abra("app", *upgrade_cmd))
if execute_hooks:
logging.info(f'execute commands for {app_domain}')
execute_cmds(app_config, upgrade=True)
execute_cmds(app_config, upgrade=True, instance_domain=instance_domain)
@cli.command()
@ -1299,7 +1352,7 @@ def cmd(recipes: Tuple[str], commands: Tuple[str], initial: bool, deploy: bool,
Execute commands for all specified applications based on the provided configuration.
"""
deployed_domains = get_deployed_apps(recipes)
for _, instance_config in INSTANCE_CONFIGS.items():
for instance_domain, instance_config in INSTANCE_CONFIGS.items():
if recipes:
selected_apps = [app for app in recipes if app in instance_config.keys()]
else:
@ -1311,7 +1364,7 @@ def cmd(recipes: Tuple[str], commands: Tuple[str], initial: bool, deploy: bool,
print(f"{domain} is not deployed")
continue
logging.info(f'execute commands for {domain}')
execute_cmds(app_config, commands, initial, deploy, upgrade, list_cmds, chaos)
execute_cmds(app_config, commands, initial, deploy, upgrade, list_cmds, chaos, instance_domain)
@cli.command()