Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit fbe8811

Browse files
author
william chu
committedJul 30, 2021
feat(gitops_server): Update github deployment status
1 parent 3416398 commit fbe8811

14 files changed

+490
-59
lines changed
 

‎Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ RUN pip3 install poetry
3535
COPY pyproject.toml poetry.lock /app/
3636

3737
# Install dependencies
38-
RUN poetry install -E server --no-dev
38+
RUN poetry install --extras server --no-dev
3939

4040
COPY cluster.key /app/
4141
COPY gitops /app/gitops/

‎charts/gitops/Chart.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ apiVersion: v1
22
appVersion: "1.0"
33
description: GitOps Server Helm chart.
44
name: gitops
5-
version: 0.3.0
5+
version: 0.4.0

‎gitops/common/app.py

+11
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ def __eq__(self, other):
4646
def is_inactive(self):
4747
return "inactive" in self.values.get("tags", [])
4848

49+
def set_value(self, path: str, value: any):
50+
"""Sets the value on the path of self.values
51+
Usage:
52+
app.set_value("deployments.label.GITHUB_DEPLOYMENT_KEY", "1")
53+
"""
54+
keys = path.split(".")
55+
current_dict = self.values
56+
for key in keys[:-1]:
57+
current_dict = current_dict.setdefault(key, {})
58+
current_dict[keys[-1]] = value
59+
4960
def _make_values(self, deployments: Dict, secrets: Dict) -> Dict:
5061
values = {
5162
**deployments,

‎gitops_server/deploy.py

+27-8
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
import logging
44
import os
55
import tempfile
6+
import uuid
67
from typing import List, Optional
78

89
from gitops.common.app import App
910

10-
from . import settings
11+
from . import github, settings, slack
1112
from .app_definitions import AppDefinitions
1213
from .git import temp_repo
13-
from .slack import post
1414
from .types import UpdateAppResult
1515
from .utils import get_repo_name_from_url, run
1616

@@ -27,25 +27,37 @@ async def post_init_summary(
2727
for typ, d in [("Adding", added_apps), ("Updating", updated_apps), ("Removing", removed_apps)]:
2828
if d:
2929
deltas += f"\n\t{typ}: {', '.join(f'`{app}`' for app in sorted(d))}"
30-
await post(
30+
await slack.post(
3131
f"A deployment from `{source}` has been initiated by *{username}* for cluster"
3232
f" `{settings.CLUSTER_NAME}`, the following apps will be updated:{deltas}\nCommit Message:"
3333
f" {commit_message}"
3434
)
3535

3636

37-
async def post_result(source: str, result: UpdateAppResult):
37+
async def post_result(app: App, source: str, result: UpdateAppResult):
38+
github_deployment_url = str(app.values.get("github/deployment_url", ""))
3839
if result["exit_code"] != 0:
39-
await post(
40+
await github.update_deployment(
41+
github_deployment_url,
42+
status=github.STATUSES.failure,
43+
description=f"Failed to deploy app. {result['output']}",
44+
)
45+
await slack.post(
4046
f"Failed to deploy app `{result['app_name']}` from `{source}` for cluster"
4147
f" `{settings.CLUSTER_NAME}`:\n>>>{result['output']}"
4248
)
49+
else:
50+
await github.update_deployment(
51+
github_deployment_url,
52+
status=github.STATUSES.in_progress,
53+
description="Helm installed app into cluster. Waiting for pods to deploy.",
54+
)
4355

4456

4557
async def post_result_summary(source: str, results: List[UpdateAppResult]):
4658
n_success = sum([r["exit_code"] == 0 for r in results])
4759
n_failed = sum([r["exit_code"] != 0 for r in results])
48-
await post(
60+
await slack.post(
4961
f"Deployment from `{source}` for `{settings.CLUSTER_NAME}` results summary:\n"
5062
f"\t{n_success} succeeded\n"
5163
f"\t{n_failed} failed"
@@ -72,6 +84,7 @@ def __init__(
7284
self.commit_message = commit_message
7385
self.current_app_definitions = current_app_definitions
7486
self.previous_app_definitions = previous_app_definitions
87+
self.deploy_id = str(uuid.uuid4())
7588

7689
# Max parallel helm installs at a time
7790
# Kube api may rate limit otherwise
@@ -130,10 +143,15 @@ async def uninstall_app(self, app: App) -> UpdateAppResult:
130143
f"helm uninstall {app.name} -n {app.values['namespace']}", suppress_errors=True
131144
)
132145
update_result = UpdateAppResult(app_name=app.name, **result)
133-
await post_result(self.current_app_definitions.name, update_result)
146+
await post_result(app, self.current_app_definitions.name, update_result)
134147
return update_result
135148

136149
async def update_app_deployment(self, app: App) -> Optional[UpdateAppResult]:
150+
app.set_value("deployment.labels.gitops/deploy_id", self.deploy_id)
151+
app.set_value("deployment.labels.gitops/status", github.STATUSES.in_progress)
152+
if github_deployment_url := app.values.get("github/deployment_url"):
153+
app.set_value("deployment.annotations.github/deployment_url", github_deployment_url)
154+
137155
async with self.semaphore:
138156
logger.info(f"Deploying app {app.name!r}.")
139157
if app.chart.type == "git":
@@ -176,7 +194,8 @@ async def update_app_deployment(self, app: App) -> Optional[UpdateAppResult]:
176194
return None
177195

178196
update_result = UpdateAppResult(app_name=app.name, **result)
179-
await post_result(self.current_app_definitions.name, update_result)
197+
198+
await post_result(app, self.current_app_definitions.name, update_result)
180199
return update_result
181200

182201
def calculate_app_deltas(self):

‎gitops_server/deployment_checker.py

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""
2+
This async worker keeps tracks of ongoing deployments by polling k8s space (CLUSTER_NAMESPACE).
3+
4+
The worker polls and checks for deployments with the following labels: gitops/deploy_id and gitops/status=in_progress
5+
6+
Any failing/successfully deployed deployment is notified to github.
7+
This mechanism will only work if the annotation: github/deployment_url is applied to the deployment.
8+
9+
TODO
10+
- Update the slack summary message from deploy.py
11+
- @notify the user if the deployment failed
12+
"""
13+
import asyncio
14+
import logging
15+
16+
import kubernetes_asyncio
17+
import kubernetes_asyncio.client
18+
import kubernetes_asyncio.config
19+
20+
from . import github
21+
from .settings import CLUSTER_NAMESPACE
22+
23+
logger = logging.getLogger("deployment_status")
24+
25+
26+
class DeploymentStore:
27+
def __init__(self):
28+
pass
29+
30+
31+
async def get_ingress_url(api, namespace: str, app: str):
32+
"""Attempts to get domain for the ingress associated with the app"""
33+
ingresses = await kubernetes_asyncio.client.NetworkingV1beta1Api(api).list_namespaced_ingress(
34+
namespace=namespace, label_selector=f"app={app}"
35+
)
36+
environment_url = ""
37+
if ingresses.items:
38+
try:
39+
environment_url: str = "https://" + ingresses.items[0].spec.rules[0].host
40+
except Exception:
41+
logger.warning(f"Could not find ingress for {app=}")
42+
pass
43+
return environment_url
44+
45+
46+
class DeploymentStatusWorker:
47+
"""Watches for deployments and updates the github deployment status"""
48+
49+
_worker = None
50+
51+
@classmethod
52+
def get_worker(
53+
cls,
54+
):
55+
if not cls._worker:
56+
loop = asyncio.get_running_loop()
57+
cls._worker = cls(loop)
58+
return cls._worker
59+
60+
def __init__(self, loop):
61+
self.loop = loop
62+
63+
async def load_config(self):
64+
logger.info("Loading kubernetes asyncio api")
65+
try:
66+
kubernetes_asyncio.config.load_incluster_config()
67+
except kubernetes_asyncio.config.config_exception.ConfigException:
68+
await kubernetes_asyncio.config.load_kube_config()
69+
70+
async def process_work(self):
71+
await self.load_config()
72+
73+
while True:
74+
async with kubernetes_asyncio.client.ApiClient() as api:
75+
apps_api = kubernetes_asyncio.client.AppsV1Api(api)
76+
deployments = await apps_api.list_namespaced_deployment(
77+
# Only things that have gitops/deploy_id aka was deployed
78+
namespace=CLUSTER_NAMESPACE,
79+
label_selector="gitops/deploy_id,gitops/status=in_progress",
80+
)
81+
await asyncio.sleep(10)
82+
83+
for deployment in deployments.items:
84+
app = deployment.metadata.labels["app"]
85+
namespace = deployment.metadata.namespace
86+
github_deployment_url = deployment.metadata.annotations.get(
87+
"github/deployment_url"
88+
)
89+
conds = {}
90+
for x in deployment.status.conditions:
91+
conds[x.type] = x
92+
status = None
93+
if (
94+
len(conds) == 2
95+
and conds["Available"].status == "True"
96+
and conds["Progressing"].status == "True"
97+
and conds["Progressing"].reason == "NewReplicaSetAvailable"
98+
):
99+
status = github.STATUSES.success
100+
await github.update_deployment(
101+
github_deployment_url,
102+
status=status,
103+
description="Deployed successfully",
104+
environment_url=await get_ingress_url(api, namespace, app),
105+
)
106+
elif (
107+
"Progressing" in conds
108+
and conds["Progressing"].status == "False"
109+
and conds["Progressing"].reason == "ProgressDeadlineExceeded"
110+
):
111+
status = github.STATUSES.failure
112+
await github.update_deployment(
113+
github_deployment_url,
114+
status=status,
115+
description="Failed to deploy. Check the pod or migrations.",
116+
)
117+
if status:
118+
logger.info(
119+
f"Patching {deployment.metadata.name}.label.gitops/status to {status}"
120+
)
121+
deployment.metadata.labels["gitops/status"] = status
122+
try:
123+
await apps_api.patch_namespaced_deployment(
124+
deployment.metadata.name, deployment.metadata.namespace, deployment
125+
)
126+
except kubernetes_asyncio.client.exceptions.ApiException as e:
127+
logger.warning(e, exc_info=True)
128+
129+
async def run(self):
130+
logger.info("Starting deployment status watching loop")
131+
try:
132+
await self.process_work()
133+
except Exception as e:
134+
logger.error(str(e), exc_info=True)

‎gitops_server/github.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import logging
2+
import os
3+
4+
import httpx
5+
6+
logger = logging.getLogger("github")
7+
8+
GITHUB_OAUTH_TOKEN = os.environ.get("GITHUB_OAUTH_TOKEN")
9+
10+
11+
class STATUSES:
12+
pending = "pending" # default status when created during gh workflow
13+
in_progress = "in_progress" # when helm installs
14+
success = "success" # when helm deployed
15+
failure = "failure" # when error during helm install
16+
error = "error" # when error during helm deploy
17+
18+
19+
async def update_deployment(deployment_url: str, status: str, description: str, environment_url=""):
20+
# https://docs.github.com/en/rest/reference/repos#create-a-deployment-status
21+
if not deployment_url:
22+
return
23+
status_url = deployment_url + "/statuses"
24+
headers = {
25+
"Authorization": f"token {GITHUB_OAUTH_TOKEN}",
26+
"Content-Type": "application/json",
27+
"Accept": (
28+
"application/vnd.github.flash-preview+json, application/vnd.github.ant-man-preview+json"
29+
),
30+
}
31+
32+
logger.info(f"Updating deployment status of: {deployment_url} to {status}")
33+
async with httpx.AsyncClient() as client:
34+
data = {
35+
"state": status,
36+
"description": description,
37+
# https://github.com/chrnorm/deployment-status/issues/13
38+
"environment_url": environment_url,
39+
}
40+
response = await client.post(
41+
status_url,
42+
json=data,
43+
headers=headers,
44+
)
45+
if response.status_code >= 300:
46+
try:
47+
logger.warn(response.json())
48+
except Exception:
49+
pass
50+
logger.exception(
51+
"Failed to update github deployment", exc_info=True, extra=response.__dict__
52+
)

‎gitops_server/logging_config.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33

44
class EndpointFilter(logging.Filter):
55
def filter(self, record: logging.LogRecord) -> bool:
6-
return record.args[2] != "/"
6+
return record.args[2] != "/" # type: ignore
77

88

9-
logging_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
10-
logging.basicConfig(format=logging_format, level=logging.DEBUG)
9+
logging_format = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
10+
logging.basicConfig(format=logging_format, level=logging.INFO)
1111

1212
# Filter out / from access logs (We don't care about these calls)
1313
logging.getLogger("uvicorn.access").addFilter(EndpointFilter())

‎gitops_server/main.py

+18-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import hashlib
23
import hmac
34
import logging
@@ -6,8 +7,9 @@
67

78
from gitops_server import settings
89
from gitops_server.app import app
10+
from gitops_server.deployment_checker import DeploymentStatusWorker
911
from gitops_server.logging_config import * # noqa
10-
from gitops_server.worker import get_worker # noqa
12+
from gitops_server.worker import Worker # noqa
1113

1214
logging.basicConfig(level=logging.INFO)
1315
logger = logging.getLogger("gitops")
@@ -28,12 +30,26 @@ async def webhook(request: Request):
2830

2931
json = await request.json()
3032

31-
worker = get_worker()
33+
worker = Worker.get_worker()
3234

3335
await worker.enqueue(json)
3436
return {"enqueued": True}
3537

3638

39+
@app.on_event("startup")
40+
async def startup_event():
41+
"""Prepare the worker.
42+
43+
Creates a new worker object and launches it as a future task.
44+
"""
45+
loop = asyncio.get_running_loop()
46+
worker = Worker.get_worker()
47+
worker.task = asyncio.ensure_future(worker.run(), loop=loop)
48+
49+
deployment_status_worker = DeploymentStatusWorker.get_worker()
50+
deployment_status_worker.task = asyncio.ensure_future(deployment_status_worker.run(), loop=loop)
51+
52+
3753
def get_digest(data: bytes) -> str:
3854
"""Calculate the digest of a webhook body.
3955

‎gitops_server/settings.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
22

33
CLUSTER_NAME = os.getenv("CLUSTER_NAME", "")
4+
# Namespace to search/deploy into
5+
CLUSTER_NAMESPACE = os.getenv("CLUSTER_NAMESPACE", "")
46
ACCOUNT_ID = os.getenv("ACCOUNT_ID", "")
57
GITHUB_WEBHOOK_KEY = os.getenv("GITHUB_WEBHOOK_KEY", "")

‎gitops_server/slack.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ async def post(message):
1818
async with httpx.AsyncClient() as client:
1919
response = await client.post(url, json=data)
2020
if response.status_code >= 300:
21-
logger.error("Failed to post a message to slack (see below):")
21+
logger.warning("Failed to post a message to slack (see below):")
2222
logger.error(f"{message}", exc_info=True)

‎gitops_server/worker.py

+11-24
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
11
import asyncio
22
import logging
33

4-
from gitops_server.app import app
5-
64
from .deploy import Deployer
75

86
logger = logging.getLogger("gitops_worker")
97

108

119
class Worker:
12-
"""Simple syncrhonous background work queue.
10+
"""Simple synchronous background work queue.
1311
1412
Deployments need to be carried out one at a time to ensure the cluster
1513
doesn't get confused. The worker is based entirely on asyncio and runs
1614
alongside the server for maximum efficiency.
1715
"""
1816

17+
_worker = None
18+
19+
@classmethod
20+
def get_worker(cls):
21+
if not cls._worker:
22+
loop = asyncio.get_running_loop()
23+
cls._worker = cls(loop)
24+
return cls._worker
25+
1926
def __init__(self, loop):
2027
self.loop = loop
2128
self.queue = asyncio.Queue(loop=self.loop)
@@ -35,6 +42,7 @@ async def run(self):
3542
awaited here to ensure synchronous operation.
3643
# TODO: Need to gracefully handle termination.
3744
"""
45+
logger.info("Starting up deployer worker loop")
3846
while True:
3947
try:
4048
await self.process_work()
@@ -48,24 +56,3 @@ async def process_work(self):
4856
if ref == "refs/heads/master":
4957
deployer = await Deployer.from_push_event(work)
5058
await deployer.deploy()
51-
52-
53-
def get_worker():
54-
global worker
55-
return worker
56-
57-
58-
worker = None
59-
60-
61-
@app.on_event("startup")
62-
async def startup_event():
63-
"""Prepare the worker.
64-
65-
Creates a new worker object and launches it as a future task.
66-
"""
67-
loop = asyncio.get_running_loop()
68-
logger.info("Starting up worker")
69-
global worker
70-
worker = Worker(loop)
71-
worker.task = asyncio.ensure_future(worker.run(), loop=loop)

‎poetry.lock

+213-14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyproject.toml

+15-4
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,21 @@ boto3 = "*"
1616
humanize = "^3.5.0"
1717
tabulate = "^0.8.9"
1818
# Server requirements
19-
fastapi = {version = "*", extras = ["server"]}
20-
httpx = {version = "^0.18.1", extras = ["server"]}
21-
uvicorn = {version = "^0.13.4", extras = ["server"]}
22-
sentry-sdk = {version = "^1.3.0", extras = ["server"]}
19+
fastapi = { version = "*", optional = true }
20+
httpx = { version = "^0.18.1", optional = true }
21+
uvicorn = { version = "^0.13.4", optional = true }
22+
sentry-sdk = { version = "^1.3.0", optional = true }
23+
kubernetes_asyncio = { version = "^12.1.2", optional = true }
24+
25+
[tool.poetry.extras]
26+
server = [
27+
"fastapi",
28+
"uvicorn",
29+
"httpx",
30+
"uvicorn",
31+
"sentry-sdk",
32+
"kubernetes_asyncio",
33+
]
2334

2435
[tool.poetry.dev-dependencies]
2536
#Flake 9 is flake 8 with pyproject support =_=.

‎tests/test_deploy.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
class TestDeploy(TestCase):
1717
@patch("gitops_server.deploy.run")
18-
@patch("gitops_server.deploy.post")
18+
@patch("gitops_server.slack.post")
1919
@patch("gitops_server.deploy.load_app_definitions", mock_load_app_definitions)
2020
@patch("gitops_server.deploy.temp_repo")
2121
async def test_deployer_git(self, temp_repo_mock, post_mock, run_mock):

0 commit comments

Comments
 (0)
Please sign in to comment.