Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e856bafa1e | ||
|
|
ecad7dd971 |
@@ -193,6 +193,14 @@ alakazam example.com.yml ps --wait --timeout 300
|
||||
alakazam -er traefik example.com.yml clean-deploy -n
|
||||
```
|
||||
|
||||
### Hiding Secrets
|
||||
|
||||
`secrets` and `setup` print the values they generate, which is right at a terminal and wrong in a pipeline whose logs are kept. `--hide-secrets` replaces them with `[hidden]`:
|
||||
|
||||
```
|
||||
alakazam --hide-secrets example.com.yml clean-deploy -n
|
||||
```
|
||||
|
||||
### Run CMDs
|
||||
|
||||
Escaping can be akward:
|
||||
@@ -269,6 +277,8 @@ For each app/recipe the following `<app_configurations>` can be used:
|
||||
- **`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`)
|
||||
- a commit can be pinned with `<release>@<commit>`, 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:
|
||||
|
||||
|
||||
+48
-5
@@ -493,9 +493,41 @@ def merge_instance_configs(group_config: Dict[str, Any], instance_domain: str, i
|
||||
if not merged_config[app].get('server'):
|
||||
merged_config[app]['server'] = server
|
||||
substitute_jinja_variable(merged_config, global_vars)
|
||||
# after the substitution, so that a templated version is resolved too
|
||||
for app, app_config in merged_config.items():
|
||||
if app_config.get('version'):
|
||||
app_config['version'] = resolve_version(app_config['version'], app)
|
||||
return merged_config
|
||||
|
||||
|
||||
def resolve_version(version: Any, app: str) -> Any:
|
||||
"""
|
||||
Reduces a configured version to the part that is deployed.
|
||||
|
||||
A version may name a commit behind an '@', as in '12.0.2+2026.5.2@be9ebb3'. What stands before
|
||||
it is the release the commit is based on: it carries no meaning for the deployment and exists
|
||||
so that a dependency bot can follow the recipe's tags and offer patch updates. Only the commit
|
||||
is deployed. Everything without an '@' is passed through untouched.
|
||||
|
||||
Args:
|
||||
version: The configured version, any type the configuration may hold
|
||||
app (str): The app the version belongs to, for the error message
|
||||
|
||||
Returns:
|
||||
The commit behind the '@', or the version unchanged
|
||||
|
||||
Raises:
|
||||
click.ClickException: If the '@' is there but no commit follows it
|
||||
"""
|
||||
if not isinstance(version, str) or "@" not in version:
|
||||
return version
|
||||
base, _, commit = version.rpartition("@")
|
||||
if not commit:
|
||||
raise click.ClickException(f"version '{version}' of {app} ends in '@' without a commit")
|
||||
logging.debug(f"{app}: deploying commit {commit}, based on {base}")
|
||||
return commit
|
||||
|
||||
|
||||
def map_subdomain(recipe: str, instance_domain: str, app_config: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Maps a subdomain for an app based on the recipe, instance domain, and specific app configuration.
|
||||
@@ -662,7 +694,7 @@ def run_streamed(command: List[str]) -> subprocess.CompletedProcess:
|
||||
return subprocess.CompletedProcess(command, process.returncode, b"".join(lines), b"")
|
||||
|
||||
|
||||
def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, stream: bool = False) -> Union[str,Dict]:
|
||||
def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, stream: bool = False, secret: bool = False) -> Union[str,Dict]:
|
||||
"""
|
||||
Execute the 'abra' command with the specified arguments. This function acts as a wrapper around the 'abra' CLI tool. It allows for capturing the output and optionally returning it as machine-readable JSON.
|
||||
|
||||
@@ -671,6 +703,7 @@ def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, s
|
||||
machine_output (bool): If True, expects the output in JSON format and parses it before returning.
|
||||
ignore_error (bool): If True, suppresses the raising of errors on non-zero return codes, otherwise an exception is raised.
|
||||
stream (bool): If True, echoes the output while the command runs instead of only returning it afterwards. Cannot be combined with machine_output, which needs clean JSON on stdout.
|
||||
secret (bool): If True, the output of this command may carry secret values, which HIDE_SECRETS then keeps out of the stream, the log and the error message.
|
||||
|
||||
Returns:
|
||||
str or dict: Returns the output from the 'abra' command. If machine_output is True, returns a dictionary, otherwise returns raw output as a string.
|
||||
@@ -687,7 +720,10 @@ def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, s
|
||||
command.append("-m")
|
||||
# the command line of 'app secret insert' ends in the secret itself, and the output of
|
||||
# 'app secret generate' contains the generated values
|
||||
quiet = HIDE_SECRETS and is_secret_command(args)
|
||||
quiet = HIDE_SECRETS and (secret or is_secret_command(args))
|
||||
if quiet:
|
||||
# echoing it live would put the very values that are being hidden into the output
|
||||
stream = False
|
||||
if not quiet:
|
||||
logging.debug(f"run command: {' '.join(command)}")
|
||||
for attempt in range(1, ABRA_RETRIES + 1):
|
||||
@@ -701,12 +737,15 @@ def abra(*args: str, machine_output: bool = False, ignore_error: bool = False, s
|
||||
delay = ABRA_RETRY_DELAY * 2 ** (attempt - 1)
|
||||
logging.warning(f"attempt {attempt}/{ABRA_RETRIES} of '{' '.join(command)}' failed to reach the server, retry in {delay}s")
|
||||
sleep(delay)
|
||||
if process.stderr and ignore_error:
|
||||
if process.stderr and ignore_error and not quiet:
|
||||
logging.warning(process.stderr.decode())
|
||||
if process.stdout and not stream and not quiet:
|
||||
logging.debug(process.stdout.decode())
|
||||
if process.returncode and not ignore_error:
|
||||
#breakpoint()
|
||||
if quiet:
|
||||
raise RuntimeError(
|
||||
f'{" ".join(args[:3])} failed, its output is withheld by --hide-secrets')
|
||||
raise RuntimeError(
|
||||
f'{" ".join(command)} \n STDOUT: \n {process.stdout.decode()} \n STDERR: {process.stderr.decode()}')
|
||||
if machine_output:
|
||||
@@ -1247,7 +1286,8 @@ def run_local_script(tokens: List[str], app_domain: str, server: str, instance_d
|
||||
return
|
||||
if dry_run:
|
||||
return
|
||||
result = subprocess.run([str(script_path)] + args, env=env)
|
||||
# the script may print a secret it just created, so its output is captured and dropped
|
||||
result = subprocess.run([str(script_path)] + args, env=env, capture_output=HIDE_SECRETS)
|
||||
if result.returncode != 0:
|
||||
message = f"Script '{cmd_display}' exited with code {result.returncode}"
|
||||
if strict:
|
||||
@@ -1259,6 +1299,9 @@ def run_secret_hooks(domain: str, app_config: Dict[str, Any], instance_domain: s
|
||||
"""
|
||||
Run local abra.sh commands or local scripts to generate secrets.
|
||||
|
||||
A secret hook may print the value it just created, which some recipes do on purpose because
|
||||
only a hash of it is stored. Under HIDE_SECRETS that output is withheld, failures included.
|
||||
|
||||
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.
|
||||
@@ -1277,7 +1320,7 @@ def run_secret_hooks(domain: str, app_config: Dict[str, Any], instance_domain: s
|
||||
else:
|
||||
print(f"Run '{cmd}' in {domain}", flush=True)
|
||||
try:
|
||||
abra("app", "cmd", "--local", domain, cmd, ignore_error=not strict, stream=True)
|
||||
abra("app", "cmd", "--local", domain, cmd, ignore_error=not strict, stream=True, secret=True)
|
||||
except RuntimeError as e:
|
||||
raise click.ClickException(f"secret hook '{cmd}' failed for {domain}: {e}")
|
||||
|
||||
|
||||
@@ -98,3 +98,89 @@ class TestDebugLog:
|
||||
insert_secret("login.a.org", "db_password", VALUE)
|
||||
assert "db_password" in caplog.text
|
||||
assert VALUE not in caplog.text
|
||||
|
||||
|
||||
class TestLocalHookOutput:
|
||||
"""A recipe may print the secret it created, vaultwarden's admin token does exactly that."""
|
||||
|
||||
TOKEN = "vaultwarden-admin-token-in-plain"
|
||||
|
||||
def install(self, monkeypatch, fail=False):
|
||||
seen = {}
|
||||
|
||||
class Process:
|
||||
returncode = 1 if fail else 0
|
||||
stdout = TestLocalHookOutput.TOKEN.encode()
|
||||
stderr = b""
|
||||
|
||||
def run(cmd, capture_output=False, **kwargs):
|
||||
seen["capture_output"] = capture_output
|
||||
return Process()
|
||||
|
||||
monkeypatch.setattr(alakazam.subprocess, "run", run)
|
||||
def streamed(cmd):
|
||||
seen["streamed"] = True
|
||||
return Process()
|
||||
|
||||
monkeypatch.setattr(alakazam, "run_streamed", streamed)
|
||||
return seen
|
||||
|
||||
def hook(self, strict=False):
|
||||
alakazam.run_secret_hooks("login.a.org", {"secret_hooks": ["insert_admin_token"], "server": "a.org"}, strict=strict)
|
||||
|
||||
def test_the_hook_is_streamed_when_not_hiding(self, monkeypatch):
|
||||
monkeypatch.setattr(alakazam, "HIDE_SECRETS", False)
|
||||
seen = self.install(monkeypatch)
|
||||
self.hook()
|
||||
assert seen.get("streamed")
|
||||
|
||||
def test_the_hook_is_not_streamed_when_hiding(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(alakazam, "HIDE_SECRETS", True)
|
||||
seen = self.install(monkeypatch)
|
||||
self.hook()
|
||||
assert not seen.get("streamed")
|
||||
assert self.TOKEN not in capsys.readouterr().out
|
||||
|
||||
def test_a_failing_hook_does_not_leak_through_the_error(self, monkeypatch):
|
||||
"""The captured output lands in the exception, which is where it would escape."""
|
||||
monkeypatch.setattr(alakazam, "HIDE_SECRETS", True)
|
||||
self.install(monkeypatch, fail=True)
|
||||
with pytest.raises(alakazam.click.ClickException) as excinfo:
|
||||
self.hook(strict=True)
|
||||
assert self.TOKEN not in excinfo.value.message
|
||||
assert "withheld by --hide-secrets" in excinfo.value.message
|
||||
|
||||
def test_a_failing_hook_still_reports_its_output_when_not_hiding(self, monkeypatch):
|
||||
monkeypatch.setattr(alakazam, "HIDE_SECRETS", False)
|
||||
self.install(monkeypatch, fail=True)
|
||||
with pytest.raises(alakazam.click.ClickException) as excinfo:
|
||||
self.hook(strict=True)
|
||||
assert self.TOKEN in excinfo.value.message
|
||||
|
||||
|
||||
class TestLocalScriptOutput:
|
||||
def install(self, monkeypatch, tmp_path):
|
||||
script = tmp_path / "hook.sh"
|
||||
script.write_text("#!/bin/sh\necho secret\n")
|
||||
script.chmod(0o755)
|
||||
seen = {}
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
|
||||
monkeypatch.setattr(alakazam, "resolve_path", lambda p, base=None: script)
|
||||
monkeypatch.setattr(alakazam.subprocess, "run",
|
||||
lambda cmd, **kw: seen.update(kw) or Result())
|
||||
return seen
|
||||
|
||||
def test_the_script_output_is_captured_when_hiding(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(alakazam, "HIDE_SECRETS", True)
|
||||
seen = self.install(monkeypatch, tmp_path)
|
||||
alakazam.run_local_script(["script", "hook.sh"], "login.a.org", "a.org", "a.org")
|
||||
assert seen["capture_output"] is True
|
||||
|
||||
def test_the_script_output_is_passed_through_when_not_hiding(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(alakazam, "HIDE_SECRETS", False)
|
||||
seen = self.install(monkeypatch, tmp_path)
|
||||
alakazam.run_local_script(["script", "hook.sh"], "login.a.org", "a.org", "a.org")
|
||||
assert seen["capture_output"] is False
|
||||
|
||||
@@ -53,7 +53,7 @@ class TestRunLocalScript:
|
||||
script = make_script(tmp_path)
|
||||
calls = []
|
||||
monkeypatch.setattr(alakazam.subprocess, "run",
|
||||
lambda cmd, env: calls.append((cmd, env)) or FakeProcess(0))
|
||||
lambda cmd, env, **kwargs: calls.append((cmd, env)) or FakeProcess(0))
|
||||
run_local_script(["script", str(script), "arg1"], "login.a.org", "a.org", "a.org")
|
||||
[(cmd, env)] = calls
|
||||
assert cmd == [str(script), "arg1"]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for a version that pins a commit while naming the release it is based on."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import alakazam
|
||||
from alakazam import classify_version, merge_instance_configs, resolve_version
|
||||
|
||||
|
||||
class TestResolveVersion:
|
||||
def test_a_pinned_commit_wins_over_the_release(self):
|
||||
assert resolve_version("12.0.2+2026.5.2@be9ebb3", "nextcloud") == "be9ebb3"
|
||||
|
||||
@pytest.mark.parametrize("version", ["1.2.3", "12.0.2+2026.5.2", "chaos", "latest", "be9ebb3"])
|
||||
def test_a_version_without_a_commit_is_untouched(self, version):
|
||||
assert resolve_version(version, "nextcloud") == version
|
||||
|
||||
@pytest.mark.parametrize("version", [None, 1.2, True])
|
||||
def test_non_strings_are_untouched(self, version):
|
||||
"""The configuration is user supplied and does not have to hold a string."""
|
||||
assert resolve_version(version, "nextcloud") is version
|
||||
|
||||
def test_a_trailing_at_is_rejected(self):
|
||||
"""Silently deploying the release instead of the intended commit would be worse."""
|
||||
with pytest.raises(click.ClickException) as excinfo:
|
||||
resolve_version("12.0.2@", "nextcloud")
|
||||
assert "without a commit" in excinfo.value.message
|
||||
assert "nextcloud" in excinfo.value.message
|
||||
|
||||
def test_the_release_is_kept_in_the_debug_log(self, caplog):
|
||||
"""It is the only place the base release survives, and CI failures are read there."""
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
resolve_version("12.0.2+2026.5.2@be9ebb3", "nextcloud")
|
||||
assert "based on 12.0.2+2026.5.2" in caplog.text
|
||||
|
||||
def test_the_last_at_separates(self):
|
||||
assert resolve_version("a@b@be9ebb3", "nextcloud") == "be9ebb3"
|
||||
|
||||
|
||||
class TestClassification:
|
||||
def test_the_resolved_commit_classifies_as_a_hash(self):
|
||||
"""upgrade branches on this, a release would take the wrong abra command."""
|
||||
assert classify_version(resolve_version("12.0.2+2026.5.2@be9ebb3", "nextcloud")) == "hash"
|
||||
|
||||
def test_an_unpinned_release_still_classifies_as_a_version(self):
|
||||
assert classify_version(resolve_version("12.0.2+2026.5.2", "nextcloud")) == "version"
|
||||
|
||||
|
||||
class TestMergedConfig:
|
||||
"""The resolution has to happen once, where the configuration is built."""
|
||||
|
||||
def merge(self, version):
|
||||
return merge_instance_configs(
|
||||
{}, "example.com", {"nextcloud": {"version": version}}, {})
|
||||
|
||||
def test_the_merged_config_carries_the_commit(self):
|
||||
assert self.merge("12.0.2+2026.5.2@be9ebb3")["nextcloud"]["version"] == "be9ebb3"
|
||||
|
||||
def test_a_plain_version_survives_the_merge(self):
|
||||
assert self.merge("12.0.2")["nextcloud"]["version"] == "12.0.2"
|
||||
|
||||
def test_an_app_without_a_version(self):
|
||||
assert "version" not in self.merge(None)["nextcloud"] or \
|
||||
self.merge(None)["nextcloud"]["version"] is None
|
||||
|
||||
def test_a_templated_version_is_resolved_after_substitution(self):
|
||||
"""Jinja runs first, so a version coming from GLOBALS is pinned as well."""
|
||||
merged = merge_instance_configs(
|
||||
{"GLOBALS": {"pin": "12.0.2+2026.5.2@be9ebb3"}},
|
||||
"example.com",
|
||||
{"nextcloud": {"version": "{{pin}}"}},
|
||||
{})
|
||||
assert merged["nextcloud"]["version"] == "be9ebb3"
|
||||
Reference in New Issue
Block a user