Skip to content

Commit

Permalink
Retrieve SMTP config from SMTP integrator (#338)
Browse files Browse the repository at this point in the history
  • Loading branch information
arturo-seijas authored Nov 9, 2023
1 parent fd72026 commit 858ac3b
Show file tree
Hide file tree
Showing 16 changed files with 631 additions and 90 deletions.
20 changes: 0 additions & 20 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,26 +38,6 @@ options:
type: string
description: URL through which Indico is accessed by users.
default: ''
smtp_login:
type: string
description: The username to send if the SMTP server requires authentication.
default: ''
smtp_password:
type: string
description: The password to send if the SMTP server requires authentication.
default: ''
smtp_port:
type: int
description: The port of the SMTP server used for sending emails.
default: 25
smtp_server:
type: string
description: The hostname of the SMTP server used for sending emails.
default: ''
smtp_use_tls:
type: boolean
description: If enabled, STARTTLS will be used to use an encrypted SMTP connection.
default: true
s3_storage:
type: string
description: Comma separated list of parameters to connect to an S3 bucket as in 's3:bucket=my-indico-test-bucket,access_key=12345,secret_key=topsecret'. Details on the available options can be found at https://github.com/indico/indico-plugins/blob/master/storage_s3/README.md#available-config-options
Expand Down
2 changes: 1 addition & 1 deletion docs/explanation/charm-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ Take, for example, when a configuration is changed by using the CLI.

1. User runs the command
```bash
juju config smtp_login=user1
juju config [email protected]
```
2. A `config-changed` event is emitted
3. In the `__init__` method is defined how to handle this event like this:
Expand Down
9 changes: 0 additions & 9 deletions docs/how-to/configure-smtp.md

This file was deleted.

1 change: 0 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ If there's a particular area of documentation that you'd like to see that's miss
| 2 | how-to-configure-a-proxy | [Configure a proxy](/t/indico-docs-how-to-configure-a-proxy/8678) |
| 2 | how-to-configure-s3 | [Configure S3](/t/indico-docs-how-to-configure-s3/8680) |
| 2 | how-to-configure-saml | [Configure SAML](/t/indico-docs-how-to-configure-saml/8664) |
| 2 | how-to-configure-smtp | [Configure SMTP](/t/indico-docs-how-to-configure-smtp/8666) |
| 2 | how-to-configure-the-external-hostname | [Configure the external hostname](/t/indico-docs-how-to-configure-the-external-hostname/8660) |
| 2 | how-to-contribute | [Contribute](/t/indico-docs-how-to-contribute/7561) |
| 2 | how-to-customize-theme | [Customize theme](/t/indico-docs-how-to-customize-theme/8682) |
Expand Down
344 changes: 344 additions & 0 deletions lib/charms/smtp_integrator/v0/smtp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
# Copyright 2023 Canonical Ltd.
# Licensed under the Apache2.0. See LICENSE file in charm source for details.

"""Library to manage the integration with the SMTP Integrator charm.
This library contains the Requires and Provides classes for handling the integration
between an application and a charm providing the `smtp` and `smtp-legacy` integrations.
If the requirer charm supports secrets, the preferred approach is to use the `smtp`
relation to leverage them.
This library also contains a `SmtpRelationData` class to wrap the SMTP data that will
be shared via the integration.
### Requirer Charm
```python
from charms.smtp_integrator.v0 import SmtpDataAvailableEvent, SmtpRequires
class SmtpRequirerCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.smtp = smtp.SmtpRequires(self)
self.framework.observe(self.smtp.on.smtp_data_available, self._handler)
...
def _handler(self, events: SmtpDataAvailableEvent) -> None:
...
```
As shown above, the library provides a custom event to handle the scenario in
which new SMTP data has been added or updated.
### Provider Charm
Following the previous example, this is an example of the provider charm.
```python
from charms.smtp_integrator.v0 import SmtpDataAvailableEvent, SmtpProvides
class SmtpProviderCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.smtp = SmtpProvides(self)
...
```
The SmtpProvides object wraps the list of relations into a `relations` property
and provides an `update_relation_data` method to update the relation data by passing
a `SmtpRelationData` data object.
"""

# The unique Charmhub library identifier, never change it
LIBID = "09583c2f9c1d4c0f9a40244cfc20b0c2"

# 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 = 4

# pylint: disable=wrong-import-position
import logging
from enum import Enum
from typing import Dict, List, Optional

import ops
from pydantic import BaseModel, Field, ValidationError

logger = logging.getLogger(__name__)

DEFAULT_RELATION_NAME = "smtp"
LEGACY_RELATION_NAME = "smtp-legacy"


class TransportSecurity(str, Enum):
"""Represent the transport security values.
Attributes:
NONE: none
STARTTLS: starttls
TLS: tls
"""

NONE = "none"
STARTTLS = "starttls"
TLS = "tls"


class AuthType(str, Enum):
"""Represent the auth type values.
Attributes:
NONE: none
NOT_PROVIDED: not_provided
PLAIN: plain
"""

NONE = "none"
NOT_PROVIDED = "not_provided"
PLAIN = "plain"


class SmtpRelationData(BaseModel):
"""Represent the relation data.
Attributes:
host: The hostname or IP address of the outgoing SMTP relay.
port: The port of the outgoing SMTP relay.
user: The SMTP AUTH user to use for the outgoing SMTP relay.
password: The SMTP AUTH password to use for the outgoing SMTP relay.
password_id: The secret ID where the SMTP AUTH password for the SMTP relay is stored.
auth_type: The type used to authenticate with the SMTP relay.
transport_security: The security protocol to use for the outgoing SMTP relay.
domain: The domain used by the sent emails from SMTP relay.
"""

host: str = Field(..., min_length=1)
port: int = Field(None, ge=1, le=65536)
user: Optional[str]
password: Optional[str]
password_id: Optional[str]
auth_type: AuthType
transport_security: TransportSecurity
domain: Optional[str]

def to_relation_data(self) -> Dict[str, str]:
"""Convert an instance of SmtpRelationData to the relation representation.
Returns:
Dict containing the representation.
"""
result = {
"host": str(self.host),
"port": str(self.port),
"auth_type": self.auth_type.value,
"transport_security": self.transport_security.value,
}
if self.domain:
result["domain"] = self.domain
if self.user:
result["user"] = self.user
if self.password:
result["password"] = self.password
if self.password_id:
result["password_id"] = self.password_id
return result


class SmtpDataAvailableEvent(ops.RelationEvent):
"""Smtp event emitted when relation data has changed.
Attributes:
host: The hostname or IP address of the outgoing SMTP relay.
port: The port of the outgoing SMTP relay.
user: The SMTP AUTH user to use for the outgoing SMTP relay.
password: The SMTP AUTH password to use for the outgoing SMTP relay.
password_id: The secret ID where the SMTP AUTH password for the SMTP relay is stored.
auth_type: The type used to authenticate with the SMTP relay.
transport_security: The security protocol to use for the outgoing SMTP relay.
domain: The domain used by the sent emails from SMTP relay.
"""

@property
def host(self) -> str:
"""Fetch the SMTP host from the relation."""
assert self.relation.app
return self.relation.data[self.relation.app].get("host")

@property
def port(self) -> int:
"""Fetch the SMTP port from the relation."""
assert self.relation.app
return int(self.relation.data[self.relation.app].get("port"))

@property
def user(self) -> str:
"""Fetch the SMTP user from the relation."""
assert self.relation.app
return self.relation.data[self.relation.app].get("user")

@property
def password(self) -> str:
"""Fetch the SMTP password from the relation."""
assert self.relation.app
return self.relation.data[self.relation.app].get("password")

@property
def password_id(self) -> str:
"""Fetch the SMTP password from the relation."""
assert self.relation.app
return self.relation.data[self.relation.app].get("password_id")

@property
def auth_type(self) -> AuthType:
"""Fetch the SMTP auth type from the relation."""
assert self.relation.app
return AuthType(self.relation.data[self.relation.app].get("auth_type"))

@property
def transport_security(self) -> TransportSecurity:
"""Fetch the SMTP transport security protocol from the relation."""
assert self.relation.app
return TransportSecurity(self.relation.data[self.relation.app].get("transport_security"))

@property
def domain(self) -> str:
"""Fetch the SMTP domain from the relation."""
assert self.relation.app
return self.relation.data[self.relation.app].get("domain")


class SmtpRequiresEvents(ops.CharmEvents):
"""SMTP events.
This class defines the events that a SMTP requirer can emit.
Attributes:
smtp_data_available: the SmtpDataAvailableEvent.
"""

smtp_data_available = ops.EventSource(SmtpDataAvailableEvent)


class SmtpRequires(ops.Object):
"""Requirer side of the SMTP relation.
Attributes:
on: events the provider can emit.
"""

on = SmtpRequiresEvents()

def __init__(self, charm: ops.CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None:
"""Construct.
Args:
charm: the provider charm.
relation_name: the relation name.
"""
super().__init__(charm, relation_name)
self.charm = charm
self.relation_name = relation_name
self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_changed)

def get_relation_data(self) -> Optional[SmtpRelationData]:
"""Retrieve the relation data.
Returns:
SmtpRelationData: the relation data.
"""
relation = self.model.get_relation(self.relation_name)
return self._get_relation_data_from_relation(relation) if relation else None

def _get_relation_data_from_relation(self, relation: ops.Relation) -> SmtpRelationData:
"""Retrieve the relation data.
Args:
relation: the relation to retrieve the data from.
Returns:
SmtpRelationData: the relation data.
"""
assert relation.app
relation_data = relation.data[relation.app]
return SmtpRelationData(
host=relation_data.get("host"),
port=relation_data.get("port"),
user=relation_data.get("user"),
password=relation_data.get("password"),
password_id=relation_data.get("password_id"),
auth_type=relation_data.get("auth_type"),
transport_security=relation_data.get("transport_security"),
domain=relation_data.get("domain"),
)

def _is_relation_data_valid(self, relation: ops.Relation) -> bool:
"""Validate the relation data.
Args:
relation: the relation to validate.
Returns:
true: if the relation data is valid.
"""
try:
_ = self._get_relation_data_from_relation(relation)
return True
except ValidationError:
return False

def _on_relation_changed(self, event: ops.RelationChangedEvent) -> None:
"""Event emitted when the relation has changed.
Args:
event: event triggering this handler.
"""
assert event.relation.app
relation_data = event.relation.data[event.relation.app]
if relation_data:
if relation_data["auth_type"] == AuthType.NONE.value:
logger.warning('Insecure setting: auth_type has a value "none"')
if relation_data["transport_security"] == TransportSecurity.NONE.value:
logger.warning('Insecure setting: transport_security has value "none"')
if self._is_relation_data_valid(event.relation):
self.on.smtp_data_available.emit(event.relation, app=event.app, unit=event.unit)


class SmtpProvides(ops.Object):
"""Provider side of the SMTP relation.
Attributes:
relations: list of charm relations.
"""

def __init__(self, charm: ops.CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None:
"""Construct.
Args:
charm: the provider charm.
relation_name: the relation name.
"""
super().__init__(charm, relation_name)
self.charm = charm
self.relation_name = relation_name

@property
def relations(self) -> List[ops.Relation]:
"""The list of Relation instances associated with this relation_name.
Returns:
List of relations to this charm.
"""
return list(self.model.relations[self.relation_name])

def update_relation_data(self, relation: ops.Relation, smtp_data: SmtpRelationData) -> None:
"""Update the relation data.
Args:
relation: the relation for which to update the data.
smtp_data: a SmtpRelationData instance wrapping the data to be updated.
"""
relation.data[self.charm.model.app].update(smtp_data.to_relation_data())
Loading

0 comments on commit 858ac3b

Please sign in to comment.