feat(cert-manager): migrate to operator + add docs
diff --git a/atmosphere/cmd/operator.py b/atmosphere/cmd/operator.py
index c8dad4b..78cfc85 100644
--- a/atmosphere/cmd/operator.py
+++ b/atmosphere/cmd/operator.py
@@ -9,7 +9,7 @@
def main():
LOG.info("Starting Atmosphere operator")
- cfg = config.Config.load_from_file()
+ cfg = config.Config.from_file()
engine = flows.get_engine(cfg)
engine.run()
diff --git a/atmosphere/flows.py b/atmosphere/flows.py
index 43c254c..55d490b 100644
--- a/atmosphere/flows.py
+++ b/atmosphere/flows.py
@@ -3,7 +3,7 @@
from atmosphere.tasks import constants
from atmosphere.tasks.composite import openstack_helm
-from atmosphere.tasks.kubernetes import flux, v1
+from atmosphere.tasks.kubernetes import cert_manager, flux, v1
def get_engine(config):
@@ -88,9 +88,7 @@
version=constants.HELM_RELEASE_PXC_OPERATOR_VERSION,
values=constants.HELM_RELEASE_PXC_OPERATOR_VALUES,
),
- openstack_helm.ApplyPerconaXtraDBClusterTask(
- namespace=constants.NAMESPACE_OPENSTACK,
- ),
+ openstack_helm.ApplyPerconaXtraDBClusterTask(),
flux.ApplyHelmRepositoryTask(
namespace=constants.NAMESPACE_OPENSTACK,
name=constants.HELM_REPOSITORY_INGRESS_NGINX,
@@ -145,6 +143,9 @@
),
)
+ for t in cert_manager.ApplyIssuerTask.from_config(config.issuer):
+ flow.add(t)
+
if config.memcached.enabled:
flow.add(
openstack_helm.ApplyReleaseSecretTask(
diff --git a/atmosphere/models/config.py b/atmosphere/models/config.py
index 2e4c3bb..f5a7fe9 100644
--- a/atmosphere/models/config.py
+++ b/atmosphere/models/config.py
@@ -9,7 +9,9 @@
class AcmeIssuerSolverConfig(base.Model):
- type = types.StringType(choices=("http", "route53"), default="http", required=True)
+ type = types.StringType(
+ choices=("http", "rfc2136", "route53"), default="http", required=True
+ )
@classmethod
def _claim_polymorphic(cls, data):
@@ -20,17 +22,27 @@
TYPE = "http"
+class Rfc2136AcmeIssuerSolverConfig(AcmeIssuerSolverConfig):
+ TYPE = "rfc2136"
+
+ nameserver = types.StringType(required=True)
+ tsig_algorithm = types.StringType(required=True)
+ tsig_key_name = types.StringType(required=True)
+ tsig_secret = types.StringType(required=True)
+
+
class Route53AcmeIssuerSolverConfig(AcmeIssuerSolverConfig):
TYPE = "route53"
+ region = types.StringType(default="global", required=True)
+ hosted_zone_id = types.StringType(required=True)
access_key_id = types.StringType(required=True)
secret_access_key = types.StringType(required=True)
- hosted_zone_id = types.StringType(required=True)
class Issuer(base.Model):
type = types.StringType(
- choices=("self-signed", "acme"), default="acme", required=True
+ choices=("acme", "ca", "self-signed"), default="acme", required=True
)
@classmethod
@@ -44,10 +56,23 @@
email = types.StringType(required=True)
server = types.URLType(default="https://acme-v02.api.letsencrypt.org/directory")
solver = types.PolyModelType(
- [HttpAcmeIssuerSolverConfig, Route53AcmeIssuerSolverConfig], required=True
+ [
+ HttpAcmeIssuerSolverConfig,
+ Rfc2136AcmeIssuerSolverConfig,
+ Route53AcmeIssuerSolverConfig,
+ ],
+ default=HttpAcmeIssuerSolverConfig(),
+ required=True,
)
+class CaIssuerConfig(Issuer):
+ TYPE = "ca"
+
+ certificate = types.StringType(required=True)
+ private_key = types.StringType(required=True)
+
+
class SelfSignedIssuerConfig(Issuer):
TYPE = "self-signed"
@@ -68,12 +93,26 @@
memcached = types.ModelType(
MemcachedConfig, default=MemcachedConfig(), required=True
)
- issuer = types.DictType(
- types.PolyModelType([AcmeIssuerConfig, SelfSignedIssuerConfig])
+ issuer = types.PolyModelType(
+ [AcmeIssuerConfig, CaIssuerConfig, SelfSignedIssuerConfig],
+ default=AcmeIssuerConfig(),
+ required=True,
)
@classmethod
- def load_from_file(cls, path=CONFIG_FILE):
+ def from_toml(cls, data, validate=True):
+ c = cls(data, validate=validate)
+ if validate:
+ c.validate()
+ return c
+
+ @classmethod
+ def from_file(cls, path=CONFIG_FILE):
with open(path, "rb") as fd:
data = tomli.load(fd)
- return cls(data, validate=True)
+ return cls.from_toml(data)
+
+ @classmethod
+ def from_string(cls, data: str, validate=True):
+ data = tomli.loads(data)
+ return cls.from_toml(data, validate)
diff --git a/atmosphere/tasks/composite/openstack_helm.py b/atmosphere/tasks/composite/openstack_helm.py
index 7f189b1..22d221d 100644
--- a/atmosphere/tasks/composite/openstack_helm.py
+++ b/atmosphere/tasks/composite/openstack_helm.py
@@ -10,9 +10,7 @@
class ApplyReleaseSecretTask(v1.ApplySecretTask):
- def __init__(
- self, config: config.Config, namespace: str, chart: str, *args, **kwargs
- ):
+ def __init__(self, config: config.Config, namespace: str, chart: str):
vals = mergedeep.merge(
{},
values.Values.for_chart(chart, config).to_native(),
@@ -21,11 +19,9 @@
values_yaml = yaml.dump(vals, default_flow_style=False)
super().__init__(
- namespace,
- f"atmosphere-{chart}",
- {"values.yaml": base64.encode_as_text(values_yaml)},
- *args,
- **kwargs,
+ namespace=namespace,
+ name=f"atmosphere-{chart}",
+ data={"values.yaml": base64.encode_as_text(values_yaml)},
)
@@ -60,27 +56,25 @@
class ApplyPerconaXtraDBClusterTask(base.ApplyKubernetesObjectTask):
- def __init__(self, namespace: str):
+ def __init__(self):
super().__init__(
kind=PerconaXtraDBCluster,
- namespace=namespace,
+ namespace=constants.NAMESPACE_OPENSTACK,
name="percona-xtradb",
requires=[
- f"helm-release-{namespace}-{constants.HELM_RELEASE_PXC_OPERATOR_NAME}",
- "name",
+ f"helm-release-{constants.NAMESPACE_OPENSTACK}-{constants.HELM_RELEASE_PXC_OPERATOR_NAME}",
],
- inject={"name": "percona-xtradb"},
)
- def generate_object(self, namespace, name, **kwargs) -> PerconaXtraDBCluster:
+ def generate_object(self) -> PerconaXtraDBCluster:
return PerconaXtraDBCluster(
self.api,
{
"apiVersion": self._obj_kind.version,
"kind": self._obj_kind.kind,
"metadata": {
- "name": name,
- "namespace": namespace.name,
+ "name": self._obj_name,
+ "namespace": self._obj_namespace,
},
"spec": {
"crVersion": "1.10.0",
@@ -147,20 +141,18 @@
name=name,
requires=[
f"helm-release-{constants.NAMESPACE_OPENSTACK}-{constants.HELM_RELEASE_RABBITMQ_OPERATOR_NAME}",
- "name",
],
- inject={"name": name},
)
- def generate_object(self, namespace, name, **kwargs) -> RabbitmqCluster:
+ def generate_object(self) -> RabbitmqCluster:
return RabbitmqCluster(
self.api,
{
"apiVersion": self._obj_kind.version,
"kind": self._obj_kind.kind,
"metadata": {
- "name": f"rabbitmq-{name}",
- "namespace": namespace.name,
+ "name": f"rabbitmq-{self._obj_name}",
+ "namespace": self._obj_namespace,
},
"spec": {
"affinity": {
diff --git a/atmosphere/tasks/kubernetes/base.py b/atmosphere/tasks/kubernetes/base.py
index c675307..7159310 100644
--- a/atmosphere/tasks/kubernetes/base.py
+++ b/atmosphere/tasks/kubernetes/base.py
@@ -59,7 +59,7 @@
def execute(self, *args, **kwargs):
self.logger.debug("Ensuring resource")
- resource = self.generate_object(*args, **kwargs)
+ resource = self.generate_object()
resp = resource.api.patch(
**resource.api_kwargs(
headers={
diff --git a/atmosphere/tasks/kubernetes/cert_manager.py b/atmosphere/tasks/kubernetes/cert_manager.py
new file mode 100644
index 0000000..6d213a9
--- /dev/null
+++ b/atmosphere/tasks/kubernetes/cert_manager.py
@@ -0,0 +1,220 @@
+import pykube
+from oslo_serialization import base64
+
+from atmosphere.models import config
+from atmosphere.tasks import constants
+from atmosphere.tasks.kubernetes import base, v1
+
+
+class Certificate(pykube.objects.NamespacedAPIObject):
+ version = "cert-manager.io/v1"
+ endpoint = "certificates"
+ kind = "Certificate"
+
+
+class ApplyCertificateTask(base.ApplyKubernetesObjectTask):
+ def __init__(self, namespace: str, name: str, spec: dict):
+ self._spec = spec
+
+ super().__init__(
+ kind=Certificate,
+ namespace=namespace,
+ name=name,
+ requires=set(["namespace"]),
+ )
+
+ def generate_object(self) -> Certificate:
+ return Certificate(
+ self.api,
+ {
+ "apiVersion": self._obj_kind.version,
+ "kind": self._obj_kind.kind,
+ "metadata": {
+ "name": self._obj_name,
+ "namespace": self._obj_namespace,
+ },
+ "spec": self._spec,
+ },
+ )
+
+
+class Issuer(pykube.objects.NamespacedAPIObject):
+ version = "cert-manager.io/v1"
+ endpoint = "issuers"
+ kind = "Issuer"
+
+
+class ApplyIssuerTask(base.ApplyKubernetesObjectTask):
+ def __init__(self, namespace: str, name: str, spec: dict):
+ self._spec = spec
+
+ super().__init__(
+ kind=Issuer,
+ namespace=namespace,
+ name=name,
+ requires=set(
+ [
+ f"helm-release-{constants.NAMESPACE_CERT_MANAGER}-{constants.HELM_RELEASE_CERT_MANAGER_NAME}",
+ ]
+ ),
+ )
+
+ def generate_object(self) -> Issuer:
+ return Issuer(
+ self.api,
+ {
+ "apiVersion": self._obj_kind.version,
+ "kind": self._obj_kind.kind,
+ "metadata": {
+ "name": self._obj_name,
+ "namespace": self._obj_namespace,
+ },
+ "spec": self._spec,
+ },
+ )
+
+ @classmethod
+ def from_config(cls, config: config.Issuer) -> list:
+ objects = []
+
+ if config.type == "acme":
+ spec = {
+ "acme": {
+ "email": config.email,
+ "server": config.server,
+ "privateKeySecretRef": {
+ "name": "cert-manager-issuer-account-key",
+ },
+ },
+ }
+
+ if config.solver.type == "http":
+ spec["acme"]["solvers"] = [
+ {
+ "http01": {
+ "ingress": {
+ "class": "openstack",
+ },
+ },
+ },
+ ]
+ elif config.solver.type == "rfc2136":
+ # NOTE(mnaser): We have to create a secret containing the AWS
+ # credentials in this case.
+ objects.append(
+ v1.ApplySecretTask(
+ constants.NAMESPACE_OPENSTACK,
+ "cert-manager-issuer-tsig-secret-key",
+ data={
+ "tsig-secret-key": base64.encode_as_text(
+ config.solver.tsig_secret
+ ),
+ },
+ )
+ )
+
+ spec["acme"]["solvers"] = [
+ {
+ "dns01": {
+ "rfc2136": {
+ "nameserver": config.solver.nameserver,
+ "tsigAlgorithm": config.solver.tsig_algorithm,
+ "tsigKeyName": config.solver.tsig_key_name,
+ "tsigSecretSecretRef": {
+ "name": "cert-manager-issuer-tsig-secret-key",
+ "key": "tsig-secret-key",
+ },
+ },
+ },
+ },
+ ]
+ elif config.solver.type == "route53":
+ # NOTE(mnaser): We have to create a secret containing the AWS
+ # credentials in this case.
+ objects.append(
+ v1.ApplySecretTask(
+ constants.NAMESPACE_OPENSTACK,
+ "cert-manager-issuer-route53-credentials",
+ data={
+ "secret-access-key": base64.encode_as_text(
+ config.solver.secret_access_key
+ ),
+ },
+ )
+ )
+
+ spec["acme"]["solvers"] = [
+ {
+ "dns01": {
+ "route53": {
+ "region": config.solver.region,
+ "hostedZoneID": config.solver.hosted_zone_id,
+ "accessKeyID": config.solver.access_key_id,
+ "secretAccessKeySecretRef": {
+ "name": "cert-manager-issuer-route53-credentials",
+ "key": "secret-access-key",
+ },
+ },
+ },
+ },
+ ]
+ elif config.type == "ca":
+ # NOTE(mnaser): We have to create a secret containing the CA
+ # certificate and key in this case.
+ objects.append(
+ v1.ApplySecretTask(
+ constants.NAMESPACE_OPENSTACK,
+ "cert-manager-issuer-ca",
+ data={
+ "tls.crt": base64.encode_as_text(config.certificate),
+ "tls.key": base64.encode_as_text(config.private_key),
+ },
+ )
+ )
+
+ spec = {
+ "ca": {
+ "secretName": "cert-manager-issuer-ca",
+ }
+ }
+ elif config.type == "self-signed":
+ # NOTE(mnaser): We have to setup the self-signed CA in this case
+ objects += [
+ ApplyIssuerTask(
+ namespace=constants.NAMESPACE_OPENSTACK,
+ name="self-signed",
+ spec={
+ "selfSigned": {},
+ },
+ ),
+ ApplyCertificateTask(
+ namespace=constants.NAMESPACE_OPENSTACK,
+ name="self-signed-ca",
+ spec={
+ "isCA": True,
+ "commonName": "selfsigned-ca",
+ "secretName": "cert-manager-selfsigned-ca",
+ "duration": "86400h",
+ "renewBefore": "360h",
+ "privateKey": {"algorithm": "ECDSA", "size": 256},
+ "issuerRef": {
+ "name": "self-signed",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ "kind": "Issuer",
+ "group": "cert-manager.io",
+ },
+ },
+ ),
+ ]
+
+ spec = {
+ "ca": {
+ "secretName": "cert-manager-selfsigned-ca",
+ }
+ }
+
+ return objects + [
+ ApplyIssuerTask(
+ namespace=constants.NAMESPACE_OPENSTACK, name="openstack", spec=spec
+ )
+ ]
diff --git a/atmosphere/tasks/kubernetes/flux.py b/atmosphere/tasks/kubernetes/flux.py
index fb66487..be3edcb 100644
--- a/atmosphere/tasks/kubernetes/flux.py
+++ b/atmosphere/tasks/kubernetes/flux.py
@@ -15,32 +15,29 @@
class ApplyHelmRepositoryTask(base.ApplyKubernetesObjectTask):
- def __init__(self, namespace: str, name: str, url: str, *args, **kwargs):
+ def __init__(self, namespace: str, name: str, url: str):
+ self._url = url
+
super().__init__(
- HelmRepository,
- namespace,
- name,
- requires=set(["namespace", "name", "url"]),
- inject={"name": name, "url": url},
- *args,
- **kwargs,
+ kind=HelmRepository,
+ namespace=namespace,
+ name=name,
+ requires=set(["namespace"]),
)
- def generate_object(
- self, namespace: pykube.Namespace, name: str, url: str, *args, **kwargs
- ):
+ def generate_object(self) -> HelmRepository:
return HelmRepository(
self.api,
{
- "apiVersion": "source.toolkit.fluxcd.io/v1beta2",
- "kind": "HelmRepository",
+ "apiVersion": self._obj_kind.version,
+ "kind": self._obj_kind.kind,
"metadata": {
- "name": name,
- "namespace": namespace.name,
+ "name": self._obj_name,
+ "namespace": self._obj_namespace,
},
"spec": {
"interval": "1m",
- "url": url,
+ "url": self._url,
},
},
)
@@ -65,68 +62,43 @@
*args,
**kwargs,
):
+ self._repository = repository
+ self._chart = chart
+ self._version = version
+ self._values = values
+ self._values_from = values_from
+
kwargs.setdefault("requires", set())
- kwargs["requires"] = kwargs["requires"].union(
- set(
- [
- "namespace",
- "name",
- "repository",
- "chart",
- "version",
- "values",
- "values_from",
- ]
- )
- )
+ kwargs["requires"] = kwargs["requires"].union(set(["repository"]))
super().__init__(
- HelmRelease,
- namespace,
- name,
+ kind=HelmRelease,
+ namespace=namespace,
+ name=name,
rebind={"repository": f"helm-repository-{namespace}-{repository}"},
- inject={
- "name": name,
- "repository": repository,
- "chart": chart,
- "version": version,
- "values": values,
- "values_from": values_from,
- },
*args,
**kwargs,
)
- def generate_object(
- self,
- namespace: pykube.Namespace,
- name: str,
- repository: HelmRepository,
- chart: str,
- version: str,
- values: dict,
- values_from: list,
- *args,
- **kwargs,
- ) -> HelmRelease:
+ def generate_object(self) -> HelmRelease:
return HelmRelease(
self.api,
{
- "apiVersion": "helm.toolkit.fluxcd.io/v2beta1",
- "kind": "HelmRelease",
+ "apiVersion": self._obj_kind.version,
+ "kind": self._obj_kind.kind,
"metadata": {
- "name": name,
- "namespace": namespace.name,
+ "name": self._obj_name,
+ "namespace": self._obj_namespace,
},
"spec": {
"interval": "60s",
"chart": {
"spec": {
- "chart": chart,
- "version": version,
+ "chart": self._chart,
+ "version": self._version,
"sourceRef": {
"kind": "HelmRepository",
- "name": repository.name,
+ "name": self._repository,
},
}
},
@@ -136,8 +108,8 @@
"upgrade": {
"disableWait": True,
},
- "values": values,
- "valuesFrom": values_from,
+ "values": self._values,
+ "valuesFrom": self._values_from,
},
},
)
diff --git a/atmosphere/tasks/kubernetes/v1.py b/atmosphere/tasks/kubernetes/v1.py
index d183d0c..fa205ae 100644
--- a/atmosphere/tasks/kubernetes/v1.py
+++ b/atmosphere/tasks/kubernetes/v1.py
@@ -7,86 +7,69 @@
class ApplyNamespaceTask(base.ApplyKubernetesObjectTask):
- def __init__(self, name: str, *args, **kwargs):
- super().__init__(
- pykube.Namespace,
- None,
- name,
- requires=set(["name"]),
- inject={"name": name},
- *args,
- **kwargs,
- )
+ def __init__(self, name: str):
+ super().__init__(kind=pykube.Namespace, namespace=None, name=name)
- def generate_object(self, name, *args, **kwargs):
+ def generate_object(self) -> pykube.Namespace:
return pykube.Namespace(
self.api,
{
- "apiVersion": "v1",
- "kind": "Namespace",
- "metadata": {"name": name},
+ "apiVersion": self._obj_kind.version,
+ "kind": self._obj_kind.kind,
+ "metadata": {"name": self._obj_name},
},
)
class ApplyServiceTask(base.ApplyKubernetesObjectTask):
def __init__(self, namespace: str, name: str, labels: dict, spec: dict):
+ self._labels = labels
+ self._spec = spec
+
super().__init__(
- pykube.Service,
- namespace,
- name,
- requires=set(["namespace", "name", "labels", "spec"]),
- inject={"name": name, "labels": labels, "spec": spec},
+ kind=pykube.Service,
+ namespace=namespace,
+ name=name,
+ requires=set(["namespace"]),
)
- def generate_object(
- self,
- namespace: pykube.Namespace,
- name: str,
- labels: dict,
- spec: dict,
- *args,
- **kwargs,
- ) -> pykube.Service:
+ def generate_object(self) -> pykube.Service:
return pykube.Service(
self.api,
{
- "apiVersion": "v1",
- "kind": "Service",
+ "apiVersion": self._obj_kind.version,
+ "kind": self._obj_kind.kind,
"metadata": {
- "name": name,
- "namespace": namespace.name,
- "labels": labels,
+ "name": self._obj_name,
+ "namespace": self._obj_namespace,
+ "labels": self._labels,
},
- "spec": spec,
+ "spec": self._spec,
},
)
class ApplySecretTask(base.ApplyKubernetesObjectTask):
- def __init__(self, namespace: str, name: str, data: str, *args, **kwargs):
+ def __init__(self, namespace: str, name: str, data: str):
+ self._data = data
+
super().__init__(
- pykube.Secret,
- namespace,
- name,
- requires=set(["namespace", "name", "data"]),
- inject={"name": name, "data": data},
- *args,
- **kwargs,
+ kind=pykube.Secret,
+ namespace=namespace,
+ name=name,
+ requires=set(["namespace"]),
)
- def generate_object(
- self, namespace: pykube.Namespace, name: str, data: dict, *args, **kwargs
- ):
+ def generate_object(self) -> pykube.Secret:
return pykube.Secret(
self.api,
{
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
- "name": name,
- "namespace": namespace.name,
+ "name": self._obj_name,
+ "namespace": self._obj_namespace,
},
- "data": data,
+ "data": self._data,
},
)
diff --git a/atmosphere/tests/e2e/test_operator.py b/atmosphere/tests/e2e/test_operator.py
index 567e5a7..65d03ee 100644
--- a/atmosphere/tests/e2e/test_operator.py
+++ b/atmosphere/tests/e2e/test_operator.py
@@ -35,7 +35,10 @@
"atmosphere_config": {
"memcached": {
"secret_key": "foobar",
- }
+ },
+ "issuer": {
+ "email": "test@example.com",
+ },
},
}
diff --git a/atmosphere/tests/unit/tasks/kubernetes/test_cert_manager.py b/atmosphere/tests/unit/tasks/kubernetes/test_cert_manager.py
new file mode 100644
index 0000000..870f443
--- /dev/null
+++ b/atmosphere/tests/unit/tasks/kubernetes/test_cert_manager.py
@@ -0,0 +1,310 @@
+import textwrap
+
+import pykube
+import pytest
+from oslo_serialization import base64
+
+from atmosphere.models import config
+from atmosphere.tasks import constants
+from atmosphere.tasks.kubernetes import cert_manager
+
+
+@pytest.mark.parametrize(
+ "cfg_data,expected",
+ [
+ pytest.param(
+ textwrap.dedent(
+ """\
+ [issuer]
+ email = "mnaser@vexxhost.com"
+ """
+ ),
+ [
+ {
+ "apiVersion": cert_manager.Issuer.version,
+ "kind": cert_manager.Issuer.kind,
+ "metadata": {
+ "name": "openstack",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ },
+ "spec": {
+ "acme": {
+ "email": "mnaser@vexxhost.com",
+ "server": "https://acme-v02.api.letsencrypt.org/directory",
+ "privateKeySecretRef": {
+ "name": "cert-manager-issuer-account-key",
+ },
+ "solvers": [
+ {
+ "http01": {
+ "ingress": {
+ "class": "openstack",
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ id="default",
+ ),
+ pytest.param(
+ textwrap.dedent(
+ """\
+ [issuer]
+ email = "mnaser@vexxhost.com"
+
+ [issuer.solver]
+ type = "rfc2136"
+ nameserver = "1.2.3.4:53"
+ tsig_algorithm = "hmac-sha256"
+ tsig_key_name = "foobar"
+ tsig_secret = "secret123"
+ """
+ ),
+ [
+ {
+ "apiVersion": pykube.Secret.version,
+ "kind": pykube.Secret.kind,
+ "metadata": {
+ "name": "cert-manager-issuer-tsig-secret-key",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ },
+ "data": {
+ "tsig-secret-key": base64.encode_as_text("secret123"),
+ },
+ },
+ {
+ "apiVersion": cert_manager.Issuer.version,
+ "kind": cert_manager.Issuer.kind,
+ "metadata": {
+ "name": "openstack",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ },
+ "spec": {
+ "acme": {
+ "email": "mnaser@vexxhost.com",
+ "server": "https://acme-v02.api.letsencrypt.org/directory",
+ "privateKeySecretRef": {
+ "name": "cert-manager-issuer-account-key",
+ },
+ "solvers": [
+ {
+ "dns01": {
+ "rfc2136": {
+ "nameserver": "1.2.3.4:53",
+ "tsigAlgorithm": "hmac-sha256",
+ "tsigKeyName": "foobar",
+ "tsigSecretSecretRef": {
+ "name": "cert-manager-issuer-tsig-secret-key",
+ "key": "tsig-secret-key",
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ id="rfc2136",
+ ),
+ pytest.param(
+ textwrap.dedent(
+ """\
+ [issuer]
+ email = "mnaser@vexxhost.com"
+
+ [issuer.solver]
+ type = "route53"
+ hosted_zone_id = "Z3A4X2Y2Y3"
+ access_key_id = "AKIAIOSFODNN7EXAMPLE"
+ secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+ """
+ ),
+ [
+ {
+ "apiVersion": pykube.Secret.version,
+ "kind": pykube.Secret.kind,
+ "metadata": {
+ "name": "cert-manager-issuer-route53-credentials",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ },
+ "data": {
+ "secret-access-key": base64.encode_as_text(
+ "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+ ),
+ },
+ },
+ {
+ "apiVersion": cert_manager.Issuer.version,
+ "kind": cert_manager.Issuer.kind,
+ "metadata": {
+ "name": "openstack",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ },
+ "spec": {
+ "acme": {
+ "email": "mnaser@vexxhost.com",
+ "server": "https://acme-v02.api.letsencrypt.org/directory",
+ "privateKeySecretRef": {
+ "name": "cert-manager-issuer-account-key",
+ },
+ "solvers": [
+ {
+ "dns01": {
+ "route53": {
+ "region": "global",
+ "hostedZoneID": "Z3A4X2Y2Y3",
+ "accessKeyID": "AKIAIOSFODNN7EXAMPLE",
+ "secretAccessKeySecretRef": {
+ "name": "cert-manager-issuer-route53-credentials",
+ "key": "secret-access-key",
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ id="route53",
+ ),
+ pytest.param(
+ textwrap.dedent(
+ """\
+ [issuer]
+ type = "ca"
+ certificate = '''
+ -----BEGIN CERTIFICATE-----
+ MIIDBjCCAe4CCQDQ3Z0Z2Z0Z0jANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC
+ VVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28x
+ ...
+ -----END CERTIFICATE-----
+ '''
+ private_key = '''
+ -----BEGIN RSA PRIVATE KEY-----
+ MIIEpAIBAAKCAQEAw3Z0Z2Z0Z0jANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC
+ VVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28x
+ ...
+ -----END RSA PRIVATE KEY-----
+ '''
+ """
+ ),
+ [
+ {
+ "apiVersion": pykube.Secret.version,
+ "kind": pykube.Secret.kind,
+ "metadata": {
+ "name": "cert-manager-issuer-ca",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ },
+ "data": {
+ "tls.crt": base64.encode_as_text(
+ textwrap.dedent(
+ """\
+ -----BEGIN CERTIFICATE-----
+ MIIDBjCCAe4CCQDQ3Z0Z2Z0Z0jANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC
+ VVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28x
+ ...
+ -----END CERTIFICATE-----
+ """
+ )
+ ),
+ "tls.key": base64.encode_as_text(
+ textwrap.dedent(
+ """\
+ -----BEGIN RSA PRIVATE KEY-----
+ MIIEpAIBAAKCAQEAw3Z0Z2Z0Z0jANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC
+ VVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28x
+ ...
+ -----END RSA PRIVATE KEY-----
+ """
+ )
+ ),
+ },
+ },
+ {
+ "apiVersion": cert_manager.Issuer.version,
+ "kind": cert_manager.Issuer.kind,
+ "metadata": {
+ "name": "openstack",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ },
+ "spec": {
+ "ca": {
+ "secretName": "cert-manager-issuer-ca",
+ },
+ },
+ },
+ ],
+ id="ca",
+ ),
+ pytest.param(
+ textwrap.dedent(
+ """\
+ [issuer]
+ type = "self-signed"
+ """
+ ),
+ [
+ {
+ "apiVersion": cert_manager.Issuer.version,
+ "kind": cert_manager.Issuer.kind,
+ "metadata": {
+ "name": "self-signed",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ },
+ "spec": {
+ "selfSigned": {},
+ },
+ },
+ {
+ "apiVersion": cert_manager.Certificate.version,
+ "kind": cert_manager.Certificate.kind,
+ "metadata": {
+ "name": "self-signed-ca",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ },
+ "spec": {
+ "isCA": True,
+ "commonName": "selfsigned-ca",
+ "secretName": "cert-manager-selfsigned-ca",
+ "duration": "86400h",
+ "renewBefore": "360h",
+ "privateKey": {"algorithm": "ECDSA", "size": 256},
+ "issuerRef": {
+ "name": "self-signed",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ "kind": "Issuer",
+ "group": "cert-manager.io",
+ },
+ },
+ },
+ {
+ "apiVersion": cert_manager.Issuer.version,
+ "kind": cert_manager.Issuer.kind,
+ "metadata": {
+ "name": "openstack",
+ "namespace": constants.NAMESPACE_OPENSTACK,
+ },
+ "spec": {
+ "ca": {
+ "secretName": "cert-manager-selfsigned-ca",
+ },
+ },
+ },
+ ],
+ id="self-signed",
+ ),
+ ],
+)
+def test_apply_issuer_task_from_config(pykube, cfg_data, expected):
+ cfg = config.Config.from_string(cfg_data, validate=False)
+ cfg.issuer.validate()
+ assert [
+ t.generate_object().obj
+ for t in cert_manager.ApplyIssuerTask.from_config(cfg.issuer)
+ ] == expected
diff --git a/docs/certificates.md b/docs/certificates.md
index 8beefbe..7975116 100644
--- a/docs/certificates.md
+++ b/docs/certificates.md
@@ -1,88 +1,113 @@
# Certificates
-## Using LetsEncrypt DNS challenges
+Atmosphere simplifies all the management of your SSL certificates for all of
+your API endpoints by automatically issuing and renewing certificates for you.
-### RFC2136
+## ACME
+
+Atmosphere uses the [ACME](https://tools.ietf.org/html/rfc8555) protocol by
+default to request certificates from [LetsEncrypt](https://letsencrypt.org/).
+
+This is configured to work out of the box if your APIs are publicly accessible,
+you just need to configure an email address.
+
+```yaml
+atmosphere_issuer_config:
+ email: foo@bar.com
+```
+
+If you're running your own internal ACME server, you can configure Atmosphere to
+point towards it by setting the `server` field.
+
+```yaml
+atmosphere_issuer_config:
+ server: https://acme.example.com
+ email: foo@bar.com
+```
+
+### DNS-01 challenges
+
+Atmosphere uses the `HTTP-01` solver by default, which means that as long as
+your ACME server can reach your API, you don't need to do anything else.
+
+If your ACME server cannot reach your API, you will need to use the DNS-01
+challenges which require you to configure your DNS provider.
+
+#### RFC2136
If you have DNS server that supports RFC2136, you can use it to solve the DNS
-challenges, you'll need to have the following information:
-
-- Email address
-- Nameserver IP address
-- TSIG Algorithm
-- TSIG Key Name
-- TSIG Key Secret
-
-You'll need to update your Ansible inventory to be the following:
+challenges, you can use the following configuration:
```yaml
-cert_manager_issuer:
- acme:
- email: <EMAIL>
- privateKeySecretRef:
- name: letsencrypt-prod
- server: https://acme-v02.api.letsencrypt.org/directory
- solvers:
- - dns01:
- rfc2136:
- nameserver: <NS>:<PORT>
- tsigAlgorithm: <ALGORITHM>
- tsigKeyName: <NAME>
- tsigSecretSecretRef:
- key: tsig-secret-key
- name: tsig-secret
+atmosphere_issuer_config:
+ solver:
+ type: rfc2136
+ nameserver: <NAMESERVER>:<PORT>
+ tsig_algorithm: <ALGORITHM>
+ tsig_key_name: <NAME>
+ tsig_secret: <SECRET>
```
-After you're done, you'll need to add a new secret to the Kubernetes cluster,
-you will need to do it by using the following YAML file:
+#### Route53
+
+If you are using Route53 to host the DNS for your domains, you can use the
+following configuration:
```yaml
-apiVersion: v1
-kind: Secret
-metadata:
- name: tsig-secret
- namespace: openstack
-type: Opaque
-stringData:
- tsig-secret-key: <KEY>
+atmosphere_issuer_config:
+ email: foo@bar.com
+ solver:
+ type: route53
+ hosted_zone_id: <HOSTED_ZONE_ID>
+ access_key_id: <AWS_ACCESS_KEY_ID>
+ secret_access_key: <AWS_SECRET_ACCESS_KEY>
```
-## Using self-signed certificates
+!!! note
+
+ You'll need to make sure that your AWS credentials have the correct
+ permissions to update the Route53 zone.
+
+## Using pre-existing CA
+
+If you have an existing CA that you'd like to use with Atmosphere, you can
+simply configure it by including the certificate and private key:
+
+```yaml
+atmosphere_issuer_config:
+ type: ca
+ certificate: |
+ -----BEGIN CERTIFICATE-----
+ MIIDBjCCAe4CCQDQ3Z0Z2Z0Z0jANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC
+ VVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28x
+ ...
+ -----END CERTIFICATE-----
+ private_key: |
+ -----BEGIN RSA PRIVATE KEY-----
+ MIIEpAIBAAKCAQEAw3Z0Z2Z0Z0jANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC
+ VVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28x
+ ...
+ -----END RSA PRIVATE KEY-----
+```
+
+!!! note
+
+ If your issuer is an intermediate certificate, you will need to ensure that
+ they `certificate` key includes the full chain in the correct order of issuer,
+ intermediate(s) then root.
+
+## Self-signed certificates
If you are in an environment which does not have a trusted certificate authority
and it does not have access to the internet to be able to use LetsEncrypt, you
can use self-signed certificates by adding the following to your inventory:
```yaml
-cert_manager_issuer:
- ca:
- secretName: root-secret
+atmosphere_issuer_config:
+ type: self-signed
```
-## Using pre-existing CA
+!!! warning
-If you have your own CA and want to use it, you will need to update your Ansible inventory to be the following:
-
-```yaml
-cert_manager_issuer:
- ca:
- secretName: custom-openstack-ca-key-pair
-```
-
-After you're done, you'll need to add a new secret to the Kubernetes cluster,
-you will need to do it by using the following YAML file:
-
-```yaml
-apiVersion: v1
-kind: Secret
-metadata:
- name: custom-openstack-ca-key-pair
- namespace: openstack
-type: Opaque
-stringData:
- tls.crt: |
- CA_CERTIFICATE_HERE
- tls.key: |
- CA_PRIVATE_KEY_HERE
-```
-NOTE: If your issuer represents an intermediate, ensure that tls.crt contains the issuer's full chain in the correct order: issuer -> intermediate(s) -> root.
+ Self-signed certificates are not recommended for production environments,
+ they are only recommended for development and testing environments.
diff --git a/molecule/default/group_vars/all/molecule.yml b/molecule/default/group_vars/all/molecule.yml
index eed6fd7..cb7ce00 100644
--- a/molecule/default/group_vars/all/molecule.yml
+++ b/molecule/default/group_vars/all/molecule.yml
@@ -1,8 +1,7 @@
atmosphere_image: "{{ lookup('file', lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') + '/image') }}"
-cert_manager_issuer:
- ca:
- secretName: root-secret
+atmosphere_issuer_config:
+ type: self-signed
openstack_helm_glance_images:
- name: cirros
diff --git a/playbooks/openstack.yml b/playbooks/openstack.yml
index 953551a..8fa8ece 100644
--- a/playbooks/openstack.yml
+++ b/playbooks/openstack.yml
@@ -52,10 +52,6 @@
tags:
- prometheus-pushgateway
- - role: cert_manager
- tags:
- - cert-manager
-
- role: keepalived
tags:
- keepalived
diff --git a/roles/atmosphere/defaults/main.yml b/roles/atmosphere/defaults/main.yml
index 5f20539..79c15e7 100644
--- a/roles/atmosphere/defaults/main.yml
+++ b/roles/atmosphere/defaults/main.yml
@@ -4,3 +4,4 @@
memcached:
secret_key: "{{ openstack_helm_endpoints_memcached_secret_key }}"
overrides: "{{ openstack_helm_infra_memcached_values | default({}) }}"
+ issuer: "{{ atmosphere_issuer_config }}"
diff --git a/roles/atmosphere/templates/role.yml b/roles/atmosphere/templates/role.yml
index 47a0361..1a0a38e 100644
--- a/roles/atmosphere/templates/role.yml
+++ b/roles/atmosphere/templates/role.yml
@@ -14,3 +14,6 @@
- apiGroups: ["rabbitmq.com"]
resources: ["rabbitmqclusters"]
verbs: ["get", "create", "patch"]
+ - apiGroups: ["cert-manager.io"]
+ resources: ["certificates", "issuers"]
+ verbs: ["get", "create", "patch"]
diff --git a/roles/cert_manager/README.md b/roles/cert_manager/README.md
deleted file mode 100644
index 77865d4..0000000
--- a/roles/cert_manager/README.md
+++ /dev/null
@@ -1 +0,0 @@
-# `cert_manager`
diff --git a/roles/cert_manager/defaults/main.yml b/roles/cert_manager/defaults/main.yml
deleted file mode 100644
index 183b22d..0000000
--- a/roles/cert_manager/defaults/main.yml
+++ /dev/null
@@ -1,35 +0,0 @@
----
-# .. vim: foldmarker=[[[,]]]:foldmethod=marker
-
-# .. Copyright (C) 2022 VEXXHOST, Inc.
-# .. SPDX-License-Identifier: Apache-2.0
-
-# Default variables
-# =================
-
-# .. contents:: Sections
-# :local:
-
-
-# .. envvar:: cert_manager_acme_server [[[
-#
-# ACME server URL
-cert_manager_acme_server: "{{ lookup('env', 'ATMOSPHERE_ACME_SERVER') | default('https://acme-v02.api.letsencrypt.org/directory', True) }}"
-
- # ]]]
-# .. envvar:: cert_manager_issuer [[[
-#
-# Definition for the ``cert-manager`` issuer
-# To use self-signed CA certificate, set cert_manager_issuer.ca.secretName as root-secret.
-cert_manager_issuer:
- acme:
- email: mnaser@vexxhost.com
- server: "{{ cert_manager_acme_server }}"
- privateKeySecretRef:
- name: issuer-account-key
- solvers:
- - http01:
- ingress:
- class: openstack
-
- # ]]]
diff --git a/roles/cert_manager/tasks/main.yml b/roles/cert_manager/tasks/main.yml
deleted file mode 100644
index c9c0901..0000000
--- a/roles/cert_manager/tasks/main.yml
+++ /dev/null
@@ -1,92 +0,0 @@
-# Copyright (c) 2022 VEXXHOST, Inc.
-#
-# Licensed under the Apache License, Version 2.0 (the "License"); you may
-# not use this file except in compliance with the License. You may obtain
-# a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-# License for the specific language governing permissions and limitations
-# under the License.
-
-- name: Create Issuer
- kubernetes.core.k8s:
- state: present
- definition:
- apiVersion: cert-manager.io/v1
- kind: Issuer
- metadata:
- name: openstack
- namespace: openstack
- spec: "{{ cert_manager_issuer }}"
- # NOTE(mnaser): Since we haven't moved to the operator pattern yet, we need to
- # keep retrying a few times as the CRDs might not be installed
- # yet.
- retries: 60
- delay: 5
- register: _result
- until: _result is not failed
-
-- name: Bootstrap self-signed PKI
- block:
- - name: Create self-signed issuer
- kubernetes.core.k8s:
- state: present
- definition:
- apiVersion: cert-manager.io/v1
- kind: ClusterIssuer
- metadata:
- name: selfsigned-issuer
- spec:
- selfSigned: {}
-
- - name: Bootstrap a custom root certificate for a private PKI
- kubernetes.core.k8s:
- state: present
- definition:
- apiVersion: cert-manager.io/v1
- kind: Certificate
- metadata:
- name: selfsigned-ca
- namespace: openstack
- spec:
- isCA: true
- commonName: selfsigned-ca
- secretName: root-secret
- duration: 86400h # 3600d
- renewBefore: 360h # 15d
- privateKey:
- algorithm: ECDSA
- size: 256
- issuerRef:
- name: selfsigned-issuer
- kind: ClusterIssuer
- group: cert-manager.io
-
- - name: Wait till the root secret is created
- kubernetes.core.k8s_info:
- api_version: v1
- kind: Secret
- wait: true
- name: root-secret
- namespace: openstack
- wait_sleep: 10
- wait_timeout: 300
- register: _openstack_helm_root_secret
-
- - name: Copy CA certificate on host
- ansible.builtin.copy:
- content: "{{ _openstack_helm_root_secret.resources[0].data['tls.crt'] | b64decode }}"
- dest: "/usr/local/share/ca-certificates/self-signed-osh-ca.crt"
- mode: "0644"
-
- - name: Update ca certificates on host
- ansible.builtin.command:
- cmd: update-ca-certificates
- changed_when: false
- when:
- - cert_manager_issuer.ca.secretName is defined
- - cert_manager_issuer.ca.secretName == "root-secret"
diff --git a/roles/certificates/README.md b/roles/certificates/README.md
new file mode 100644
index 0000000..ab2904d
--- /dev/null
+++ b/roles/certificates/README.md
@@ -0,0 +1,6 @@
+# `certificates`
+
+!!! warning
+
+ This is a legacy role that is meant to be phased out eventually when there
+ is no need for the control-plane systems to have the CA installed on them.
diff --git a/roles/cert_manager/meta/main.yml b/roles/certificates/meta/main.yml
similarity index 92%
rename from roles/cert_manager/meta/main.yml
rename to roles/certificates/meta/main.yml
index 97154e4..4d6cbdd 100644
--- a/roles/cert_manager/meta/main.yml
+++ b/roles/certificates/meta/main.yml
@@ -14,7 +14,7 @@
galaxy_info:
author: VEXXHOST, Inc.
- description: Ansible role for cert-manager
+ description: Ansible role for distributing certificates
license: Apache-2.0
min_ansible_version: 5.5.0
standalone: false
diff --git a/roles/certificates/tasks/main.yml b/roles/certificates/tasks/main.yml
new file mode 100644
index 0000000..334d7e7
--- /dev/null
+++ b/roles/certificates/tasks/main.yml
@@ -0,0 +1,53 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Bootstrap PKI
+ block:
+ - name: Wait till the secret is created
+ kubernetes.core.k8s_info:
+ api_version: v1
+ kind: Secret
+ name: cert-manager-selfsigned-ca
+ namespace: openstack
+ wait: true
+ wait_sleep: 1
+ wait_timeout: 300
+ register: _openstack_helm_root_secret
+ when: atomsphere_issuer_config.type == "self-signed"
+
+ - name: Wait till the secret is created
+ kubernetes.core.k8s_info:
+ api_version: v1
+ kind: Secret
+ name: cert-manager-issuer-ca
+ namespace: openstack
+ wait: true
+ wait_sleep: 1
+ wait_timeout: 300
+ register: _openstack_helm_root_secret
+ when: atomsphere_issuer_config.type == "ca"
+
+ - name: Copy CA certificate on host
+ ansible.builtin.copy:
+ content: "{{ _openstack_helm_root_secret.resources[0].data['tls.crt'] | b64decode }}"
+ dest: "/usr/local/share/ca-certificates/self-signed-osh-ca.crt"
+ mode: "0644"
+
+ - name: Update CA certificates on host
+ ansible.builtin.command:
+ cmd: update-ca-certificates
+ changed_when: false
+ when:
+ - atomsphere_issuer_config.type is defined
+ - atomsphere_issuer_config.type in ("self-signed", "ca")
diff --git a/roles/openstack_cli/meta/main.yml b/roles/openstack_cli/meta/main.yml
index c18c9db..d1ef07b 100644
--- a/roles/openstack_cli/meta/main.yml
+++ b/roles/openstack_cli/meta/main.yml
@@ -22,3 +22,6 @@
- name: Ubuntu
versions:
- focal
+
+dependencies:
+ - role: certificates
diff --git a/roles/openstacksdk/meta/main.yml b/roles/openstacksdk/meta/main.yml
index b328fac..7d9c3db 100644
--- a/roles/openstacksdk/meta/main.yml
+++ b/roles/openstacksdk/meta/main.yml
@@ -22,3 +22,6 @@
- name: Ubuntu
versions:
- focal
+
+dependencies:
+ - role: certificates