Skip to content

Commit

Permalink
[DPE-2122] Internal Secrets implementation - juju 3 (canonical#102)
Browse files Browse the repository at this point in the history
## Issue

Juju 3 secrets to be integrated to Openseach Charm


## Solution

NOTE: For this purpose we agreed to implement a partial Juju3 pipeline
on top of the exising Juju2 one, in order to preserve CI runtimes
  • Loading branch information
juditnovak authored Sep 1, 2023
1 parent 03fbe22 commit cb3611a
Show file tree
Hide file tree
Showing 23 changed files with 853 additions and 207 deletions.
18 changes: 18 additions & 0 deletions lib/charms/opensearch/v0/constants_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details.

"""In this file we declare the constants and enums used by Juju secrets in Opensearch."""

# The unique Charmhub library identifier, never change it
LIBID = "2f539a53ab0a4916957beaf1d6b27124"

# Increment this major API version when introducing breaking changes
LIBAPI = 0

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 1


ADMIN_PW = "admin-password"
ADMIN_PW_HASH = f"{ADMIN_PW}-hash"
31 changes: 14 additions & 17 deletions lib/charms/opensearch/v0/opensearch_base_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,10 @@
TLSRelationBrokenError,
WaitingToStart,
)
from charms.opensearch.v0.constants_secrets import ADMIN_PW, ADMIN_PW_HASH
from charms.opensearch.v0.constants_tls import TLS_RELATION, CertType
from charms.opensearch.v0.helper_charm import Status
from charms.opensearch.v0.helper_cluster import ClusterTopology, Node
from charms.opensearch.v0.helper_databag import (
RelationDataStore,
Scope,
SecretsDataStore,
)
from charms.opensearch.v0.helper_networking import (
get_host_ip,
is_reachable,
Expand All @@ -58,13 +54,15 @@
)
from charms.opensearch.v0.opensearch_fixes import OpenSearchFixes
from charms.opensearch.v0.opensearch_health import HealthColors, OpenSearchHealth
from charms.opensearch.v0.opensearch_internal_data import RelationDataStore, Scope
from charms.opensearch.v0.opensearch_locking import OpenSearchOpsLock
from charms.opensearch.v0.opensearch_nodes_exclusions import (
ALLOCS_TO_DELETE,
VOTING_TO_DELETE,
OpenSearchExclusions,
)
from charms.opensearch.v0.opensearch_relation_provider import OpenSearchProvider
from charms.opensearch.v0.opensearch_secrets import OpenSearchSecrets
from charms.opensearch.v0.opensearch_tls import OpenSearchTLS
from charms.opensearch.v0.opensearch_users import OpenSearchUserManager
from charms.rolling_ops.v0.rollingops import RollingOpsManager
Expand Down Expand Up @@ -120,7 +118,7 @@ def __init__(self, *args, distro: Type[OpenSearchDistribution] = None):
self.opensearch_exclusions = OpenSearchExclusions(self)
self.opensearch_fixes = OpenSearchFixes(self)
self.peers_data = RelationDataStore(self, PeerRelationName)
self.secrets = SecretsDataStore(self, PeerRelationName)
self.secrets = OpenSearchSecrets(self, PeerRelationName)
self.tls = OpenSearchTLS(self, TLS_RELATION)
self.status = Status(self)
self.health = OpenSearchHealth(self)
Expand Down Expand Up @@ -225,7 +223,7 @@ def _on_peer_relation_created(self, event: RelationCreatedEvent):
return

# Store the "Admin" certificate, key and CA on the disk of the new unit
self._store_tls_resources(CertType.APP_ADMIN, current_secrets, override_admin=False)
self.store_tls_resources(CertType.APP_ADMIN, current_secrets, override_admin=False)

def _on_peer_relation_joined(self, event: RelationJoinedEvent):
"""Event received by all units when a new node joins the cluster."""
Expand Down Expand Up @@ -424,7 +422,7 @@ def _on_set_password_action(self, event: ActionEvent):
password = event.params.get("password") or generate_password()
try:
self._put_admin_user(password)
password = self.secrets.get(Scope.APP, f"{user_name}_password")
password = self.secrets.get(Scope.APP, f"{user_name}-password")
event.set_results({f"{user_name}-password": password})
except OpenSearchError as e:
event.fail(f"Failed changing the password: {e}")
Expand All @@ -440,17 +438,16 @@ def _on_get_password_action(self, event: ActionEvent):
event.fail("admin user or TLS certificates not configured yet.")
return

password = self.secrets.get(Scope.APP, f"{user_name}_password")
password = self.secrets.get(Scope.APP, f"{user_name}-password")
cert = self.secrets.get_object(
Scope.APP, CertType.APP_ADMIN.val
) # replace later with new user certs
ca_chain = "\n".join(cert["chain"][::-1])

event.set_results(
{
"username": user_name,
"password": password,
"ca-chain": ca_chain,
"ca-chain": cert["chain"],
}
)

Expand All @@ -467,7 +464,7 @@ def on_tls_conf_set(
current_secrets = self.secrets.get_object(scope, cert_type.val)

# Store cert/key on disk - must happen after opensearch stop for transport certs renewal
self._store_tls_resources(cert_type, current_secrets)
self.store_tls_resources(cert_type, current_secrets)

if scope == Scope.UNIT:
# node http or transport cert
Expand Down Expand Up @@ -693,7 +690,7 @@ def _put_admin_user(self, pwd: Optional[str] = None):
if resp.get("status") != "OK":
raise OpenSearchError(f"{resp}")
else:
hashed_pwd = self.secrets.get(Scope.APP, "admin_password_hash")
hashed_pwd = self.secrets.get(Scope.APP, ADMIN_PW_HASH)
if not hashed_pwd:
hashed_pwd, pwd = generate_hashed_password()

Expand All @@ -715,8 +712,8 @@ def _put_admin_user(self, pwd: Optional[str] = None):
},
)

self.secrets.put(Scope.APP, "admin_password", pwd)
self.secrets.put(Scope.APP, "admin_password_hash", hashed_pwd)
self.secrets.put(Scope.APP, ADMIN_PW, pwd)
self.secrets.put(Scope.APP, ADMIN_PW_HASH, hashed_pwd)
self.peers_data.put(Scope.APP, "admin_user_initialized", True)

def _initialize_security_index(self, admin_secrets: Dict[str, any]) -> None:
Expand Down Expand Up @@ -890,7 +887,7 @@ def _check_certs_expiration(self, event: UpdateStatusEvent) -> None:
if (datetime.now() - last_cert_check).seconds < 6 * 3600:
return

certs = self.secrets.get_unit_certificates()
certs = self.tls.get_unit_certificates()

# keep certificates that are expiring in less than 24h
for cert_type in list(certs.keys()):
Expand All @@ -915,7 +912,7 @@ def _check_certs_expiration(self, event: UpdateStatusEvent) -> None:
)

@abstractmethod
def _store_tls_resources(
def store_tls_resources(
self, cert_type: CertType, secrets: Dict[str, any], override_admin: bool = True
):
"""Write certificates and keys on disk."""
Expand Down
8 changes: 6 additions & 2 deletions lib/charms/opensearch/v0/opensearch_distro.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@

import requests
import urllib3.exceptions
from charms.opensearch.v0.constants_secrets import ADMIN_PW
from charms.opensearch.v0.helper_cluster import Node
from charms.opensearch.v0.helper_conf_setter import YamlConfigSetter
from charms.opensearch.v0.helper_databag import Scope
from charms.opensearch.v0.helper_networking import (
get_host_ip,
is_reachable,
Expand All @@ -31,6 +31,7 @@
OpenSearchHttpError,
OpenSearchStartTimeoutError,
)
from charms.opensearch.v0.opensearch_internal_data import Scope

# The unique Charmhub library identifier, never change it
LIBID = "7145c219467d43beb9c566ab4a72c454"
Expand Down Expand Up @@ -245,7 +246,10 @@ def call(

try:
with requests.Session() as s:
s.auth = ("admin", self._charm.secrets.get(Scope.APP, "admin_password"))
s.auth = (
"admin",
self._charm.secrets.get(Scope.APP, ADMIN_PW),
)

request_kwargs = {
"method": method.upper(),
Expand Down
8 changes: 8 additions & 0 deletions lib/charms/opensearch/v0/opensearch_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,11 @@ class OpenSearchScaleDownError(OpenSearchError):

class OpenSearchIndexError(OpenSearchError):
"""Exception thrown when an opensearch index is invalid."""


class OpenSearchSecretError(OpenSearchError):
"""Parent exception for secrets related issues within OpenSearch."""


class OpenSearchSecretInsertionError(OpenSearchSecretError):
"""Exception thrown when a secret (group) was not found."""
2 changes: 1 addition & 1 deletion lib/charms/opensearch/v0/opensearch_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
)
from charms.opensearch.v0.helper_charm import Status
from charms.opensearch.v0.helper_cluster import ClusterState
from charms.opensearch.v0.helper_databag import Scope
from charms.opensearch.v0.opensearch_exceptions import OpenSearchHttpError
from charms.opensearch.v0.opensearch_internal_data import Scope
from ops.model import BlockedStatus, WaitingStatus

# The unique Charmhub library identifier, never change it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
"""Utility classes for app / unit data bag related operations."""

import json
import logging
from abc import ABC, abstractmethod
from ast import literal_eval
from typing import Dict, Optional, Union

from charms.opensearch.v0.constants_tls import CertType
from charms.opensearch.v0.helper_enums import BaseStrEnum
from ops import Secret
from overrides import override

# The unique Charmhub library identifier, never change it
Expand All @@ -23,6 +24,9 @@
LIBPATCH = 1


logger = logging.getLogger(__name__)


class Scope(BaseStrEnum):
"""Peer relations scope."""

Expand Down Expand Up @@ -53,11 +57,6 @@ def has(self, scope: Scope, key: str):
"""Check if the said key is contained in the store."""
pass

@abstractmethod
def all(self, scope: Scope) -> Dict[str, str]:
"""Get all content of a store."""
pass

@abstractmethod
def get(
self, scope: Scope, key: str, default: Optional[Union[int, float, str, bool]] = None
Expand Down Expand Up @@ -140,14 +139,6 @@ def has(self, scope: Scope, key: str):

return key in self._get_relation_data(scope)

@override
def all(self, scope: Scope) -> Dict[str, str]:
"""Get all content of a store."""
if scope is None:
raise ValueError("Scope undefined.")

return self._get_relation_data(scope)

@override
def get(
self,
Expand Down Expand Up @@ -196,27 +187,71 @@ def _get_relation_data(self, scope: Scope) -> Dict[str, str]:
return relation.data[relation_scope]


class SecretsDataStore(RelationDataStore):
"""Class representing a secret store for a charm.
class SecretCache:
"""Internal helper class locally cache secrets.
For now, it is simply a base class for regular Relation data store
The data structure is precisely re-using/simulating as in the actual Secret Storage
"""

def get_unit_certificates(self) -> Dict[CertType, str]:
"""Retrieve the list of certificates for this unit."""
certs = {}

transport_secrets = self.get_object(Scope.UNIT, CertType.UNIT_TRANSPORT.val)
if transport_secrets and "cert" in transport_secrets:
certs[CertType.UNIT_TRANSPORT] = transport_secrets["cert"]

http_secrets = self.get_object(Scope.UNIT, CertType.UNIT_HTTP.val)
if http_secrets and "cert" in http_secrets:
certs[CertType.UNIT_HTTP] = http_secrets["cert"]

if self._charm.unit.is_leader():
admin_secrets = self.get_object(Scope.APP, CertType.APP_ADMIN.val)
if admin_secrets and "cert" in admin_secrets:
certs[CertType.APP_ADMIN] = admin_secrets["cert"]

return certs
CACHED_META = "meta"
CACHED_CONTENT = "content"

def __init__(self):
# Structure:
# NOTE: "objects" (i.e. dict-s) and scalar values are handled in a unified way
# precisely as done for the Secret objects themselves.
#
# self.secrets = {
# "app": {
# "opensearch:app:admin-password": {
# "meta": <Secret instance>,
# "content": {
# "opensearch:app:admin-password": "bla"
# }
# }
# },
# "unit": {
# "opensearch:unit:0:certificates": {
# "meta": <Secret instance>,
# "content": {
# "ca-cert": "<certificate>",
# "cert": "<certificate>",
# "chain": "<certificate>"
# }
# }
# }
# }
self.secrets = {Scope.APP: {}, Scope.UNIT: {}}

def get_meta(self, scope: Scope, label: str) -> Optional[Secret]:
"""Getting cached secret meta-information."""
return self.secrets[scope].get(label, {}).get(self.CACHED_META)

def set_meta(self, scope: Scope, label: str, secret: Secret) -> None:
"""Setting cached secret meta-information."""
self.secrets[scope].setdefault(label, {}).update({self.CACHED_META: secret})

def get_content(self, scope: Scope, label: str) -> Dict[str, str]:
"""Getting cached secret content."""
return self.secrets[scope].get(label, {}).get(self.CACHED_CONTENT)

def put_content(self, scope: Scope, label: str, content: Union[str, Dict[str, str]]):
"""Setting cached secret content."""
self.secrets[scope].setdefault(label, {}).update({self.CACHED_CONTENT: content})

def put(
self,
scope: Scope,
label: str,
secret: Optional[Secret] = None,
content: Optional[Union[str, Dict[str, str]]] = None,
) -> None:
"""Updating cached secret information."""
if secret:
self.set_meta(scope, label, secret)
if content:
self.put_content(scope, label, content)

def delete(self, scope: Scope, label: str) -> None:
"""Removing cached secret information."""
self.secrets[scope].pop(label, None)
2 changes: 1 addition & 1 deletion lib/charms/opensearch/v0/opensearch_locking.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
import logging

from charms.opensearch.v0.constants_charm import PeerRelationName
from charms.opensearch.v0.helper_databag import Scope
from charms.opensearch.v0.opensearch_exceptions import (
OpenSearchHttpError,
OpenSearchOpsLockAlreadyAcquiredError,
)
from charms.opensearch.v0.opensearch_internal_data import Scope

# The unique Charmhub library identifier, never change it
LIBID = "0924c6d81c604a15873ad43498cd6895"
Expand Down
2 changes: 1 addition & 1 deletion lib/charms/opensearch/v0/opensearch_nodes_exclusions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
from functools import cached_property
from typing import List, Optional, Set

from charms.opensearch.v0.helper_databag import Scope
from charms.opensearch.v0.models import Node
from charms.opensearch.v0.opensearch_exceptions import (
OpenSearchError,
OpenSearchHttpError,
)
from charms.opensearch.v0.opensearch_internal_data import Scope

# The unique Charmhub library identifier, never change it
LIBID = "51c1ac864e9a4d12b1d1ef27c0ff2e50"
Expand Down
5 changes: 3 additions & 2 deletions lib/charms/opensearch/v0/opensearch_relation_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@
UserCreationFailed,
)
from charms.opensearch.v0.constants_tls import CertType
from charms.opensearch.v0.helper_databag import Scope
from charms.opensearch.v0.helper_networking import unit_ip
from charms.opensearch.v0.helper_security import generate_hashed_password
from charms.opensearch.v0.opensearch_exceptions import (
OpenSearchHttpError,
OpenSearchIndexError,
)
from charms.opensearch.v0.opensearch_internal_data import Scope
from charms.opensearch.v0.opensearch_users import OpenSearchUserMgmtError
from ops.charm import (
CharmBase,
Expand Down Expand Up @@ -336,7 +336,8 @@ def update_certs(self, relation_id, ca_chain=None):
except AttributeError:
# cert doesn't exist - presumably we don't yet have a TLS relation.
return
self.opensearch_provides.set_tls_ca(relation_id, "\n".join(ca_chain[::-1]))

self.opensearch_provides.set_tls_ca(relation_id, ca_chain)

def _on_relation_changed(self, event: RelationChangedEvent) -> None:
if not self.unit.is_leader():
Expand Down
Loading

0 comments on commit cb3611a

Please sign in to comment.