feat: add local script support to hooks #9
22
README.md
22
README.md
@ -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.
|
||||
|
||||
119
alakazam.py
119
alakazam.py
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user