feat: add --syncvalues flag and improve secret sharing #5
+132
-14
@@ -471,7 +471,7 @@ def get_env_path(server: str, domain: str) -> Path:
|
||||
return Path(f"~/.abra/servers/{server}/{domain}.env").expanduser()
|
||||
|
||||
|
||||
def exchange_secrets(app1: str, instance_config: Dict[str, Any], apps: Tuple[str]) -> None:
|
||||
def exchange_secrets(app1: str, instance_config: Dict[str, Any], apps: Tuple[str], syncvalues: bool = False) -> None:
|
||||
"""
|
||||
Facilitates the exchange of shared secrets between apps within the same instance based on the configuration.
|
||||
This function checks for shared secrets configurations and applies them to ensure that secrets are synchronized between apps.
|
||||
@@ -480,6 +480,7 @@ def exchange_secrets(app1: str, instance_config: Dict[str, Any], apps: Tuple[str
|
||||
app1 (str): The first app to participate in the secret exchange.
|
||||
instance_config (dict): Configuration of the instance including all apps.
|
||||
apps (list): List of other apps in the instance to potentially share secrets with.
|
||||
syncvalues (bool): When True, update target secrets whose values differ from the source.
|
||||
|
||||
Returns:
|
||||
None
|
||||
@@ -490,25 +491,29 @@ def exchange_secrets(app1: str, instance_config: Dict[str, Any], apps: Tuple[str
|
||||
app2_config = instance_config[app2]
|
||||
app2_domain = app2_config['app_domain']
|
||||
if app1_shared_secrets := get_value(app1_config, "shared_secrets", app2):
|
||||
logging.info(f'share secrets between {app1_domain} and {app2_domain}')
|
||||
share_secrets(app1_domain, app2_domain, app1_shared_secrets)
|
||||
logging.info(f'share secrets from {app1_domain} to {app2_domain}')
|
||||
share_secrets(app1_domain, app2_domain, app1_shared_secrets, syncvalues=syncvalues)
|
||||
if app2_shared_secrets := get_value(app2_config, "shared_secrets", app1):
|
||||
logging.info(f'share secrets between {app1_domain} and {app2_domain}')
|
||||
share_secrets(app2_domain, app1_domain, app2_shared_secrets)
|
||||
logging.info(f'share secrets from {app2_domain} to {app1_domain}')
|
||||
share_secrets(app2_domain, app1_domain, app2_shared_secrets, syncvalues=syncvalues)
|
||||
|
||||
|
||||
def str2bool(value: str) -> bool:
|
||||
return value.lower() in ("yes", "true", "t", "1")
|
||||
|
||||
def share_secrets(app1_domain: str, app2_domain: str, secrets: Dict[str, str]) -> None:
|
||||
def share_secrets(app1_domain: str, app2_domain: str, secrets: Dict[str, str], syncvalues: bool = False) -> None:
|
||||
"""
|
||||
Facilitates the sharing of secrets between two applications.
|
||||
This function checks and transfers secrets from one applications to another if one applications possesses a secret that the other lacks, ensuring both applications maintain synchronized secret configurations.
|
||||
app1 is treated as the source; app2 is the target.
|
||||
This function checks and transfers secrets from app1 to app2 if app2 lacks the secret,
|
||||
or compares values when both have the secret (with --syncvalues to update the target).
|
||||
|
||||
Args:
|
||||
app1_domain (str): The domain of the first application.
|
||||
app2_domain (str): The domain of the second application.
|
||||
app1_domain (str): The domain of the source application.
|
||||
app2_domain (str): The domain of the target application.
|
||||
secrets (dict): A dictionary mapping secret names in app2 to their corresponding names in app1.
|
||||
syncvalues (bool): When True, update app2's secret if its value differs from app1's.
|
||||
When False, only log a warning on mismatch.
|
||||
"""
|
||||
app1_stored_secrets = abra("app", "secret", "ls", app1_domain, machine_output=True)
|
||||
app1_stored_secrets = {x['name']: str2bool(x['created on server']) for x in app1_stored_secrets}
|
||||
@@ -528,7 +533,6 @@ def share_secrets(app1_domain: str, app2_domain: str, secrets: Dict[str, str]) -
|
||||
app2_container = []
|
||||
for app2_secret in secrets:
|
||||
app1_secret = secrets[app2_secret]
|
||||
# TODO: test if both apps have the secret available
|
||||
try:
|
||||
app1_secret_is_stored = app1_stored_secrets[app1_secret]
|
||||
except KeyError:
|
||||
@@ -540,21 +544,62 @@ def share_secrets(app1_domain: str, app2_domain: str, secrets: Dict[str, str]) -
|
||||
logging.error(f"{app2_domain} does not contain secret {app2_secret}")
|
||||
continue
|
||||
if app1_secret_is_stored and not app2_secret_is_stored:
|
||||
# Source has the secret, target doesn't
|
||||
if not app1_container:
|
||||
logging.error(f"{app1_domain} already contains the secrets {app1_secret} but it's not running, so it's not possible to export the secret.")
|
||||
continue
|
||||
secret = get_secret(app1_domain, app1_secret, app1_container)
|
||||
insert_secret(app2_domain, app2_secret, secret)
|
||||
logging.info(f"Shared secret {app1_secret} from {app1_domain} to {app2_domain} as {app2_secret}")
|
||||
elif app2_secret_is_stored and not app1_secret_is_stored:
|
||||
# Target has secret but source doesn't, copy back.
|
||||
if not app2_container:
|
||||
logging.error(f"{app2_domain} already contains the secrets {app2_secret} but it's not running, so it's not possible to export the secret.")
|
||||
continue
|
||||
secret = get_secret(app2_domain, app2_secret, app2_container)
|
||||
insert_secret(app1_domain, app1_secret, secret)
|
||||
logging.info(f"Shared secret {app2_secret} from {app2_domain} to {app1_domain} as {app1_secret}")
|
||||
elif not any([app1_secret_is_stored, app2_secret_is_stored]):
|
||||
# Neither has it — generate new and insert to both
|
||||
secret = generate_secret(app1_domain, app1_secret)
|
||||
insert_secret(app2_domain, app2_secret, secret)
|
||||
logging.info(f"Generated secrets exachanged secret {app1_secret} and {app2_secret} in {app1_domain} and {app2_domain}")
|
||||
logging.info(f"Generated secret {app1_secret} in {app1_domain} and exchanged to secret {app2_secret} in {app2_domain}")
|
||||
else:
|
||||
# Both apps have the secret — compare values (app1 is source, app2 is target)
|
||||
if not app1_container:
|
||||
logging.warning(
|
||||
f"{app1_domain} has {app1_secret} but is not running; cannot compare values"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
app1_value = get_secret(app1_domain, app1_secret, app1_container)
|
||||
|
|
||||
app2_value = get_secret(app2_domain, app2_secret, app2_container) if app2_container else None
|
||||
except RuntimeError as e:
|
||||
logging.warning(f"Could not read secrets for comparison ({app1_domain}/{app2_domain}): {e}")
|
||||
continue
|
||||
if app2_value is None or app1_value != app2_value:
|
||||
if syncvalues:
|
||||
if not app2_container:
|
||||
logging.info(f"{app2_domain} is not running. Secret value could be correct already, but syncing anyway.")
|
||||
logging.info(
|
||||
f"Syncing {app1_secret} from {app1_domain} to {app2_secret} in {app2_domain}"
|
||||
)
|
||||
update_secret(app2_domain, app2_secret, app1_value, bool(app2_container))
|
||||
else:
|
||||
if not app2_container:
|
||||
logging.warning(f"{app2_domain} is not running. {app1_domain}/{app1_secret} could differ from {app2_domain}/{app2_secret}, but couldn't confirm.")
|
||||
else:
|
||||
logging.warning(
|
||||
f"Secret mismatch: {app1_domain}/{app1_secret} differs from "
|
||||
f"{app2_domain}/{app2_secret}. "
|
||||
)
|
||||
logging.warning(f"Run 'alakazam GROUP_PATH secrets --syncvalues' to synchronize.")
|
||||
|
||||
else:
|
||||
|
dannygroenewegen marked this conversation as resolved
Outdated
moritz
commented
Maybe it should be stated somewhere, that the secret of app2 will be overwritten, not only if it differs from the secret of app1, but also if the container is not running. The result will be the same, but for clarity. Maybe it should be stated somewhere, that the secret of app2 will be overwritten, not only if it differs from the secret of app1, but also if the container is not running. The result will be the same, but for clarity.
|
||||
logging.info(
|
||||
f"Secrets {app1_secret}/{app2_secret} already in sync "
|
||||
f"between {app1_domain} and {app2_domain}"
|
||||
)
|
||||
|
||||
|
||||
def get_secret(domain: str, secret_name: str, containers: List) -> str:
|
||||
@@ -575,7 +620,57 @@ def get_secret(domain: str, secret_name: str, containers: List) -> str:
|
||||
return str(secret)
|
||||
except RuntimeError:
|
||||
continue
|
||||
raise RuntimeError(f"{secret_name} not found for {domain}")
|
||||
|
||||
logging.info(f"{secret_name} not found for {domain} in a running container."
|
||||
f"Trying to read from host filesystem via SSH for distroless containers")
|
||||
return get_secret_from_host(domain, secret_name)
|
||||
|
||||
|
||||
def get_secret_from_host(domain: str, secret_name: str) -> str:
|
||||
"""
|
||||
Read a Docker Swarm secret value by SSHing to the host and reading from the
|
||||
container's mount path. Works for distroless containers that have no shell.
|
||||
"""
|
||||
host = None
|
||||
for server, domains in get_server_apps().items():
|
||||
if domain in domains:
|
||||
host = server
|
||||
break
|
||||
if not host:
|
||||
raise RuntimeError(f"Could not determine server for {domain}")
|
||||
|
||||
# Stack name convention: dots replaced with underscores (e.g. foo.example.com → foo_example_com)
|
||||
stack_name = domain.replace(".", "_")
|
||||
|
||||
# Step 1: find SECRET_ID and container ID for this secret
|
||||
find_cmd = (
|
||||
f"docker stack services {stack_name} --format '{{{{.Name}}}}' | while read svc; do "
|
||||
f" CID=$(docker ps --no-trunc -q --filter \"name=$svc\" | head -1); "
|
||||
f" [ -z \"$CID\" ] && continue; "
|
||||
f" docker service inspect \"$svc\" --format "
|
||||
f" '{{{{json .Spec.TaskTemplate.ContainerSpec.Secrets}}}}' "
|
||||
f" | jq -r --arg cid \"$CID\" "
|
||||
f" '.[]? | .SecretID + \" \" + $cid + \" \" + .SecretName'; "
|
||||
f"done | grep ' {stack_name}_{secret_name}_' | head -1"
|
||||
)
|
||||
|
dannygroenewegen marked this conversation as resolved
Outdated
moritz
commented
Instead of this for loop, you could use Instead of this for loop, you could use `get_server_apps()`
|
||||
result = subprocess.run(["ssh", host, find_cmd], capture_output=True, text=True)
|
||||
match_line = result.stdout.strip()
|
||||
if not match_line:
|
||||
raise RuntimeError(f"Secret '{secret_name}' not found in stack '{stack_name}' on {host}")
|
||||
|
||||
parts = match_line.split()
|
||||
secret_id, container_id = parts[0], parts[1]
|
||||
|
||||
# Step 2: read the secret file from the host filesystem
|
||||
read_cmd = (
|
||||
f"cat /var/lib/docker/containers/{container_id}/mounts/secrets/{secret_id} 2>/dev/null "
|
||||
f"|| sudo cat /var/lib/docker/containers/{container_id}/mounts/secrets/{secret_id}"
|
||||
)
|
||||
read_result = subprocess.run(["ssh", host, read_cmd], capture_output=True, text=True)
|
||||
value = read_result.stdout # no strip — trailing newline could be part of the secret
|
||||
if not value:
|
||||
raise RuntimeError(f"Could not read value for secret '{secret_name}' on {host}")
|
||||
return value
|
||||
|
||||
def generate_secret(domain: str, secret_name: str) -> str:
|
||||
"""
|
||||
@@ -665,6 +760,22 @@ def insert_secret(domain: str, secret_name: str, secret: str) -> None:
|
||||
abra("app", "secret", "insert", domain, secret_name, "v1", secret)
|
||||
|
||||
|
||||
def update_secret(domain: str, secret_name: str, secret: str, was_deployed: bool) -> None:
|
||||
"""
|
||||
Updates an existing secret by removing and reinserting it.
|
||||
If the app was deployed (containers non-empty), undeploys first and redeploys after.
|
||||
If the app was not deployed, only replaces the secret in Docker Swarm.
|
||||
"""
|
||||
secret = unquote_strings(secret)
|
||||
logging.info(f"Updating secret {secret_name} in {domain} (deployed: {was_deployed})")
|
||||
if was_deployed:
|
||||
abra("--no-input", "app", "undeploy", domain, ignore_error=True)
|
||||
abra("app", "secret", "remove", domain, secret_name, ignore_error=True)
|
||||
abra("app", "secret", "insert", domain, secret_name, "v1", secret)
|
||||
if was_deployed:
|
||||
abra("--no-input", "app", "deploy", domain, ignore_error=True)
|
||||
|
||||
|
||||
|
dannygroenewegen marked this conversation as resolved
Outdated
moritz
commented
for a more intuitive function signature it would be good to pass a boolean for a more intuitive function signature it would be good to pass a boolean `was_deployed` instead of the complete list of containers.
|
||||
def uncomment(keys: List[str], path: str, match_all: bool = False) -> None:
|
||||
"""
|
||||
Uncomments lines in a configuration file that contain specified keys.
|
||||
@@ -874,7 +985,8 @@ def config(recipes: Tuple[str]) -> None:
|
||||
|
||||
@cli.command()
|
||||
@click.option('recipes', '-r', '--recipe', multiple=True, metavar='<RecipeName>', help='Filter for selcted recipes, this option can be specified multiple times.')
|
||||
def secrets(recipes: Tuple[str]) -> None:
|
||||
@click.option('--syncvalues', is_flag=True, default=False, help='Synchronize secret values from source to target when they differ. Without this flag, value mismatches are only reported as warnings.')
|
||||
def secrets(recipes: Tuple[str], syncvalues: bool) -> None:
|
||||
"""
|
||||
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.
|
||||
@@ -885,13 +997,19 @@ def secrets(recipes: Tuple[str]) -> None:
|
||||
selected_apps = [app for app in recipes if app in instance_config.keys()]
|
||||
else:
|
||||
selected_apps = instance_config.keys()
|
||||
# Pass 1: insert config secrets and run secret hooks for all apps first,
|
||||
# so that source secrets are stored before any exchange happens.
|
||||
for app in selected_apps:
|
||||
app_config = instance_config[app]
|
||||
domain = app_config['app_domain']
|
||||
print(f"Create secrets for {domain}")
|
||||
insert_secrets_from_conf(domain, app_config)
|
||||
run_secret_hooks(domain, app_config)
|
||||
exchange_secrets(app, instance_config, instance_apps)
|
||||
# Pass 2: exchange secrets between apps, then generate any remaining missing secrets.
|
||||
for app in selected_apps:
|
||||
app_config = instance_config[app]
|
||||
domain = app_config['app_domain']
|
||||
exchange_secrets(app, instance_config, instance_apps, syncvalues=syncvalues)
|
||||
generate_all_secrets(domain)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user
I'm not sure how you argue to remove the bidirectional secret sharing? The order which app is source or target should be quite irrelevant.
The condition
app2_secret_is_stored and not app1_secret_is_stored:ensures, that no secret will be overwritten. Because the secret of app1 doesn't exists.You're right, good catch. The whole logic is a bit hard to grasp because app1 and app2 refer to different apps depending on which call to share_secrets is being made from exchange_secrets. I think the edge case I wanted to solve here has already been solved by splitting the secret insertion and secret exchange into two phases in the secrets function, but I probably made that split after these changes.
For context: I have a case where it is important which app is the source: Rauthy doesn't support creating an OIDC client with a known secret. So Rauthy gets deployed first with a dummy value for the client secret. Then I use the initial-hooks to create a client with the Rauthy API and update the docker secret with the value that the API returns. Afterwards,
alakazam group secrets --syncvaluesshould only copy the secret value from rauthy to app2, not the other way around.I'm not sure if I exactly understand your rauthy flow but it sounds like the authentik saml workflow, for example with kimai.
First auhtentik needs to be deployed, to create the SAML certificate. Then the
secret-hookfor kimai runs this abra.sh function https://git.coopcloud.tech/coop-cloud/kimai/src/branch/main/abra.sh#L9 which calls the abra.sh function of authentik: https://git.coopcloud.tech/coop-cloud/authentik/src/branch/main/abra.sh#L362 to print the certificate and insert it as kimai secret.So in the end you use the Rauthy docker secrets only as "cache" to save the return value of the API and then share these values using
alakazam group secrets --syncvaluesto updated the dummy secret values that were generated randomly by abra in the first place?Did you tried to use the
secret-hooksfor that?Yes, exactly like this. Indeed seems similar to the saml workflow with kimai, that's another way to approach the problem. I did go over the authentik combine.yml, but missed that this one is different. I think ideally, I would like an SSO recipe to only have some commands to add a generic new client, and any app only have a generic connect-sso command. Alakazam should then contain the integration logic and configs, since alakazam handles that. Feels a bit cumbersome that for almost every integration app1 needs a compose.app2.yml file+secret and app2 needs a compose.app1.yml file+secret.