Skip to content

Commit 9122713

Browse files
mkjpryorMatt Pryor
andauthored
Add apps driver for new apps operator resources (#362)
Should be rebased onto `master` after #372 has merged. --------- Co-authored-by: Matt Pryor <[email protected]>
1 parent 5d230e1 commit 9122713

File tree

9 files changed

+395
-30
lines changed

9 files changed

+395
-30
lines changed

api/azimuth/apps/app.py

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import functools
2+
import json
3+
import logging
4+
import typing as t
5+
6+
import dateutil.parser
7+
8+
import httpx
9+
10+
import yaml
11+
12+
from easykube import (
13+
Configuration,
14+
ApiError,
15+
SyncClient,
16+
PRESENT
17+
)
18+
19+
from ..acls import allowed_by_acls
20+
from ..cluster_api import dto as capi_dto
21+
from ..provider import base as cloud_base
22+
from ..utils import get_namespace
23+
24+
from . import base, dto, errors
25+
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
APPS_API_VERSION = "apps.azimuth-cloud.io/v1alpha1"
31+
32+
33+
def convert_exceptions(f):
34+
"""
35+
Decorator that converts Kubernetes API exceptions into errors from :py:mod:`..errors`.
36+
"""
37+
@functools.wraps(f)
38+
def wrapper(*args, **kwargs):
39+
try:
40+
return f(*args, **kwargs)
41+
except ApiError as exc:
42+
# Extract the status code and message
43+
status_code = exc.response.status_code
44+
message = (
45+
str(exc)
46+
.replace("apptemplates.apps.azimuth-cloud.io", "Kubernetes app template")
47+
.replace("apps.apps.azimuth-cloud.io", "Kubernetes app")
48+
)
49+
if status_code == 400:
50+
raise errors.BadInputError(message)
51+
elif status_code == 404:
52+
raise errors.ObjectNotFoundError(message)
53+
elif status_code == 409:
54+
raise errors.InvalidOperationError(message)
55+
else:
56+
logger.exception("Unknown error with Kubernetes API.")
57+
raise errors.CommunicationError("Unknown error with Kubernetes API.")
58+
except httpx.HTTPError as exc:
59+
logger.exception("Could not connect to Kubernetes API.")
60+
raise errors.CommunicationError("Could not connect to Kubernetes API.")
61+
return wrapper
62+
63+
64+
class Provider(base.Provider):
65+
"""
66+
Base class for Cluster API providers.
67+
"""
68+
def __init__(self):
69+
# Get the easykube configuration from the environment
70+
self._ekconfig = Configuration.from_environment()
71+
72+
def session(self, cloud_session: cloud_base.ScopedSession) -> 'Session':
73+
"""
74+
Returns a Cluster API session scoped to the given cloud provider session.
75+
"""
76+
client = self._ekconfig.sync_client()
77+
# Work out what namespace to target for the tenancy
78+
namespace = get_namespace(client, cloud_session.tenancy())
79+
# Set the target namespace as the default namespace for the client
80+
client.default_namespace = namespace
81+
return Session(client, cloud_session)
82+
83+
84+
class Session(base.Session):
85+
"""
86+
Base class for a scoped session.
87+
"""
88+
def __init__(self, client: SyncClient, cloud_session: cloud_base.ScopedSession):
89+
self._client = client
90+
self._cloud_session = cloud_session
91+
92+
def _log(self, message, *args, level = logging.INFO, **kwargs):
93+
logger.log(
94+
level,
95+
"[%s] [%s] " + message,
96+
self._cloud_session.username(),
97+
self._cloud_session.tenancy().name,
98+
*args,
99+
**kwargs
100+
)
101+
102+
def _from_api_app_template(self, at):
103+
"""
104+
Converts an app template from the Kubernetes API to a DTO.
105+
"""
106+
status = at.get("status", {})
107+
return dto.AppTemplate(
108+
at.metadata.name,
109+
status.get("label", at.metadata.name),
110+
status.get("logo"),
111+
status.get("description"),
112+
dto.Chart(
113+
at.spec.chart.repo,
114+
at.spec.chart.name
115+
),
116+
at.spec.get("defaultValues", {}),
117+
[
118+
dto.Version(
119+
version["name"],
120+
version.get("valuesSchema", {}),
121+
version.get("uiSchema", {})
122+
)
123+
for version in status.get("versions", [])
124+
]
125+
)
126+
127+
@convert_exceptions
128+
def app_templates(self) -> t.Iterable[dto.AppTemplate]:
129+
"""
130+
Lists the app templates currently available to the tenancy.
131+
"""
132+
self._log("Fetching available app templates")
133+
templates = list(self._client.api(APPS_API_VERSION).resource("apptemplates").list())
134+
self._log("Found %s app templates", len(templates))
135+
136+
# Filter templates based on ACL annotations
137+
tenancy = self._cloud_session.tenancy()
138+
templates = [t for t in templates if allowed_by_acls(t, tenancy)]
139+
140+
# Don't return app templates with no versions
141+
return tuple(
142+
self._from_api_app_template(at)
143+
for at in templates
144+
if at.get("status", {}).get("versions")
145+
)
146+
147+
@convert_exceptions
148+
def find_app_template(self, id: str) -> dto.AppTemplate:
149+
"""
150+
Finds an app template by id.
151+
"""
152+
self._log("Fetching app template with id '%s'", id)
153+
template = self._client.api(APPS_API_VERSION).resource("apptemplates").fetch(id)
154+
155+
tenancy = self._cloud_session.tenancy()
156+
if not allowed_by_acls(template, tenancy):
157+
raise errors.ObjectNotFoundError(f"Cannot find app template {id}")
158+
159+
# Don't return app templates with no versions
160+
if template.get("status", {}).get("versions"):
161+
return self._from_api_app_template(template)
162+
else:
163+
raise errors.ObjectNotFoundError(f"Kubernetes app template '{id}' not found")
164+
165+
def _from_api_app(self, app):
166+
"""
167+
Converts a Helm release to an app DTO.
168+
"""
169+
# We want to account for the case where a change has been made but the operator
170+
# has not yet caught up by tweaking the status
171+
app_phase = app.get("status", {}).get("phase")
172+
if app.metadata.get("deletionTimestamp"):
173+
# If the app has a deletion timestamp, flag it as uninstalling even if
174+
# the operator hasn't yet updated the status
175+
app_phase = "Uninstalling"
176+
elif not app_phase:
177+
# If there is no status, then the operator has not caught up after a create
178+
app_phase = "Pending"
179+
else:
180+
# Otherwise, we can compare the spec to the last handled configuration
181+
last_handled_configuration = json.loads(
182+
app.metadata
183+
.get("annotations", {})
184+
.get("apps.azimuth-cloud.io/last-handled-configuration", "{}")
185+
)
186+
last_handled_spec = last_handled_configuration.get("spec")
187+
if last_handled_spec and app.spec != last_handled_spec:
188+
app_phase = "Upgrading"
189+
app_status = app.get("status", {})
190+
return dto.App(
191+
app.metadata.name,
192+
app.metadata.name,
193+
app.metadata.get("annotations", {}).get("azimuth.stackhpc.com/cluster"),
194+
app.spec.template.name,
195+
app.spec.template.version,
196+
app.spec.get("values", {}),
197+
app_phase,
198+
app_status.get("usage") or None,
199+
app_status.get("failureMessage") or None,
200+
[
201+
dto.Service(
202+
name,
203+
service["label"],
204+
service["fqdn"],
205+
service.get("iconUrl")
206+
)
207+
for name, service in app_status.get("services", {}).items()
208+
],
209+
dateutil.parser.parse(app.metadata["creationTimestamp"]),
210+
app.spec["createdByUsername"],
211+
app.spec["createdByUserId"],
212+
app.spec.get("updatedByUsername"),
213+
app.spec.get("updatedByUserId"),
214+
)
215+
216+
@convert_exceptions
217+
def apps(self) -> t.Iterable[dto.App]:
218+
"""
219+
Lists the apps for the tenancy.
220+
"""
221+
self._log("Fetching available apps")
222+
# The apps are the HelmReleases that reference an Azimuth app template
223+
apps = list(self._client.api(APPS_API_VERSION).resource("apps").list())
224+
self._log("Found %s apps", len(apps))
225+
return tuple(self._from_api_app(app) for app in apps)
226+
227+
@convert_exceptions
228+
def find_app(self, id: str) -> dto.App:
229+
"""
230+
Finds an app by id.
231+
"""
232+
self._log("Fetching app with id '%s'", id)
233+
# We only want to include apps with the app-template label
234+
app = self._client.api(APPS_API_VERSION).resource("apps").fetch(id)
235+
return self._from_api_app(app)
236+
237+
@convert_exceptions
238+
def create_app(
239+
self,
240+
name: str,
241+
template: dto.AppTemplate,
242+
values: t.Dict[str, t.Any],
243+
*,
244+
kubernetes_cluster: t.Optional[capi_dto.Cluster] = None,
245+
zenith_identity_realm_name: t.Optional[str] = None
246+
) -> dto.App:
247+
"""
248+
Create a new app in the tenancy.
249+
"""
250+
# For now, we require a Kubernetes cluster
251+
if not kubernetes_cluster:
252+
raise errors.BadInputError("No Kubernetes cluster specified.")
253+
# This driver requires the Zenith identity realm to be specified
254+
if not zenith_identity_realm_name:
255+
raise errors.BadInputError("No Zenith identity realm specified.")
256+
# NOTE(mkjpryor)
257+
# We know that the target namespace exists because it has a cluster in
258+
return self._from_api_app(
259+
self._client
260+
.api(APPS_API_VERSION)
261+
.resource("apps")
262+
.create({
263+
"metadata": {
264+
"name": name,
265+
"labels": {
266+
"app.kubernetes.io/managed-by": "azimuth",
267+
},
268+
# If the app belongs to a cluster, store that in an annotation
269+
"annotations": {
270+
"azimuth.stackhpc.com/cluster": kubernetes_cluster.id,
271+
},
272+
},
273+
"spec": {
274+
"template": {
275+
"name": template.id,
276+
# Use the most recent version when creating an app
277+
"version": template.versions[0].name,
278+
},
279+
"kubeconfigSecret": {
280+
# Use the kubeconfig for the cluster
281+
"name": f"{kubernetes_cluster.id}-kubeconfig",
282+
"key": "value",
283+
},
284+
"zenithIdentityRealmName": zenith_identity_realm_name,
285+
"values": values,
286+
"createdByUsername": self._cloud_session.username(),
287+
"createdByUserId": self._cloud_session.user_id(),
288+
},
289+
})
290+
)
291+
292+
@convert_exceptions
293+
def update_app(
294+
self,
295+
app: t.Union[dto.App, str],
296+
template: dto.AppTemplate,
297+
version: dto.Version,
298+
values: t.Dict[str, t.Any]
299+
) -> dto.App:
300+
"""
301+
Update the specified cluster with the given parameters.
302+
"""
303+
if isinstance(app, dto.App):
304+
app = app.id
305+
return self._from_api_app(
306+
self._client
307+
.api(APPS_API_VERSION)
308+
.resource("apps")
309+
.patch(
310+
app,
311+
{
312+
"spec": {
313+
"template": {
314+
"name": template.id,
315+
"version": version.name,
316+
},
317+
"values": values,
318+
"updatedByUsername": self._cloud_session.username(),
319+
"updatedByUserId": self._cloud_session.user_id(),
320+
},
321+
},
322+
)
323+
)
324+
325+
@convert_exceptions
326+
def delete_app(self, app: t.Union[dto.App, str]) -> t.Optional[dto.App]:
327+
"""
328+
Delete the specified app.
329+
"""
330+
# Check if the specified id is actually an app before deleting it
331+
if isinstance(app, dto.App):
332+
app = app.id
333+
self._client.api(APPS_API_VERSION).resource("apps").delete(
334+
app,
335+
propagation_policy = "Foreground"
336+
)
337+
return self.find_app(app)
338+
339+
def close(self):
340+
"""
341+
Closes the session and performs any cleanup.
342+
"""
343+
self._client.close()

api/azimuth/apps/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ def create_app(
5050
template: dto.AppTemplate,
5151
values: t.Dict[str, t.Any],
5252
*,
53-
kubernetes_cluster: t.Optional[capi_dto.Cluster] = None
53+
kubernetes_cluster: t.Optional[capi_dto.Cluster] = None,
54+
zenith_identity_realm_name: t.Optional[str] = None
5455
) -> dto.App:
5556
"""
5657
Create a new app in the tenancy.

api/azimuth/apps/dto.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ class App:
7575
id: str
7676
#: The human-readable name of the app
7777
name: str
78-
#: The id of the Kubernetes cluster that the app is deployed on
79-
kubernetes_cluster_id: str
78+
#: The id of the Kubernetes cluster that the app is deployed on, if known
79+
kubernetes_cluster_id: t.Optional[str]
8080
#: The id of the template for the app
8181
template_id: str
8282
#: The version of the template that the app is using

0 commit comments

Comments
 (0)