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

Relate to keystone to get credentials for exporter #4

Merged
merged 3 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 12 additions & 0 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,25 @@ bases:
- name: ubuntu
channel: "22.04"

requires:
credentials:
interface: keystone-admin
chanchiwai-ray marked this conversation as resolved.
Show resolved Hide resolved

config:
options:
port:
type: int
default: 9180
description: |
Start the exporter at "port".
ssl_ca:
default: ''
type: string
description: |
Custom SSL CA for keystone if required.
chanchiwai-ray marked this conversation as resolved.
Show resolved Hide resolved

The format should be the contents of a PEM encoded file.
(no base64 encoding required).
samuelallan72 marked this conversation as resolved.
Show resolved Hide resolved


#
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
ops ~= 2.5
pyyaml ~= 6.0
110 changes: 101 additions & 9 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,20 @@

import logging
import os
from pathlib import Path
from typing import Any, Optional

import ops
import yaml
from ops.model import (
ActiveStatus,
BlockedStatus,
ModelError,
WaitingStatus,
)
from service import get_installed_snap_service, snap_install

logger = logging.getLogger(__name__)

# Miscellaneous constants
USER_HOME = Path().home()

# charm global constants
RESOURCE_NAME = "openstack-exporter"

Expand All @@ -35,8 +33,12 @@


# Snap config options global constants
# This is to match between openstack-exporter and the entry in clouds.yaml
CLOUD_NAME = "openstack"
OS_CLIENT_CONFIG = USER_HOME / "clouds.yaml"
# store the clouds.yaml where it's easily accessible by the openstack-exporter snap
# This is the SNAP_COMMON directory for the exporter snap, which is accessible,
# unversioned, and retained across updates of the snap.
OS_CLIENT_CONFIG = "/var/snap/golang-openstack-exporter/common/clouds.yaml"
chanchiwai-ray marked this conversation as resolved.
Show resolved Hide resolved


class OpenstackExporterOperatorCharm(ops.CharmBase):
Expand All @@ -50,6 +52,78 @@ def __init__(self, *args: tuple[Any]) -> None:
self.framework.observe(self.on.upgrade_charm, self._on_upgrade)
self.framework.observe(self.on.config_changed, self._on_config_changed)
self.framework.observe(self.on.collect_unit_status, self._on_collect_unit_status)
self.framework.observe(self.on.credentials_relation_changed, self._on_credentials_changed)

def _is_keystone_data_ready(self, data: dict[str, str]) -> bool:
"""Check if all the data is available from keystone.

Data is validated as unit relation data from the keystone-admin interface.
"""
return all(
data.get(x)
for x in [
"service_protocol",
"service_hostname",
"service_port",
"service_username",
"service_password",
"service_project_name",
"service_project_domain_name",
"service_user_domain_name",
"service_region",
]
)

def _write_cloud_config(self, data: dict[str, str]) -> None:
"""Build a standard clouds.yaml given the keystone credentials data.

This is used by the exporter to connect to the openstack cloud,
including credentials, keystone endpoint, ca certificate, region.

Only api version 3 is supported,
since v2 was removed a long time ago (Queens release)
https://docs.openstack.org/keystone/latest/contributor/http-api.html
"""
auth_url = "{protocol}://{hostname}:{port}/v3".format(
protocol=data["service_protocol"],
hostname=data["service_hostname"],
port=data["service_port"],
)
contents = {
"clouds": {
CLOUD_NAME: {
"region_name": data["service_region"],
"identity_api_version": "3",
"identity_interface": "internal",
"auth": {
"username": data["service_username"],
"password": data["service_password"],
"project_name": data["service_project_name"],
"project_domain_name": data["service_project_domain_name"],
"user_domain_name": data["service_user_domain_name"],
"auth_url": auth_url,
},
"verify": data["service_protocol"] == "https",
"cacert": self.config["ssl_ca"],
}
}
}

os.makedirs(os.path.dirname(OS_CLIENT_CONFIG), exist_ok=True)
with open(OS_CLIENT_CONFIG, "w") as f:
yaml.dump(contents, f)

def _get_keystone_data(self) -> dict[str, str]:
"""Get keystone data if ready, otherwise empty dict."""
# The double loop is because credentials are in the unit data,
# and not all units are guaranteed to have this data.
# So we pick the first one that has the data we need.
for rel in self.model.relations.get("credentials", []):
for unit in rel.units:
data = rel.data[unit]
if self._is_keystone_data_ready(data):
return data
return {}

def get_resource(self) -> Optional[str]:
"""Return the path-to-resource or None if the resource is empty.
Expand Down Expand Up @@ -94,9 +168,15 @@ def configure(self, event: ops.HookEvent) -> None:
}
)

# TODO: properly start and stop the service depends on the relations to
# keystone and grafana-agent.
snap_service.start()
data = self._get_keystone_data()
if not data:
logger.info("Keystone credentials are not available, stopping services.")
snap_service.stop()
return

logger.info("Keystone credentials are available, starting services.")
self._write_cloud_config(data)
snap_service.restart_and_enable()

def _on_install(self, _: ops.InstallEvent) -> None:
"""Handle install charm event."""
Expand All @@ -110,12 +190,24 @@ def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None:
"""Handle config changed event."""
self.configure(event)

def _on_credentials_changed(self, event: ops.RelationChangedEvent) -> None:
"""Handle updates to credentials from keystone."""
self.configure(event)

def _on_collect_unit_status(self, event: ops.CollectStatusEvent) -> None:
"""Handle collect unit status event (called after every event)."""
if not self.model.relations.get("credentials"):
event.add_status(BlockedStatus("Keystone is not related"))

if not self._get_keystone_data():
event.add_status(WaitingStatus("Waiting for credentials from keystone"))

snap_service = get_installed_snap_service(SNAP_NAME, SNAP_SERVICE_NAME)

if not snap_service:
event.add_status(BlockedStatus("snap service is not installed, please check snap service"))
event.add_status(
BlockedStatus("snap service is not installed, please check snap service")
)
elif not snap_service.is_active():
event.add_status(
BlockedStatus("snap service is not running, please check snap service")
Expand Down
15 changes: 13 additions & 2 deletions src/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,19 @@ def is_active(self) -> bool:
"""Return True if the snap service is active."""
return self.snap_client.services.get(self.snap_service, {}).get("active", False)

def start(self) -> None:
"""Start and enable the snap service."""
def restart_and_enable(self) -> None:
"""Restart and enable the snap service.

Ensure:
- service is running
- service is enabled
- service is restarted if already running to apply updated config
"""
# This is safe to always run,
# because restarting when service is disabled has no effect,
# and restarting when enabled but stopped has the same effect as start.
self.snap_client.restart([self.snap_service])
# This is idempotent, so ok to always run to ensure it's started and enabled
self.snap_client.start([self.snap_service], enable=True)

def stop(self) -> None:
Expand Down