Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DPE-2731] Secret labels + missing unittests #268

Closed
wants to merge 5 commits into from
Closed
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Secret labels
juditnovak committed Oct 12, 2023
commit d20f1d67d5879c5858e59d30c0002640e7c2365f
2 changes: 1 addition & 1 deletion lib/charms/mongodb/v0/mongodb_secrets.py
Original file line number Diff line number Diff line change
@@ -63,7 +63,7 @@ def add_secret(self, content: Dict[str, str], scope: Scopes) -> Secret:
"Secret is already defined with uri %s", self._secret_uri
)

if scope == Config.APP_SCOPE:
if scope == Config.Relations.APP_SCOPE:
secret = self.charm.app.add_secret(content, label=self.label)
else:
secret = self.charm.unit.add_secret(content, label=self.label)
224 changes: 52 additions & 172 deletions src/charm.py
Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@
# See LICENSE file for licensing details.
import json
import logging
import re
import subprocess
import time
from typing import Dict, List, Optional, Set
@@ -30,6 +29,7 @@
)
from charms.mongodb.v0.mongodb_backups import S3_RELATION, MongoDBBackups
from charms.mongodb.v0.mongodb_provider import MongoDBProvider
from charms.mongodb.v0.mongodb_secrets import SecretCache, generate_secret_label
from charms.mongodb.v0.mongodb_tls import MongoDBTLS
from charms.mongodb.v0.mongodb_vm_legacy_provider import MongoDBLegacyProvider
from charms.mongodb.v0.users import (
@@ -61,18 +61,13 @@
BlockedStatus,
MaintenanceStatus,
Relation,
SecretNotFoundError,
Unit,
WaitingStatus,
)
from tenacity import Retrying, before_log, retry, stop_after_attempt, wait_fixed

from config import Config
from exceptions import (
AdminUserCreationError,
ApplicationHostNotFoundError,
SecretNotAddedError,
)
from exceptions import AdminUserCreationError, ApplicationHostNotFoundError
from machine_helpers import (
push_file_to_unit,
remove_file_from_unit,
@@ -134,7 +129,7 @@ def __init__(self, *args):
log_slots=Config.Monitoring.LOG_SLOTS,
)

self.secrets = {APP_SCOPE: {}, UNIT_SCOPE: {}}
self.secrets = SecretCache(self)

# BEGIN: properties

@@ -586,19 +581,27 @@ def _on_secret_remove(self, event: SecretRemoveEvent):
)

def _on_secret_changed(self, event: SecretChangedEvent):
if self._compare_secret_ids(
event.secret.id, self.app_peer_data.get(Config.Secrets.SECRET_INTERNAL_LABEL)
):
"""Handles secrets changes event.

When user run set-password action, juju leader changes the password inside the database
and inside the secret object. This action runs the restart for monitoring tool and
for backup tool on non-leader units to keep them working with MongoDB. The same workflow
occurs on TLS certs change.
"""
label = None
if generate_secret_label(self, Config.Relations.APP_SCOPE) == event.secret.label:
label = generate_secret_label(self, Config.Relations.APP_SCOPE)
scope = APP_SCOPE
elif self._compare_secret_ids(
event.secret.id, self.unit_peer_data.get(Config.Secrets.SECRET_INTERNAL_LABEL)
):
elif generate_secret_label(self, Config.Relations.UNIT_SCOPE) == event.secret.label:
label = generate_secret_label(self, Config.Relations.UNIT_SCOPE)
scope = UNIT_SCOPE
else:
logging.debug("Secret %s changed, but it's unknown", event.secret.id)
return
logging.debug("Secret %s for scope %s changed, refreshing", event.secret.id, scope)
self._update_juju_secrets_cache(scope)

# Refreshing cache
self.secrets.get(label)

# changed secrets means that the URIs used for PBM and mongodb_exporter are now out of date
self._connect_mongodb_exporter()
@@ -987,39 +990,50 @@ def _unit_ip(self, unit: Unit) -> str:

def get_secret(self, scope: str, key: str) -> Optional[str]:
"""Get secret from the secret storage."""
if self._juju_has_secrets:
return self._juju_secret_get(scope, key)
label = generate_secret_label(self, scope)
secret = self.secrets.get(label)
if not secret:
return

if scope == UNIT_SCOPE:
return self.unit_peer_data.get(key, None)
elif scope == APP_SCOPE:
return self.app_peer_data.get(key, None)
else:
raise RuntimeError("Unknown secret scope.")
value = secret.get_content().get(key)
if value != Config.Secrets.SECRET_DELETED_LABEL:
return value

def set_secret(self, scope: str, key: str, value: Optional[str]) -> Optional[str]:
"""Set secret in the secret storage.

Juju versions > 3.0 use `juju secrets`, this function first checks
which secret store is being used before setting the secret.
"""
if self._juju_has_secrets:
if not value:
return self._juju_secret_remove(scope, key)
return self._juju_secret_set(scope, key, value)
if not value:
return self.remove_secret(scope, key)

if scope == UNIT_SCOPE:
if not value:
del self.unit_peer_data[key]
return
self.unit_peer_data.update({key: str(value)})
elif scope == APP_SCOPE:
if not value:
del self.app_peer_data[key]
return
self.app_peer_data.update({key: str(value)})
label = generate_secret_label(self, scope)
secret = self.secrets.get(label)
if not secret:
self.secrets.add(label, {key: value}, scope)
else:
raise RuntimeError("Unknown secret scope.")
content = secret.get_content()
content.update({key: value})
secret.set_content(content)
return label

def remove_secret(self, scope, key) -> None:
"""Removing a secret."""
label = generate_secret_label(self, scope)
secret = self.secrets.get(label)

if not secret:
return

content = secret.get_content()

if not content.get(key) or content[key] == Config.Secrets.SECRET_DELETED_LABEL:
logger.error(f"Non-existing secret {scope}:{key} was attempted to be removed.")
return

content[key] = Config.Secrets.SECRET_DELETED_LABEL
secret.set_content(content)

def restart_mongod_service(self, auth=None):
"""Restarts the mongod service with its associated configuration."""
@@ -1105,140 +1119,6 @@ def _peer_data(self, scope: Scopes):
scope_obj = self._scope_obj(scope)
return self._peers.data[scope_obj]

@staticmethod
def _compare_secret_ids(secret_id1: str, secret_id2: str) -> bool:
"""Reliable comparison on secret equality.

NOTE: Secret IDs may be of any of these forms:
- secret://9663a790-7828-4186-8b21-2624c58b6cfe/citb87nubg2s766pab40
- secret:citb87nubg2s766pab40
"""
if not secret_id1 or not secret_id2:
return False

regex = re.compile(".*[^/][/:]")

pure_id1 = regex.sub("", secret_id1)
pure_id2 = regex.sub("", secret_id2)

if pure_id1 and pure_id2:
return pure_id1 == pure_id2
return False

def _juju_secret_set(self, scope: Scopes, key: str, value: str) -> str:
"""Helper function setting Juju secret in Juju versions >3.0."""
peer_data = self._peer_data(scope)
self._update_juju_secrets_cache(scope)

secret = self.secrets[scope].get(Config.Secrets.SECRET_LABEL)

# It's not the first secret for the scope, we can re-use the existing one
# that was fetched in the previous call, as fetching secrets from juju is
# slow
if secret:
secret_cache = self.secrets[scope][Config.Secrets.SECRET_CACHE_LABEL]

if secret_cache.get(key) == value:
logging.debug(f"Key {scope}:{key} has this value defined already")
else:
secret_cache[key] = value
try:
secret.set_content(secret_cache)
logging.debug(f"Secret {scope}:{key} was {key} set")
except OSError as error:
logging.error(
f"Error in attempt to set '{key}' secret for scope '{scope}'. "
f"Existing keys were: {list(secret_cache.keys())}. {error}"
)

# We need to create a brand-new secret for this scope
else:
scope_obj = self._scope_obj(scope)

secret = scope_obj.add_secret({key: value})
if not secret:
raise SecretNotAddedError(f"Couldn't set secret {scope}:{key}")

self.secrets[scope][Config.Secrets.SECRET_LABEL] = secret
self.secrets[scope][Config.Secrets.SECRET_CACHE_LABEL] = {key: value}
logging.debug(f"Secret {scope}:{key} published (as first). ID: {secret.id}")
peer_data.update({Config.Secrets.SECRET_INTERNAL_LABEL: secret.id})

return self.secrets[scope][Config.Secrets.SECRET_LABEL].id

def _update_juju_secrets_cache(self, scope: Scopes) -> None:
"""Helper function to retrieve all Juju secrets.

This function is responsible for direct communication with the Juju Secret
store to retrieve the Mono Charm's single, unique Secret object's metadata,
and --on success-- its contents.
In parallel with retrieving secret information, it's immediately locally cached,
making sure that we have the snapshot of the secret for the lifetime of the event
(that's being processed) without additional fetch requests to the Juju Secret Store.

(Note: metadata, i.e. the Secret object itself is cached as it may be necessary for
later operations, like updating contents.)

The function is returning a boolean that reflects success or failure of the above.
"""
peer_data = self._peer_data(scope)

if not peer_data.get(Config.Secrets.SECRET_INTERNAL_LABEL):
return

if Config.Secrets.SECRET_CACHE_LABEL not in self.secrets[scope]:
try:
# NOTE: Secret contents are not yet available!
secret = self.model.get_secret(id=peer_data[Config.Secrets.SECRET_INTERNAL_LABEL])
except SecretNotFoundError as e:
logging.debug(
f"No secret found for ID {peer_data[Config.Secrets.SECRET_INTERNAL_LABEL]}, {e}"
)
return

logging.debug(f"Secret {peer_data[Config.Secrets.SECRET_INTERNAL_LABEL]} downloaded")

# We keep the secret object around -- needed when applying modifications
self.secrets[scope][Config.Secrets.SECRET_LABEL] = secret

# We retrieve and cache actual secret data for the lifetime of the event scope
self.secrets[scope][Config.Secrets.SECRET_CACHE_LABEL] = secret.get_content()

def _get_juju_secrets_cache(self, scope: Scopes):
return self.secrets[scope].get(Config.Secrets.SECRET_CACHE_LABEL)

def _juju_secret_get(self, scope: Scopes, key: str) -> Optional[str]:
"""Helper function to get Juju secret."""
if not key:
return

self._update_juju_secrets_cache(scope)
secret_cache = self._get_juju_secrets_cache(scope)
if secret_cache:
secret_data = secret_cache.get(key)
if secret_data and secret_data != Config.Secrets.SECRET_DELETED_LABEL:
logging.debug(f"Getting secret {scope}:{key}")
return secret_data
logging.debug(f"No value found for secret {scope}:{key}")

def _juju_secret_remove(self, scope: Scopes, key: str) -> None:
"""Remove a Juju 3.x secret."""
self._update_juju_secrets_cache(scope)

secret = self.secrets[scope].get(Config.Secrets.SECRET_LABEL)
if not secret:
logging.error(f"Secret {scope}:{key} wasn't deleted: no secrets are available")
return

secret_cache = self.secrets[scope].get(Config.Secrets.SECRET_CACHE_LABEL)
if not secret_cache or key not in secret_cache:
logging.error(f"No secret {scope}:{key}")
return

secret_cache[key] = Config.Secrets.SECRET_DELETED_LABEL
secret.set_content(secret_cache)
logging.debug(f"Secret {scope}:{key}")

# END: helper functions


6 changes: 6 additions & 0 deletions src/exceptions.py
Original file line number Diff line number Diff line change
@@ -38,3 +38,9 @@ class MissingSecretError(MongoSecretError):
"""Could be raised when a Juju 3 mandatory secret couldn't be found."""

pass


class SecretAlreadyExistsError(MongoSecretError):
"""A secret that we want to create already exists."""

pass