Start of cookie cutter
This commit is contained in:
parent
e6e421fa3d
commit
76034ff83a
@ -1,5 +1,30 @@
|
||||
Advanced Sunbeam OpenStack
|
||||
========================================
|
||||
Advanced Sunbeam OpenStack Documentation
|
||||
========================================
|
||||
|
||||
Tuturials
|
||||
#########
|
||||
|
||||
`Writing an OpenStack API charm with ASO <writing-OS-API-charm.rst>`_.
|
||||
|
||||
How-Tos
|
||||
#######
|
||||
|
||||
|
||||
`How-To write a pebble handler <howto-pebble-handler.rst>`_.
|
||||
|
||||
`How-To write a relation handler <howto-relation-handler.rst>`_.
|
||||
|
||||
`How-To write a charm context <howto-config-context.rst>`_.
|
||||
|
||||
|
||||
Reference
|
||||
#########
|
||||
|
||||
|
||||
|
||||
Concepts
|
||||
########
|
||||
|
||||
`How to Write a charm with ASO <howto-write-charm.rst>`_.
|
||||
|
||||
`ASO Concepts <concepts.rst>`_.
|
||||
|
5
ops-sunbeam/aso-charm-init.sh
Executable file
5
ops-sunbeam/aso-charm-init.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
[ -e .tox/cookie/bin/activate ] || tox -e cookie
|
||||
source .tox/cookie/bin/activate
|
||||
shared_code/aso-charm-init.py $@
|
@ -113,7 +113,7 @@ Charms
|
||||
|
||||
ASO currently provides two base classes to choose from when writing a charm.
|
||||
The first is `OSBaseOperatorCharm` and the second, which is derived from the
|
||||
first, `OSBaseOperatorAPICharm`.
|
||||
first, `OSBaseOperatorAPICharm`.
|
||||
|
||||
The base classes setup a default set of relation handlers (based on what
|
||||
relations are present in the charm metadata) and default container handlers.
|
||||
|
1
ops-sunbeam/cookie-requirements.txt
Normal file
1
ops-sunbeam/cookie-requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
cookiecutter
|
55
ops-sunbeam/howto-config-context.rst
Normal file
55
ops-sunbeam/howto-config-context.rst
Normal file
@ -0,0 +1,55 @@
|
||||
=============================
|
||||
How-To Write a config context
|
||||
=============================
|
||||
|
||||
A config context is an additional context that is passed to the template
|
||||
renderer in its own namespace. They are usually useful when some logic
|
||||
needs to be applied to user supplied charm configuration. The context
|
||||
has access to the charm object.
|
||||
|
||||
Below is an example which applies logic to the charm config as well as
|
||||
collecting the application name to constuct the context.
|
||||
|
||||
.. code:: python
|
||||
|
||||
|
||||
class CinderCephConfigurationContext(ConfigContext):
|
||||
"""Cinder Ceph configuration context."""
|
||||
|
||||
def context(self) -> None:
|
||||
"""Cinder Ceph configuration context."""
|
||||
config = self.charm.model.config.get
|
||||
data_pool_name = config('rbd-pool-name') or self.charm.app.name
|
||||
if config('pool-type') == "erasure-coded":
|
||||
pool_name = (
|
||||
config('ec-rbd-metadata-pool') or
|
||||
f"{data_pool_name}-metadata"
|
||||
)
|
||||
else:
|
||||
pool_name = data_pool_name
|
||||
backend_name = config('volume-backend-name') or self.charm.app.name
|
||||
return {
|
||||
'cluster_name': self.charm.app.name,
|
||||
'rbd_pool': pool_name,
|
||||
'rbd_user': self.charm.app.name,
|
||||
'backend_name': backend_name,
|
||||
'backend_availability_zone': config('backend-availability-zone'),
|
||||
}
|
||||
|
||||
Configuring Charm to use custom config context
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The charm can append the new context onto those provided by the base class.
|
||||
|
||||
.. code:: python
|
||||
|
||||
class MyCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
||||
"""Charm the service."""
|
||||
|
||||
@property
|
||||
def config_contexts(self) -> List[sunbeam_ctxts.ConfigContext]:
|
||||
"""Configuration contexts for the operator."""
|
||||
contexts = super().config_contexts
|
||||
contexts.append(
|
||||
sunbeam_ctxts.CinderCephConfigurationContext(self, "cinder_ceph"))
|
||||
return contexts
|
132
ops-sunbeam/howto-pebble-handler.rst
Normal file
132
ops-sunbeam/howto-pebble-handler.rst
Normal file
@ -0,0 +1,132 @@
|
||||
=============================
|
||||
How-To Write a pebble handler
|
||||
=============================
|
||||
|
||||
A pebble handler sits between a charm and a container it manages. A pebble
|
||||
handler presents the charm with a consistent method of interaction with
|
||||
the container. For example the charm can query the handler to check config
|
||||
has been rendered and services started. It can call the `execute` method
|
||||
to run commands in the container or call `write_config` to render the
|
||||
defined files into the container.
|
||||
|
||||
Common Pebble handler changes
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
ASO provides a pebble handler base classes which provide the starting point
|
||||
for writing a new handler. If the container runs a service then the
|
||||
`ServicePebbleHandler` should be used. If the container does not provide a
|
||||
service (perhaps its just an environment for executing commands that affact
|
||||
other container) then `PebbleHandler` should be used.
|
||||
|
||||
.. code:: python
|
||||
|
||||
import container_handlers
|
||||
|
||||
class MyServicePebbleHandler(container_handlers.ServicePebbleHandler):
|
||||
"""Manage MyService Container."""
|
||||
|
||||
The handlers can create directories in the container once the pebble is
|
||||
available.
|
||||
|
||||
.. code:: python
|
||||
|
||||
@property
|
||||
def directories(self) -> List[sunbeam_chandlers.ContainerDir]:
|
||||
"""Directories to create in container."""
|
||||
return [
|
||||
sunbeam_chandlers.ContainerDir(
|
||||
'/var/log/my-service',
|
||||
'root',
|
||||
'root'),
|
||||
|
||||
In addition to directories the handler can list configuration files which need
|
||||
to be rendered into the container. These will be rendered as templates using
|
||||
all available contexts.
|
||||
|
||||
.. code:: python
|
||||
|
||||
def default_container_configs(
|
||||
self
|
||||
) -> List[sunbeam_core.ContainerConfigFile]:
|
||||
"""Files to render into containers."""
|
||||
return [
|
||||
sunbeam_core.ContainerConfigFile(
|
||||
'/etc/mysvc/mvsvc.conf',
|
||||
'root',
|
||||
'root')]
|
||||
|
||||
If a service should be running in the conainer the handler specifies the
|
||||
layer describing the service that will be passed to pebble.
|
||||
|
||||
.. code:: python
|
||||
|
||||
def get_layer(self) -> dict:
|
||||
"""Pebble configuration layer for MyService service."""
|
||||
return {
|
||||
"summary": "My service",
|
||||
"description": "Pebble config layer for MyService",
|
||||
"services": {
|
||||
'my_svc': {
|
||||
"override": "replace",
|
||||
"summary": "My Super Service",
|
||||
"command": "/usr/bin/my-svc",
|
||||
"startup": "disabled",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Advanced Pebble handler changes
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
By default the pebble handler is the observer of pebble events. If this
|
||||
behaviour needs to be altered then `setup_pebble_handler` method can be
|
||||
changed.
|
||||
|
||||
.. code:: python
|
||||
|
||||
def setup_pebble_handler(self) -> None:
|
||||
"""Configure handler for pebble ready event."""
|
||||
pass
|
||||
|
||||
Or perhaps it is ok for the pebble handler to observe the event but a
|
||||
different reaction is required. In this case the method associated
|
||||
with the event can be overridden.
|
||||
|
||||
.. code:: python
|
||||
|
||||
def _on_service_pebble_ready(
|
||||
self, event: ops.charm.PebbleReadyEvent
|
||||
) -> None:
|
||||
"""Handle pebble ready event."""
|
||||
container = event.workload
|
||||
container.add_layer(self.service_name, self.get_layer(), combine=True)
|
||||
self.execute(["run", "special", "command"])
|
||||
logger.debug(f"Plan: {container.get_plan()}")
|
||||
self.ready = True
|
||||
self._state.pebble_ready = True
|
||||
self.charm.configure_charm(event)
|
||||
|
||||
Configuring Charm to use custom pebble handler
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The charms `get_pebble_handlers` method dictates which pebble handlers are used.
|
||||
|
||||
.. code:: python
|
||||
|
||||
class MyCharmCharm(NeutronOperatorCharm):
|
||||
|
||||
def get_pebble_handlers(self) -> List[sunbeam_chandlers.PebbleHandler]:
|
||||
"""Pebble handlers for the service."""
|
||||
return [
|
||||
MyServicePebbleHandler(
|
||||
self,
|
||||
'my-server-container',
|
||||
self.service_name,
|
||||
self.container_configs,
|
||||
self.template_dir,
|
||||
self.openstack_release,
|
||||
self.configure_charm,
|
||||
)
|
||||
]
|
||||
|
142
ops-sunbeam/howto-relation-handler.rst
Normal file
142
ops-sunbeam/howto-relation-handler.rst
Normal file
@ -0,0 +1,142 @@
|
||||
===============================
|
||||
How-To Write a relation handler
|
||||
===============================
|
||||
|
||||
A relation handler gives the charm a consistent method of interacting with
|
||||
relation interfaces. It can also encapsulate common interface tasks, this
|
||||
removes the need for duplicate code across multiple charms.
|
||||
|
||||
This how-to will walk through the steps to write a database relation handler
|
||||
for the requires side.
|
||||
|
||||
In this database interface the database charm expects the client to provide the name
|
||||
of the database(s) to be created. To model this the relation handler will require
|
||||
the charm to specify the database name(s) when the class is instantiated
|
||||
|
||||
.. code:: python
|
||||
|
||||
|
||||
class DBHandler(RelationHandler):
|
||||
"""Handler for DB relations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
charm: ops.charm.CharmBase,
|
||||
relation_name: str,
|
||||
callback_f: Callable,
|
||||
databases: List[str] = None,
|
||||
) -> None:
|
||||
"""Run constructor."""
|
||||
self.databases = databases
|
||||
super().__init__(charm, relation_name, callback_f)
|
||||
|
||||
The handler initialises the interface with the database names and also sets up
|
||||
an observer for relation changed events.
|
||||
|
||||
.. code:: python
|
||||
|
||||
def setup_event_handler(self) -> ops.charm.Object:
|
||||
"""Configure event handlers for a MySQL relation."""
|
||||
logger.debug("Setting up DB event handler")
|
||||
# Lazy import to ensure this lib is only required if the charm
|
||||
# has this relation.
|
||||
import charms.sunbeam_mysql_k8s.v0.mysql as mysql
|
||||
db = mysql.MySQLConsumer(
|
||||
self.charm, self.relation_name, databases=self.databases
|
||||
)
|
||||
_rname = self.relation_name.replace("-", "_")
|
||||
db_relation_event = getattr(
|
||||
self.charm.on, f"{_rname}_relation_changed"
|
||||
)
|
||||
self.framework.observe(db_relation_event, self._on_database_changed)
|
||||
return db
|
||||
|
||||
The method run when tha changed event is seen checks whether all required data
|
||||
has been provided. If it is then it calls back to the charm, if not then no
|
||||
action is taken.
|
||||
|
||||
.. code:: python
|
||||
|
||||
def _on_database_changed(self, event: ops.framework.EventBase) -> None:
|
||||
"""Handle database change events."""
|
||||
databases = self.interface.databases()
|
||||
logger.info(f"Received databases: {databases}")
|
||||
if not self.ready:
|
||||
return
|
||||
self.callback_f(event)
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""Whether the handler is ready for use."""
|
||||
try:
|
||||
# Nothing to wait for
|
||||
return bool(self.interface.databases())
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
The `ready` property is common across all handlers and allows the charm to
|
||||
check the state of any relation in a consistent way.
|
||||
|
||||
The relation handlers also provide a context which can be used when rendering
|
||||
templates. ASO places each relation context in its own namespace.
|
||||
|
||||
.. code:: python
|
||||
|
||||
def context(self) -> dict:
|
||||
"""Context containing database connection data."""
|
||||
try:
|
||||
databases = self.interface.databases()
|
||||
except AttributeError:
|
||||
return {}
|
||||
if not databases:
|
||||
return {}
|
||||
ctxt = {}
|
||||
conn_data = {
|
||||
"database_host": self.interface.credentials().get("address"),
|
||||
"database_password": self.interface.credentials().get("password"),
|
||||
"database_user": self.interface.credentials().get("username"),
|
||||
"database_type": "mysql+pymysql",
|
||||
}
|
||||
|
||||
for db in self.interface.databases():
|
||||
ctxt[db] = {"database": db}
|
||||
ctxt[db].update(conn_data)
|
||||
connection = (
|
||||
"{database_type}://{database_user}:{database_password}"
|
||||
"@{database_host}/{database}")
|
||||
if conn_data.get("database_ssl_ca"):
|
||||
connection = connection + "?ssl_ca={database_ssl_ca}"
|
||||
if conn_data.get("database_ssl_cert"):
|
||||
connection = connection + (
|
||||
"&ssl_cert={database_ssl_cert}"
|
||||
"&ssl_key={database_ssl_key}")
|
||||
ctxt[db]["connection"] = str(connection.format(
|
||||
**ctxt[db]))
|
||||
return ctxt
|
||||
|
||||
Configuring Charm to use custom relation handler
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The base class will add the default relation handlers for any interfaces
|
||||
which do not yet have a handler. Therefore the custom handler is added to
|
||||
the list and then passed to the super method. The base charm class will
|
||||
see a handler already exists for shared-db and not add the default one.
|
||||
|
||||
.. code:: python
|
||||
|
||||
class MyCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
||||
"""Charm the service."""
|
||||
|
||||
def get_relation_handlers(self, handlers=None) -> List[
|
||||
sunbeam_rhandlers.RelationHandler]:
|
||||
"""Relation handlers for the service."""
|
||||
handlers = handlers or []
|
||||
if self.can_add_handler("shared-db", handlers):
|
||||
self.db = sunbeam_rhandlers.DBHandler(
|
||||
self, "shared-db", self.configure_charm, self.databases
|
||||
)
|
||||
handlers.append(self.db)
|
||||
handlers = super().get_relation_handlers(handlers)
|
||||
return handlers
|
||||
|
||||
|
@ -1,432 +0,0 @@
|
||||
=============
|
||||
New API Charm
|
||||
=============
|
||||
|
||||
The example below will walk through the creation of a basic API charm for the
|
||||
OpenStack `Glance <https://wiki.openstack.org/wiki/Glance>`__ service designed
|
||||
to run on kubernetes.
|
||||
|
||||
Create the skeleton charm
|
||||
=========================
|
||||
|
||||
Prerequisite
|
||||
~~~~~~~~~~~~
|
||||
|
||||
The charmcraft tool builds a skeleton charm.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
mkdir charm-glance-operator
|
||||
cd charm-glance-operator/
|
||||
charmcraft init --name sunbeam-glance-operator
|
||||
|
||||
Some useful files can be found in ASO so that needs
|
||||
to be available locally
|
||||
|
||||
.. code:: bash
|
||||
|
||||
git clone https://github.com/openstack-charmers/advanced-sunbeam-openstack
|
||||
|
||||
Amend charmcraft file to include git at build time:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
parts:
|
||||
charm:
|
||||
build-packages:
|
||||
- git
|
||||
|
||||
Add Metadata
|
||||
============
|
||||
|
||||
The first job is to write the metadata yaml.
|
||||
|
||||
.. code:: yaml
|
||||
|
||||
# Copyright 2021 Canonical Ltd
|
||||
# See LICENSE file for licensing details.
|
||||
name: sunbeam-glance-operator
|
||||
maintainer: OpenStack Charmers <openstack-charmers@lists.ubuntu.com>
|
||||
summary: OpenStack Image Registry and Delivery Service
|
||||
description: |
|
||||
The Glance project provides an image registration and discovery service
|
||||
and an image delivery service. These services are used in conjunction
|
||||
by Nova to deliver images.
|
||||
version: 3
|
||||
bases:
|
||||
- name: ubuntu
|
||||
channel: 20.04/stable
|
||||
tags:
|
||||
- openstack
|
||||
- storage
|
||||
- misc
|
||||
|
||||
containers:
|
||||
glance-api:
|
||||
resource: glance-api-image
|
||||
|
||||
resources:
|
||||
glance-api-image:
|
||||
type: oci-image
|
||||
description: OCI image for OpenStack Glance (kolla/glance-api-image)
|
||||
|
||||
requires:
|
||||
shared-db:
|
||||
interface: mysql_datastore
|
||||
limit: 1
|
||||
ingress:
|
||||
interface: ingress
|
||||
identity-service:
|
||||
interface: keystone
|
||||
limit: 1
|
||||
amqp:
|
||||
interface: rabbitmq
|
||||
image-service:
|
||||
interface: glance
|
||||
ceph:
|
||||
interface: ceph-client
|
||||
|
||||
peers:
|
||||
peers:
|
||||
interface: glance-peer
|
||||
|
||||
The first part of the metadata is pretty self explanatory, is sets out the some
|
||||
general information about the charm. The `containers` section lists all the
|
||||
containers that this charm will manage. Glance consists of just one container
|
||||
so just one container is listed here. Similarly in the resources section all
|
||||
the container images are listed. Since there is just one container only one
|
||||
image is listed here.
|
||||
|
||||
The requires section lists all the relations this charm is reliant on. These
|
||||
are all standard for an OpenStack API charm plus the additional ceph relation.
|
||||
|
||||
Common Files
|
||||
============
|
||||
|
||||
ASO contains some common files which need to copied into the charm.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
cp advanced-sunbeam-openstack/shared_code/tox.ini charm-glance-operator/
|
||||
cp advanced-sunbeam-openstack/shared_code/requirements.txt charm-glance-operator/
|
||||
cp -r advanced-sunbeam-openstack/shared_code/templates charm-glance-operator/src/
|
||||
cp advanced-sunbeam-openstack/shared_code/.stestr.conf charm-glance-operator/
|
||||
cp advanced-sunbeam-openstack/shared_code/test-requirements.txt charm-glance-operator/
|
||||
|
||||
At the moment the wsgi template needs to be renamed to add incluse the
|
||||
service name.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
cd charm-glance-operator
|
||||
mv /src/templates/wsgi-template.conf.j2 ./src/templates/wsgi-glance-api.conf.j2
|
||||
|
||||
There are some config options which are common accross the OpenStack api charms. Since
|
||||
this charm uses ceph add the ceph config options too.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
cd advanced-sunbeam-openstack/shared_code/
|
||||
echo "options:" > ../../charm-glance-operator/config.yaml
|
||||
cat config-api.yaml >> ../../charm-glance-operator/config.yaml
|
||||
cat config-ceph-options.yaml >> ../../charm-glance-operator/config.yaml
|
||||
|
||||
Fetch interface libs corresponding to the requires interfaces:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
cd charm-glance-operator
|
||||
charmcraft fetch-lib charms.nginx_ingress_integrator.v0.ingress
|
||||
charmcraft fetch-lib charms.sunbeam_mysql_k8s.v0.mysql
|
||||
charmcraft fetch-lib charms.sunbeam_keystone_operator.v0.identity_service
|
||||
charmcraft fetch-lib charms.sunbeam_rabbitmq_operator.v0.amqp
|
||||
charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch
|
||||
|
||||
Templates
|
||||
=========
|
||||
|
||||
Much of the glance configuration is covered by common templates which were copied
|
||||
into the charm in the previous step. The only additional template for this charm
|
||||
is for `glance-api.conf`. Add the following into `./src/templates/glance-api.conf.j2`
|
||||
|
||||
.. code::
|
||||
|
||||
###############################################################################
|
||||
# [ WARNING ]
|
||||
# glance configuration file maintained by Juju
|
||||
# local changes may be overwritten.
|
||||
###############################################################################
|
||||
[DEFAULT]
|
||||
debug = {{ options.debug }}
|
||||
transport_url = {{ amqp.transport_url }}
|
||||
|
||||
{% include "parts/section-database" %}
|
||||
|
||||
{% include "parts/section-identity" %}
|
||||
|
||||
|
||||
|
||||
[glance_store]
|
||||
default_backend = ceph
|
||||
filesystem_store_datadir = /var/lib/glance/images/
|
||||
|
||||
[ceph]
|
||||
rbd_store_chunk_size = 8
|
||||
rbd_store_pool = glance
|
||||
rbd_store_user = glance
|
||||
rados_connect_timeout = 0
|
||||
rbd_store_ceph_conf = /etc/ceph/ceph.conf
|
||||
|
||||
[paste_deploy]
|
||||
flavor = keystone
|
||||
|
||||
Charm
|
||||
=====
|
||||
|
||||
This is subject to change as more of the common code is generalised into aso.
|
||||
|
||||
Inherit from OSBaseOperatorAPICharm
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Start by creating a charm class that inherits from the `OSBaseOperatorAPICharm`
|
||||
class which contains all the code which is common accross OpenStack API charms.
|
||||
|
||||
.. code:: python
|
||||
|
||||
#!/usr/bin/env python3
|
||||
"""Glance Operator Charm.
|
||||
|
||||
This charm provide Glance services as part of an OpenStack deployment
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from ops.framework import StoredState
|
||||
from ops.main import main
|
||||
|
||||
import advanced_sunbeam_openstack.cprocess as sunbeam_cprocess
|
||||
import advanced_sunbeam_openstack.charm as sunbeam_charm
|
||||
import advanced_sunbeam_openstack.core as sunbeam_core
|
||||
import advanced_sunbeam_openstack.relation_handlers as sunbeam_rhandlers
|
||||
import advanced_sunbeam_openstack.config_contexts as sunbeam_ctxts
|
||||
|
||||
from charms.observability_libs.v0.kubernetes_service_patch \
|
||||
import KubernetesServicePatch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GlanceOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
||||
"""Charm the service."""
|
||||
|
||||
ceph_conf = "/etc/ceph/ceph.conf"
|
||||
|
||||
_state = StoredState()
|
||||
service_name = "glance-api"
|
||||
wsgi_admin_script = '/usr/bin/glance-wsgi-api'
|
||||
wsgi_public_script = '/usr/bin/glance-wsgi-api'
|
||||
|
||||
def __init__(self, framework):
|
||||
super().__init__(framework)
|
||||
self.service_patcher = KubernetesServicePatch(
|
||||
self,
|
||||
[
|
||||
('public', self.default_public_ingress_port),
|
||||
]
|
||||
)
|
||||
|
||||
The `KubernetesServicePatch` module is used to expose the service within kubernetes
|
||||
so that it is externally visable. Hopefully this will eventually be accomplished by
|
||||
Juju and and can be removed.
|
||||
|
||||
Ceph Support
|
||||
~~~~~~~~~~~~
|
||||
|
||||
This glance charm with relate to Ceph to store uploaded images. A relation to Ceph
|
||||
is not common accross the api charms to we need to add the components from ASO to
|
||||
support the ceph relation.
|
||||
|
||||
|
||||
.. code:: python
|
||||
|
||||
@property
|
||||
def config_contexts(self) -> List[sunbeam_ctxts.ConfigContext]:
|
||||
"""Configuration contexts for the operator."""
|
||||
contexts = super().config_contexts
|
||||
contexts.append(
|
||||
sunbeam_ctxts.CephConfigurationContext(self, "ceph_config"))
|
||||
return contexts
|
||||
|
||||
@property
|
||||
def container_configs(self) -> List[sunbeam_core.ContainerConfigFile]:
|
||||
"""Container configurations for the operator."""
|
||||
_cconfigs = super().container_configs
|
||||
_cconfigs.extend(
|
||||
[
|
||||
sunbeam_core.ContainerConfigFile(
|
||||
[self.service_name],
|
||||
self.ceph_conf,
|
||||
self.service_user,
|
||||
self.service_group,
|
||||
),
|
||||
]
|
||||
)
|
||||
return _cconfigs
|
||||
|
||||
def get_relation_handlers(self) -> List[sunbeam_rhandlers.RelationHandler]:
|
||||
"""Relation handlers for the service."""
|
||||
handlers = super().get_relation_handlers()
|
||||
self.ceph = sunbeam_rhandlers.CephClientHandler(
|
||||
self,
|
||||
"ceph",
|
||||
self.configure_charm,
|
||||
allow_ec_overwrites=True,
|
||||
app_name='rbd'
|
||||
)
|
||||
|
||||
|
||||
In the `config_contexts` `sunbeam_ctxts.CephConfigurationContext` is added to the list
|
||||
of config contexts. This will look after transalting some of the charms
|
||||
configuration options into Ceph configuration.
|
||||
|
||||
In `container_configs` the `ceph.conf` is added to the list of configuration
|
||||
files to be rendered in containers.
|
||||
|
||||
Finally in `get_relation_handlers` the relation handler for the `ceph` relation is
|
||||
added.
|
||||
|
||||
OpenStack Endpoints
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
`OSBaseOperatorAPICharm` makes assumptions based on the self.service_name but a few
|
||||
of these are broken as there is a mix between `glance` and `glance_api`. Finally the
|
||||
charm needs to specify what endpoint should be registered in the keystone catalgue
|
||||
each charm needs to explicitly state this as there is a lot of variation between
|
||||
services
|
||||
|
||||
.. code:: python
|
||||
|
||||
@property
|
||||
def service_conf(self) -> str:
|
||||
"""Service default configuration file."""
|
||||
return f"/etc/glance/glance-api.conf"
|
||||
|
||||
@property
|
||||
def service_user(self) -> str:
|
||||
"""Service user file and directory ownership."""
|
||||
return 'glance'
|
||||
|
||||
@property
|
||||
def service_group(self) -> str:
|
||||
"""Service group file and directory ownership."""
|
||||
return 'glance'
|
||||
|
||||
@property
|
||||
def service_endpoints(self):
|
||||
return [
|
||||
{
|
||||
'service_name': 'glance',
|
||||
'type': 'image',
|
||||
'description': "OpenStack Image",
|
||||
'internal_url': f'{self.internal_url}',
|
||||
'public_url': f'{self.public_url}',
|
||||
'admin_url': f'{self.admin_url}'}]
|
||||
|
||||
@property
|
||||
return 9292
|
||||
|
||||
Bootstrap
|
||||
~~~~~~~~~
|
||||
|
||||
Currently ASO does not support database migrations, this will be fixed soon but until
|
||||
then add a db sync to the bootstrap process.
|
||||
|
||||
.. code:: python
|
||||
|
||||
def _do_bootstrap(self):
|
||||
"""
|
||||
Starts the appropriate services in the order they are needed.
|
||||
If the service has not yet been bootstrapped, then this will
|
||||
1. Create the database
|
||||
"""
|
||||
super()._do_bootstrap()
|
||||
try:
|
||||
container = self.unit.get_container(self.wsgi_container_name)
|
||||
logger.info("Syncing database...")
|
||||
out = sunbeam_cprocess.check_output(
|
||||
container,
|
||||
[
|
||||
'sudo', '-u', 'glance',
|
||||
'glance-manage', '--config-dir',
|
||||
'/etc/glance', 'db', 'sync'],
|
||||
service_name='keystone-db-sync',
|
||||
timeout=180)
|
||||
logging.debug(f'Output from database sync: \n{out}')
|
||||
except sunbeam_cprocess.ContainerProcessError:
|
||||
logger.exception('Failed to bootstrap')
|
||||
self._state.bootstrapped = False
|
||||
return
|
||||
|
||||
Configure Charm
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
The container used by this charm should include `ceph-common` but it currently does
|
||||
not. To work around this install it in the container. As glance communicates with Ceph
|
||||
another specialisation is needed to run `ceph-authtool`.
|
||||
|
||||
|
||||
.. code:: python
|
||||
|
||||
def configure_charm(self, event) -> None:
|
||||
"""Catchall handler to cconfigure charm services."""
|
||||
if not self.relation_handlers_ready():
|
||||
logging.debug("Defering configuration, charm relations not ready")
|
||||
return
|
||||
|
||||
for ph in self.pebble_handlers:
|
||||
if ph.pebble_ready:
|
||||
container = self.unit.get_container(
|
||||
ph.container_name
|
||||
)
|
||||
sunbeam_cprocess.check_call(
|
||||
container,
|
||||
['apt', 'update'])
|
||||
sunbeam_cprocess.check_call(
|
||||
container,
|
||||
['apt', 'install', '-y', 'ceph-common'])
|
||||
try:
|
||||
sunbeam_cprocess.check_call(
|
||||
container,
|
||||
['ceph-authtool',
|
||||
f'/etc/ceph/ceph.client.{self.app.name}.keyring',
|
||||
'--create-keyring',
|
||||
f'--name=client.{self.app.name}',
|
||||
f'--add-key={self.ceph.key}']
|
||||
)
|
||||
except sunbeam_cprocess.ContainerProcessError:
|
||||
pass
|
||||
ph.init_service(self.contexts())
|
||||
|
||||
super().configure_charm(event)
|
||||
# Restarting services after bootstrap should be in aso
|
||||
if self._state.bootstrapped:
|
||||
for handler in self.pebble_handlers:
|
||||
handler.start_service()
|
||||
|
||||
OpenStack Release
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
This charm is spefic to a particular release so the final step is to add a
|
||||
release specific class.
|
||||
|
||||
.. code:: python
|
||||
|
||||
class GlanceWallabyOperatorCharm(GlanceOperatorCharm):
|
||||
|
||||
openstack_release = 'wallaby'
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Note: use_juju_for_storage=True required per
|
||||
# https://github.com/canonical/operator/issues/506
|
||||
main(GlanceWallabyOperatorCharm, use_juju_for_storage=True)
|
76
ops-sunbeam/shared_code/aso-charm-init.py
Executable file
76
ops-sunbeam/shared_code/aso-charm-init.py
Executable file
@ -0,0 +1,76 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import shutil
|
||||
import yaml
|
||||
import argparse
|
||||
import tempfile
|
||||
import os
|
||||
import glob
|
||||
from cookiecutter.main import cookiecutter
|
||||
import subprocess
|
||||
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
def start_msg():
|
||||
print("This tool is designed to be used after 'charmcraft init' was initially run")
|
||||
|
||||
def cookie(output_dir, extra_context):
|
||||
cookiecutter(
|
||||
'aso_charm/',
|
||||
extra_context=extra_context,
|
||||
output_dir=output_dir)
|
||||
|
||||
def arg_parser():
|
||||
parser = argparse.ArgumentParser(description='Process some integers.')
|
||||
parser.add_argument('charm_path', help='path to charm')
|
||||
return parser.parse_args()
|
||||
|
||||
def read_metadata_file(charm_dir):
|
||||
with open(f'{charm_dir}/metadata.yaml', 'r') as f:
|
||||
metadata = yaml.load(f, Loader=yaml.FullLoader)
|
||||
return metadata
|
||||
|
||||
def switch_dir():
|
||||
abspath = os.path.abspath(__file__)
|
||||
dname = os.path.dirname(abspath)
|
||||
os.chdir(dname)
|
||||
|
||||
def get_extra_context(charm_dir):
|
||||
metadata = read_metadata_file(charm_dir)
|
||||
charm_name = metadata['name']
|
||||
service_name = charm_name.replace('sunbeam-', '')
|
||||
service_name = service_name.replace('-operator', '')
|
||||
ctxt = {
|
||||
'service_name': service_name,
|
||||
'charm_name': charm_name}
|
||||
# XXX REMOVE
|
||||
ctxt['db_sync_command'] = 'ironic-dbsync --config-file /etc/ironic/ironic.conf create_schema'
|
||||
ctxt['ingress_port'] = 6385
|
||||
return ctxt
|
||||
|
||||
def sync_code(src_dir, target_dir):
|
||||
cmd = ['rsync', '-r', '-v', f'{src_dir}/', target_dir]
|
||||
subprocess.check_call(cmd)
|
||||
|
||||
def main() -> int:
|
||||
"""Echo the input arguments to standard output"""
|
||||
start_msg()
|
||||
args = arg_parser()
|
||||
charm_dir = args.charm_path
|
||||
switch_dir()
|
||||
with tempfile.TemporaryDirectory() as tmpdirname:
|
||||
extra_context = get_extra_context(charm_dir)
|
||||
service_name = extra_context['service_name']
|
||||
cookie(
|
||||
tmpdirname,
|
||||
extra_context)
|
||||
src_dir = f"{tmpdirname}/{service_name}"
|
||||
shutil.copyfile(
|
||||
f'{src_dir}/src/templates/wsgi-template.conf.j2',
|
||||
f'{src_dir}/src/templates/wsgi-{service_name}-api.conf')
|
||||
sync_code(src_dir, charm_dir)
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
9
ops-sunbeam/shared_code/aso_charm/cookiecutter.json
Normal file
9
ops-sunbeam/shared_code/aso_charm/cookiecutter.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"service_name": "",
|
||||
"charm_name": "",
|
||||
"ingress_port": "",
|
||||
"db_sync_command": "",
|
||||
"_copy_without_render": [
|
||||
"src/templates"
|
||||
]
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
# NOTE: no actions yet!
|
||||
{ }
|
@ -0,0 +1,17 @@
|
||||
type: "charm"
|
||||
bases:
|
||||
- build-on:
|
||||
- name: "ubuntu"
|
||||
channel: "20.04"
|
||||
run-on:
|
||||
- name: "ubuntu"
|
||||
channel: "20.04"
|
||||
parts:
|
||||
charm:
|
||||
build-packages:
|
||||
- git
|
||||
- libffi-dev
|
||||
- libssl-dev
|
||||
charm-python-packages:
|
||||
- setuptools < 58
|
||||
- cryptography < 3.4
|
@ -0,0 +1,27 @@
|
||||
options:
|
||||
debug:
|
||||
default: False
|
||||
description: Enable debug logging.
|
||||
type: boolean
|
||||
os-admin-hostname:
|
||||
default: glance.juju
|
||||
description: |
|
||||
The hostname or address of the admin endpoints that should be advertised
|
||||
in the glance image provider.
|
||||
type: string
|
||||
os-internal-hostname:
|
||||
default: glance.juju
|
||||
description: |
|
||||
The hostname or address of the internal endpoints that should be advertised
|
||||
in the glance image provider.
|
||||
type: string
|
||||
os-public-hostname:
|
||||
default: glance.juju
|
||||
description: |
|
||||
The hostname or address of the internal endpoints that should be advertised
|
||||
in the glance image provider.
|
||||
type: string
|
||||
region:
|
||||
default: RegionOne
|
||||
description: Space delimited list of OpenStack regions
|
||||
type: string
|
@ -0,0 +1,42 @@
|
||||
name: {{ cookiecutter.charm_name }}
|
||||
summary: OpenStack {{ cookiecutter.service_name }} service
|
||||
maintainer: OpenStack Charmers <openstack-charmers@lists.ubuntu.com>
|
||||
description: |
|
||||
OpenStack {{ cookiecutter.service_name }} provides an HTTP service for managing, selecting,
|
||||
and claiming providers of classes of inventory representing available
|
||||
resources in a cloud.
|
||||
.
|
||||
version: 3
|
||||
bases:
|
||||
- name: ubuntu
|
||||
channel: 20.04/stable
|
||||
tags:
|
||||
- openstack
|
||||
|
||||
containers:
|
||||
{{ cookiecutter.service_name }}-api:
|
||||
resource: {{ cookiecutter.service_name }}-api-image
|
||||
|
||||
resources:
|
||||
{{ cookiecutter.service_name }}-api-image:
|
||||
type: oci-image
|
||||
description: OCI image for OpenStack {{ cookiecutter.service_name }}
|
||||
|
||||
requires:
|
||||
shared-db:
|
||||
interface: mysql_datastore
|
||||
limit: 1
|
||||
identity-service:
|
||||
interface: keystone
|
||||
ingress:
|
||||
interface: ingress
|
||||
amqp:
|
||||
interface: rabbitmq
|
||||
|
||||
provides:
|
||||
{{ cookiecutter.service_name }}:
|
||||
interface: {{ cookiecutter.service_name }}
|
||||
|
||||
peers:
|
||||
peers:
|
||||
interface: {{ cookiecutter.service_name }}-peer
|
@ -1,6 +1,5 @@
|
||||
# ops >= 1.2.0
|
||||
ops
|
||||
jinja2
|
||||
git+https://github.com/canonical/operator@2875e73e#egg=ops
|
||||
git+https://opendev.org/openstack/charm-ops-openstack#egg=ops_openstack
|
||||
git+https://github.com/openstack-charmers/advanced-sunbeam-openstack#egg=advanced_sunbeam_openstack
|
||||
lightkube
|
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""{{ cookiecutter.service_name[0]|upper}}{{cookiecutter.service_name[1:] }} Operator Charm.
|
||||
|
||||
This charm provide {{ cookiecutter.service_name[0]|upper}}{{cookiecutter.service_name[1:] }} services as part of an OpenStack deployment
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from ops.framework import StoredState
|
||||
from ops.main import main
|
||||
|
||||
import advanced_sunbeam_openstack.charm as sunbeam_charm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class {{ cookiecutter.service_name[0]|upper}}{{cookiecutter.service_name[1:] }}OperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
||||
"""Charm the service."""
|
||||
|
||||
_state = StoredState()
|
||||
service_name = "{{ cookiecutter.service_name }}-api"
|
||||
wsgi_admin_script = '/usr/bin/{{ cookiecutter.service_name }}-api-wsgi'
|
||||
wsgi_public_script = '/usr/bin/{{ cookiecutter.service_name }}-api-wsgi'
|
||||
|
||||
db_sync_cmds = [
|
||||
{{ cookiecutter.db_sync_command.split() }}
|
||||
]
|
||||
|
||||
@property
|
||||
def service_conf(self) -> str:
|
||||
"""Service default configuration file."""
|
||||
return f"/etc/{{ cookiecutter.service_name }}/{{ cookiecutter.service_name }}.conf"
|
||||
|
||||
@property
|
||||
def service_user(self) -> str:
|
||||
"""Service user file and directory ownership."""
|
||||
return '{{ cookiecutter.service_name }}'
|
||||
|
||||
@property
|
||||
def service_group(self) -> str:
|
||||
"""Service group file and directory ownership."""
|
||||
return '{{ cookiecutter.service_name }}'
|
||||
|
||||
@property
|
||||
def service_endpoints(self):
|
||||
return [
|
||||
{
|
||||
'service_name': '{{ cookiecutter.service_name }}',
|
||||
'type': '{{ cookiecutter.service_name }}',
|
||||
'description': "OpenStack {{ cookiecutter.service_name[0]|upper}}{{cookiecutter.service_name[1:] }} API",
|
||||
'internal_url': f'{self.internal_url}',
|
||||
'public_url': f'{self.public_url}',
|
||||
'admin_url': f'{self.admin_url}'}]
|
||||
|
||||
@property
|
||||
def default_public_ingress_port(self):
|
||||
return {{ cookiecutter.ingress_port }}
|
||||
|
||||
|
||||
class {{ cookiecutter.service_name[0]|upper}}{{cookiecutter.service_name[1:] }}WallabyOperatorCharm({{ cookiecutter.service_name[0]|upper}}{{cookiecutter.service_name[1:] }}OperatorCharm):
|
||||
|
||||
openstack_release = 'wallaby'
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Note: use_juju_for_storage=True required per
|
||||
# https://github.com/canonical/operator/issues/506
|
||||
main({{ cookiecutter.service_name[0]|upper}}{{cookiecutter.service_name[1:] }}WallabyOperatorCharm, use_juju_for_storage=True)
|
@ -0,0 +1,2 @@
|
||||
[keystone_authtoken]
|
||||
{% include "parts/identity-data" %}
|
1
ops-sunbeam/shared_code/templates
Symbolic link
1
ops-sunbeam/shared_code/templates
Symbolic link
@ -0,0 +1 @@
|
||||
aso_charm/{{cookiecutter.service_name}}/src/templates
|
@ -1,2 +0,0 @@
|
||||
[keystone_authtoken]
|
||||
{% include "parts/identity-connection" %}
|
@ -42,6 +42,11 @@ deps =
|
||||
commands =
|
||||
./fetch-libs.sh
|
||||
|
||||
[testenv:cookie]
|
||||
basepython = python3
|
||||
deps = -r{toxinidir}/cookie-requirements.txt
|
||||
commands = /bin/true
|
||||
|
||||
[testenv:py3.8]
|
||||
basepython = python3.8
|
||||
deps = -r{toxinidir}/requirements.txt
|
||||
|
154
ops-sunbeam/writing-OS-API-charm.rst
Normal file
154
ops-sunbeam/writing-OS-API-charm.rst
Normal file
@ -0,0 +1,154 @@
|
||||
=============
|
||||
New API Charm
|
||||
=============
|
||||
|
||||
The example below will walk through the creation of a basic API charm for the
|
||||
OpenStack `Ironic <https://wiki.openstack.org/wiki/Ironic>`__ service designed
|
||||
to run on kubernetes.
|
||||
|
||||
Create the skeleton charm
|
||||
=========================
|
||||
|
||||
Prerequisite
|
||||
~~~~~~~~~~~~
|
||||
|
||||
Build a base geneeric charm with the `charmcraft` tool.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
mkdir charm-ironic-operator
|
||||
cd charm-ironic-operator
|
||||
charmcraft init --name sunbeam-ironic-operator
|
||||
|
||||
Add ASO common files to new charm. The script will ask a few basic questions:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
git clone https://github.com/openstack-charmers/advanced-sunbeam-openstack
|
||||
cd advanced-sunbeam-openstack/shared_code
|
||||
./aso-charm-init.sh ~/branches/charm-ironic-operator
|
||||
|
||||
This tool is designed to be used after 'charmcraft init' was initially run
|
||||
service_name [ironic]: ironic
|
||||
charm_name [sunbeam-ironic-operator]: sunbeam-ironic-operator
|
||||
ingress_port []: 6385
|
||||
db_sync_command [] ironic-dbsync --config-file /etc/ironic/ironic.conf create_schema:
|
||||
|
||||
Fetch interface libs corresponding to the requires interfaces:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
cd charm-ironic-operator
|
||||
charmcraft login
|
||||
charmcraft fetch-lib charms.nginx_ingress_integrator.v0.ingress
|
||||
charmcraft fetch-lib charms.sunbeam_mysql_k8s.v0.mysql
|
||||
charmcraft fetch-lib charms.sunbeam_keystone_operator.v0.identity_service
|
||||
charmcraft fetch-lib charms.sunbeam_rabbitmq_operator.v0.amqp
|
||||
charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch
|
||||
|
||||
Templates
|
||||
=========
|
||||
|
||||
Much of the service configuration is covered by common templates which were copied
|
||||
into the charm in the previous step. The only additional template for this charm
|
||||
is for `ironic.conf`. Add the following into `./src/templates/ironic.conf.j2`
|
||||
|
||||
.. code::
|
||||
|
||||
[DEFAULT]
|
||||
debug = {{ options.debug }}
|
||||
auth_strategy=keystone
|
||||
transport_url = {{ amqp.transport_url }}
|
||||
|
||||
[keystone_authtoken]
|
||||
{% include "parts/identity-data" %}
|
||||
|
||||
[database]
|
||||
{% include "parts/database-connection" %}
|
||||
|
||||
[neutron]
|
||||
{% include "parts/identity-data" %}
|
||||
|
||||
[glance]
|
||||
{% include "parts/identity-data" %}
|
||||
|
||||
[cinder]
|
||||
{% include "parts/identity-data" %}
|
||||
|
||||
[service_catalog]
|
||||
{% include "parts/identity-data" %}
|
||||
|
||||
|
||||
Make charm deployable
|
||||
=====================
|
||||
|
||||
The next step is to pack the charm into a deployable format
|
||||
|
||||
.. code:: bash
|
||||
|
||||
cd charm-ironic-operator
|
||||
charmcraft pack
|
||||
|
||||
|
||||
Deploy Charm
|
||||
============
|
||||
|
||||
The charm can now be deployed. The Kolla project has images that can be used to
|
||||
run the service. Juju can pull the image directly from dockerhub.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
juju deploy ./sunbeam-ironic-operator_ubuntu-20.04-amd64.charm --resource ironic-api-image=kolla/ubuntu-binary-ironic-api:wallaby ironic
|
||||
juju add-relation ironic mysql
|
||||
juju add-relation ironic keystone
|
||||
juju add-relation ironic rabbitmq
|
||||
|
||||
Test Service
|
||||
============
|
||||
|
||||
Check that the juju status shows the charms is active and no error messages are
|
||||
preset. Then check the ironic api service is reponding.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ juju status ironic
|
||||
Model Controller Cloud/Region Version SLA Timestamp
|
||||
ks micro microk8s/localhost 2.9.22 unsupported 13:31:41Z
|
||||
|
||||
App Version Status Scale Charm Store Channel Rev OS Address Message
|
||||
ironic active 1 sunbeam-ironic-operator local 0 kubernetes 10.152.183.73
|
||||
|
||||
Unit Workload Agent Address Ports Message
|
||||
ironic/0* active idle 10.1.155.106
|
||||
|
||||
$ curl http://10.1.155.106:6385 | jq '.'
|
||||
{
|
||||
"name": "OpenStack Ironic API",
|
||||
"description": "Ironic is an OpenStack project which aims to provision baremetal machines.",
|
||||
"default_version": {
|
||||
"id": "v1",
|
||||
"links": [
|
||||
{
|
||||
"href": "http://10.1.155.106:6385/v1/",
|
||||
"rel": "self"
|
||||
}
|
||||
],
|
||||
"status": "CURRENT",
|
||||
"min_version": "1.1",
|
||||
"version": "1.72"
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"id": "v1",
|
||||
"links": [
|
||||
{
|
||||
"href": "http://10.1.155.106:6385/v1/",
|
||||
"rel": "self"
|
||||
}
|
||||
],
|
||||
"status": "CURRENT",
|
||||
"min_version": "1.1",
|
||||
"version": "1.72"
|
||||
}
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user