Skip to content

Commit ee09738

Browse files
authored
Init repo (#1)
* Init repo * Add CI Workflow to build image (#4) * Add tags for image * Release workflow to publish image
1 parent 6b2d2b3 commit ee09738

File tree

7 files changed

+309
-6
lines changed

7 files changed

+309
-6
lines changed

.github/workflows/image.yml renamed to .github/workflows/image-release.yml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
name: Publish Docker image
1+
name: "[Release] - Publish Docker image"
22

3-
on: push
3+
on:
4+
release:
5+
types: [published]
46

57
jobs:
68
push_to_registry:
@@ -13,14 +15,18 @@ jobs:
1315
- name: Log in to Docker Hub
1416
uses: docker/login-action@v2
1517
with:
16-
username: ${{ secrets.DOCKER_USERNAME }}
17-
password: ${{ secrets.DOCKER_PASSWORD }}
18-
18+
username: ${{ secrets.DOCKERHUB_USERNAME }}
19+
password: ${{ secrets.DOCKERHUB_TOKEN }}
20+
1921
- name: Extract metadata (tags, labels) for Docker
2022
id: meta
21-
uses: docker/metadata-action@v2
23+
uses: docker/metadata-action@v4
2224
with:
2325
images: mstiri/kube-cert-acm
26+
tags: |
27+
type=ref,event=branch
28+
type=semver,pattern={{version}}
29+
type=semver,pattern={{major}}.{{minor}}
2430
2531
- name: Build and push Docker image
2632
uses: docker/build-push-action@v4

.gitignore

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Editors
2+
.vscode/
3+
.idea/
4+
5+
6+
# Mac/OSX
7+
.DS_Store
8+
9+
10+
# Byte-compiled / optimized / DLL files
11+
__pycache__/
12+
*.py[cod]
13+
14+
# Environments
15+
.env
16+
.venv
17+
env/

Dockerfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM python:3.9.7-alpine3.14
2+
RUN addgroup -S appgrp && adduser -S appusr -G appgrp -h /app
3+
WORKDIR /app
4+
COPY requirements.txt main.py helpers.py logger.py ./
5+
RUN apk upgrade && apk add --no-cache --virtual .build-deps gcc musl-dev python3-dev libffi-dev && \
6+
pip3 install --no-cache-dir -r requirements.txt && \
7+
apk del .build-deps python3-dev libffi-dev
8+
USER appusr
9+
CMD ["python3", "-m", "main"]

helpers.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import yaml
2+
from logger import getJSONLogger
3+
from kubernetes import client, config
4+
from kubernetes.client.rest import ApiException
5+
import base64
6+
import OpenSSL
7+
from OpenSSL import crypto
8+
import boto3
9+
10+
CERT_BEGIN = "-----BEGIN CERTIFICATE-----"
11+
CERT_END = "-----END CERTIFICATE-----"
12+
CRD_GROUP = 'cert-manager.io'
13+
CRD_CERT_NAME = 'certificate'
14+
CRD_CERT_PLURAL = 'certificates'
15+
16+
logger = getJSONLogger('kube-cert-acm.helpers')
17+
18+
19+
# Discover in cluster kubernetes configuration
20+
config.load_incluster_config()
21+
api_instance = client.CoreV1Api()
22+
custom_api_instance = client.CustomObjectsApi()
23+
24+
session = boto3.Session()
25+
aws_client = session.client('acm')
26+
27+
28+
def read_conf_file(config_file: str) -> list:
29+
"""Read config file and return a list of valid entries."""
30+
certificates = []
31+
with open(config_file, 'r') as f:
32+
try:
33+
confs = yaml.safe_load(f)
34+
except yaml.YAMLError as yerr:
35+
logger.error(f'Error parsing config file. Err: {yerr}')
36+
raise Exception
37+
for cert in confs:
38+
if all(key in cert for key in ('cert', 'namespace', 'domain_name')):
39+
logger.debug(f'Required keys are present in {cert}')
40+
certificates.append(cert)
41+
else:
42+
logger.error(f'A required key is missing in {cert}')
43+
44+
return certificates
45+
46+
47+
def get_certificate_secret_name(certificate: str, namespace: str) -> str:
48+
"""Get the secret name of the provided certificate / namespace."""
49+
try:
50+
api_resp = custom_api_instance.get_namespaced_custom_object(
51+
group=CRD_GROUP, version='v1', namespace=namespace, name=certificate, plural=CRD_CERT_PLURAL)
52+
except ApiException as e:
53+
logger.error(
54+
f'Exception getting Certificate: {certificate}. Status: {e.status}. Reason: {e.reason}', exc_info=False)
55+
raise Exception
56+
try:
57+
cert_secret_name = api_resp['spec']['secretName']
58+
except Exception as e:
59+
logger.error(
60+
f'Exception extracting secretName from certificate object. Error: {e}')
61+
return None
62+
return cert_secret_name
63+
64+
65+
def get_cert_and_key_from_secret(secret: str, namespace: str):
66+
"""Get the certificate and its private key from the provided secret / namespace."""
67+
try:
68+
resp = api_instance.read_namespaced_secret(secret, namespace)
69+
except ApiException as e:
70+
logger.exception(
71+
f"Exception reading secret {secret} from namesapce {namespace}). Status: {e.status}, Reason: {e.reason}", exc_info=False)
72+
return None, None
73+
try:
74+
certificate = base64.b64decode(resp.data.get('tls.crt'))
75+
key = base64.b64decode(resp.data.get('tls.key'))
76+
except Exception as e:
77+
logger.error('Failed to extract data from returned secret')
78+
return None, None
79+
return certificate, key
80+
81+
82+
def get_certificate_and_chain(kube_certificate: str):
83+
"""Separate certificate from the chain."""
84+
try:
85+
crypto.load_certificate(crypto.FILETYPE_PEM, kube_certificate)
86+
except OpenSSL.crypto.Error as e:
87+
logger.error(f"OpenSSL error while loading certificate. Error: {e}")
88+
return None, None
89+
certs = kube_certificate.decode().split(CERT_BEGIN)[1:]
90+
certificate, chain = '', ''
91+
for chunk in certs:
92+
if CERT_BEGIN not in certificate or CERT_END not in certificate:
93+
certificate = CERT_BEGIN + chunk
94+
else:
95+
chain = chain + CERT_BEGIN + chunk
96+
return certificate, chain
97+
98+
99+
def certificate_exists(domain_name: str) -> bool:
100+
"""Check if a certificate for this domain is already in ACM"""
101+
logger.info(
102+
f'Check if there is any certificate in ACM for the domain: {domain_name}')
103+
try:
104+
resp = aws_client.list_certificates()
105+
except Exception as e:
106+
logger.error(f'Error listing certificates. Error: {e}')
107+
raise Exception
108+
for cert in resp['CertificateSummaryList']:
109+
if domain_name == cert.get('DomainName'):
110+
return True
111+
return False
112+
113+
114+
def get_acm_certificate(domain_name: str):
115+
"""Get ACM certificate for the provided domain name"""
116+
try:
117+
resp = aws_client.list_certificates()
118+
except Exception as e:
119+
logger.error(f'Error listing certificates. Error: {e}')
120+
return None, None
121+
for cert in resp['CertificateSummaryList']:
122+
if domain_name == cert.get('DomainName'):
123+
certificate_arn = cert.get('CertificateArn')
124+
acm_cert = aws_client.get_certificate(
125+
CertificateArn=certificate_arn)['Certificate']
126+
return acm_cert, certificate_arn
127+
128+
129+
def compare_certificates(kube_certificate: str, acm_certificate: str) -> bool:
130+
"""Compare a kubernetes and ACM certificates to check if they are the same"""
131+
try:
132+
kube_cert_sn = crypto.load_certificate(
133+
crypto.FILETYPE_PEM, kube_certificate).get_serial_number()
134+
acm_cert_sn = crypto.load_certificate(
135+
crypto.FILETYPE_PEM, acm_certificate).get_serial_number()
136+
except OpenSSL.crypto.Error as e:
137+
logger.error(f"OpenSSL error while getting certificate SN. Error: {e}")
138+
return False
139+
logger.debug(f"Cluster cert SN: {kube_cert_sn}")
140+
logger.debug(f"ACM cert SN: {acm_cert_sn}")
141+
if kube_cert_sn == acm_cert_sn:
142+
logger.info('These 2 certificates are the same')
143+
return True
144+
else:
145+
logger.info('These 2 certificates are different')
146+
return False
147+
148+
149+
def import_certificate(certificate: str, chain: str, kube_private_key: str, certificate_arn: str = None) -> bool:
150+
"""Improt provided certificate to ACM"""
151+
try:
152+
if certificate_arn:
153+
resp = aws_client.import_certificate(
154+
Certificate=certificate,
155+
CertificateArn=certificate_arn,
156+
CertificateChain=chain,
157+
PrivateKey=kube_private_key)
158+
else:
159+
resp = aws_client.import_certificate(
160+
Certificate=certificate,
161+
CertificateChain=chain,
162+
PrivateKey=kube_private_key)
163+
if resp['CertificateArn']:
164+
logger.info(
165+
f"Certificate imported with the ARN: {resp['CertificateArn']}")
166+
return True
167+
else:
168+
logger.error("Failed to import certificate to ACM")
169+
except Exception as e:
170+
logger.error(f'Exception during certificate import to ACM. Error: {e}')
171+
return False

logger.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import logging
2+
import sys
3+
from pythonjsonlogger import jsonlogger
4+
import os
5+
from datetime import datetime
6+
7+
logging_level = os.getenv("LOG_LEVEL") or 'INFO'
8+
9+
10+
class CustomJsonFormatter(jsonlogger.JsonFormatter):
11+
def add_fields(self, log_record, record, message_dict):
12+
super(CustomJsonFormatter, self).add_fields(
13+
log_record, record, message_dict)
14+
if not log_record.get('timestamp'):
15+
log_record['timestamp'] = datetime.fromtimestamp(record.created)
16+
if log_record.get('severity'):
17+
log_record['severity'] = log_record['severity'].upper()
18+
else:
19+
log_record['severity'] = record.levelname
20+
21+
22+
def getJSONLogger(name):
23+
logger = logging.getLogger(name)
24+
handler = logging.StreamHandler(sys.stdout)
25+
formatter = CustomJsonFormatter(
26+
'%(timestamp)s %(severity)s %(name)s %(message)s')
27+
handler.setFormatter(formatter)
28+
logger.addHandler(handler)
29+
logger.setLevel(logging_level)
30+
logger.propagate = False
31+
return logger

main.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import sys
2+
import os
3+
import time
4+
from logger import getJSONLogger
5+
from helpers import read_conf_file, get_certificate_secret_name, get_cert_and_key_from_secret, get_certificate_and_chain
6+
from helpers import certificate_exists, get_acm_certificate, compare_certificates, import_certificate
7+
8+
logger = getJSONLogger('kube-cert-acm')
9+
sys.tracebacklimit = 0
10+
11+
CHECK_INTERVAL_SECONDS = os.getenv("CHECK_INTERVAL_SECONDS") or 60
12+
CONFIG_FILE = "/app/config/certificates_config.yaml"
13+
14+
15+
def cert_sync():
16+
logger.debug('Begin certificates synchronisation')
17+
try:
18+
certificates = read_conf_file(CONFIG_FILE)
19+
except Exception as e:
20+
logger.error(f"Failed to read config file: {e}")
21+
return
22+
for cert in certificates:
23+
logger.debug(f'Certificate to sync: {cert}')
24+
try:
25+
cert_secret_name = get_certificate_secret_name(
26+
cert['cert'], cert['namespace'])
27+
except Exception as e:
28+
continue
29+
if not cert_secret_name:
30+
continue
31+
kube_certificate, key = get_cert_and_key_from_secret(
32+
cert_secret_name, cert['namespace'])
33+
if not (kube_certificate and key):
34+
continue
35+
certificate, chain = get_certificate_and_chain(kube_certificate)
36+
if not (certificate and chain):
37+
continue
38+
try:
39+
cert_exists = certificate_exists(cert['domain_name'])
40+
except Exception as e:
41+
continue
42+
if cert_exists:
43+
acm_certificate, certificate_arn = get_acm_certificate(
44+
cert['domain_name'])
45+
if not (acm_certificate and certificate_arn):
46+
continue
47+
if compare_certificates(kube_certificate, acm_certificate):
48+
logger.debug(
49+
'ACM and Kubernetes certificates are the same. Nothing to sync')
50+
else:
51+
logger.debug(
52+
'ACM and Kubernetes certificates are different. Sync to be performed')
53+
resp = import_certificate(
54+
certificate, chain, key, certificate_arn)
55+
else:
56+
logger.info(
57+
'Certificate does not exist on ACM. Certificate to be imported')
58+
import_certificate(certificate, chain, key)
59+
60+
61+
if __name__ == '__main__':
62+
while True:
63+
cert_sync()
64+
logger.info(f"Coming back in {CHECK_INTERVAL_SECONDS} seconds")
65+
time.sleep(int(CHECK_INTERVAL_SECONDS))

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
boto3
2+
kubernetes
3+
pyOpenSSL
4+
python-json-logger

0 commit comments

Comments
 (0)