Skip to content

Commit 6a431a2

Browse files
mkjpryorMatt PryorJohnGarbutt
authored
Add null cloud provider (#364)
Co-authored-by: Matt Pryor <[email protected]> Co-authored-by: John Garbutt <[email protected]>
1 parent 9122713 commit 6a431a2

File tree

13 files changed

+223
-78
lines changed

13 files changed

+223
-78
lines changed

api/azimuth/apps/app.py

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77

88
import httpx
99

10-
import yaml
11-
1210
from easykube import (
1311
Configuration,
1412
ApiError,
@@ -65,9 +63,14 @@ class Provider(base.Provider):
6563
"""
6664
Base class for Cluster API providers.
6765
"""
68-
def __init__(self):
66+
def __init__(
67+
self,
68+
# The label to use to find the default kubeconfig secret
69+
default_kubeconfig_secret_label: str = "apps.azimuth-cloud.io/default-kubeconfig"
70+
):
6971
# Get the easykube configuration from the environment
7072
self._ekconfig = Configuration.from_environment()
73+
self._default_kubeconfig_secret_label = default_kubeconfig_secret_label
7174

7275
def session(self, cloud_session: cloud_base.ScopedSession) -> 'Session':
7376
"""
@@ -78,16 +81,22 @@ def session(self, cloud_session: cloud_base.ScopedSession) -> 'Session':
7881
namespace = get_namespace(client, cloud_session.tenancy())
7982
# Set the target namespace as the default namespace for the client
8083
client.default_namespace = namespace
81-
return Session(client, cloud_session)
84+
return Session(client, cloud_session, self._default_kubeconfig_secret_label)
8285

8386

8487
class Session(base.Session):
8588
"""
8689
Base class for a scoped session.
8790
"""
88-
def __init__(self, client: SyncClient, cloud_session: cloud_base.ScopedSession):
91+
def __init__(
92+
self,
93+
client: SyncClient,
94+
cloud_session: cloud_base.ScopedSession,
95+
default_kubeconfig_secret_label: str
96+
):
8997
self._client = client
9098
self._cloud_session = cloud_session
99+
self._default_kubeconfig_secret_label = default_kubeconfig_secret_label
91100

92101
def _log(self, message, *args, level = logging.INFO, **kwargs):
93102
logger.log(
@@ -234,6 +243,23 @@ def find_app(self, id: str) -> dto.App:
234243
app = self._client.api(APPS_API_VERSION).resource("apps").fetch(id)
235244
return self._from_api_app(app)
236245

246+
def _find_default_kubeconfig_secret(self) -> t.Optional[t.Dict[str, t.Any]]:
247+
"""
248+
Attempts to locate a default kubeconfig secret in the namespace.
249+
"""
250+
# Find the first secret with the expected label
251+
secret = self._client.api("v1").resource("secrets").first(
252+
labels = { self._default_kubeconfig_secret_label: PRESENT }
253+
)
254+
if secret:
255+
# Use the first key in the secret (we expect it to be the only key)
256+
try:
257+
return secret.metadata.name, next(iter(secret.get("data", {}).keys()))
258+
except StopIteration:
259+
# Fall through to the exception
260+
pass
261+
raise errors.BadInputError("Unable to determine Kubernetes cluster for app.")
262+
237263
@convert_exceptions
238264
def create_app(
239265
self,
@@ -247,12 +273,15 @@ def create_app(
247273
"""
248274
Create a new app in the tenancy.
249275
"""
250-
# For now, we require a Kubernetes cluster
251-
if not kubernetes_cluster:
252-
raise errors.BadInputError("No Kubernetes cluster specified.")
253276
# This driver requires the Zenith identity realm to be specified
254277
if not zenith_identity_realm_name:
255278
raise errors.BadInputError("No Zenith identity realm specified.")
279+
# Decide which kubeconfig to use
280+
if kubernetes_cluster:
281+
kubeconfig_secret_name = f"{kubernetes_cluster.id}-kubeconfig"
282+
kubeconfig_secret_key = "value"
283+
else:
284+
kubeconfig_secret_name, kubeconfig_secret_key = self._find_default_kubeconfig_secret()
256285
# NOTE(mkjpryor)
257286
# We know that the target namespace exists because it has a cluster in
258287
return self._from_api_app(
@@ -266,9 +295,11 @@ def create_app(
266295
"app.kubernetes.io/managed-by": "azimuth",
267296
},
268297
# If the app belongs to a cluster, store that in an annotation
269-
"annotations": {
270-
"azimuth.stackhpc.com/cluster": kubernetes_cluster.id,
271-
},
298+
"annotations": (
299+
{ "azimuth.stackhpc.com/cluster": kubernetes_cluster.id }
300+
if kubernetes_cluster
301+
else {}
302+
),
272303
},
273304
"spec": {
274305
"template": {
@@ -277,9 +308,8 @@ def create_app(
277308
"version": template.versions[0].name,
278309
},
279310
"kubeconfigSecret": {
280-
# Use the kubeconfig for the cluster
281-
"name": f"{kubernetes_cluster.id}-kubeconfig",
282-
"key": "value",
311+
"name": kubeconfig_secret_name,
312+
"key": kubeconfig_secret_key,
283313
},
284314
"zenithIdentityRealmName": zenith_identity_realm_name,
285315
"values": values,

api/azimuth/provider/base.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ def tenancies(self) -> Iterable[dto.Tenancy]:
127127
# Convert the tenancies from the auth DTO to the provider DTO
128128
return [dto.Tenancy(t.id, t.name) for t in self.auth_session.tenancies()]
129129

130+
def _requires_credential(self) -> bool:
131+
"""
132+
Indicates whether the provider requires a credential.
133+
"""
134+
# By default, we always require a credential
135+
return True
136+
130137
def _scoped_session(
131138
self,
132139
auth_user: auth_dto.User,
@@ -154,13 +161,21 @@ def scoped_session(self, tenancy: Union[dto.Tenancy, str]) -> 'ScopedSession':
154161
raise errors.ObjectNotFoundError(
155162
"Could not find tenancy with ID {}.".format(tenancy)
156163
)
157-
# Get the credential from the auth session for this provider
158-
credential = self.auth_session.credential(tenancy.id, self.provider_name)
159-
# If the auth session is unable to supply a credential for the provider, bail
160-
if not credential:
161-
msg = f"no credentials available for {self.provider_name} provider"
162-
raise errors.InvalidOperationError(msg)
163-
return self._scoped_session(self.auth_user, tenancy, credential.data)
164+
# If the provider requires a credential, try to find one
165+
if self._requires_credential():
166+
# Get the credential from the auth session for this provider
167+
credential = self.auth_session.credential(tenancy.id, self.provider_name)
168+
# If a credential is available, extract the data
169+
# If the auth session is unable to supply a credential, bail
170+
if credential:
171+
credential_data = credential.data
172+
else:
173+
msg = f"no credentials available for {self.provider_name} provider"
174+
raise errors.InvalidOperationError(msg)
175+
else:
176+
# If the provider does not require a credential, just pass empty data
177+
credential_data = ""
178+
return self._scoped_session(self.auth_user, tenancy, credential_data)
164179

165180
def close(self):
166181
"""

api/azimuth/provider/null.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from . import base, dto
2+
3+
4+
class Provider(base.Provider):
5+
"""
6+
Null provider implementation that doesn't implement anything.
7+
"""
8+
provider_name = "null"
9+
10+
def _from_auth_session(self, auth_session, auth_user):
11+
return UnscopedSession(auth_session, auth_user)
12+
13+
14+
class UnscopedSession(base.UnscopedSession):
15+
"""
16+
Unscoped session implementation for the null provider.
17+
"""
18+
provider_name = "null"
19+
20+
def _requires_credential(self):
21+
# The null provider does not require a credential
22+
return False
23+
24+
def _scoped_session(self, auth_user, tenancy, credential_data):
25+
return ScopedSession(auth_user, tenancy)
26+
27+
28+
class ScopedSession(base.ScopedSession):
29+
"""
30+
Scoped session implementation for the null provider.
31+
"""
32+
provider_name = "null"
33+
34+
def capabilities(self):
35+
return dto.Capabilities(supports_volumes = False)

api/azimuth/serializers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1053,7 +1053,10 @@ class CreateKubernetesAppSerializer(serializers.Serializer):
10531053
"^[a-z0-9-]+$",
10541054
write_only = True,
10551055
# The apps driver decides whether the cluster is required or not
1056-
required = False
1056+
# So the field is not required and empty strings and null are allowed
1057+
required = False,
1058+
allow_blank = True,
1059+
allow_null = True
10571060
)
10581061
values = serializers.JSONField(write_only = True)
10591062

api/azimuth/settings.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,18 @@ class AppsProviderSetting(ObjectFactorySetting):
120120
Custom setting for the Kubernetes app provider.
121121
"""
122122
def _get_default(self, instance):
123-
# For now, apps are only available if a Kubernetes provider is configured
123+
# If a Cluster API provider is configured, use the helmrelease provider
124+
# If not, use the app provider with the default settings
124125
if instance.CLUSTER_API_PROVIDER:
125-
# By default, we use the HelmRelease provider (for now)
126126
return {
127127
"FACTORY": "azimuth.apps.helmrelease.Provider",
128128
"PARAMS": {},
129129
}
130130
else:
131-
return None
131+
return {
132+
"FACTORY": "azimuth.apps.app.Provider",
133+
"PARAMS": {},
134+
}
132135

133136

134137
class AppsSettings(SettingsObject):

chart/files/api/settings/04-cloud-provider.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ AZIMUTH:
1717
{{- with .Values.provider.openstack.internalNetDNSNameservers }}
1818
INTERNAL_NET_DNS_NAMESERVERS: {{ toYaml . | nindent 8 }}
1919
{{- end }}
20+
{{- else if (eq .Values.provider.type "null")}}
21+
FACTORY: azimuth.provider.null.Provider
22+
PARAMS: {}
2023
{{- else }}
2124
{{- fail (printf "Unrecognised cloud provider '%s'" .Values.provider.type) }}
2225
{{- end }}
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1+
{{- if .Values.appsProvider.enabled }}
2+
{{- if eq .Values.appsProvider.type "app" }}
13
AZIMUTH:
2-
{{- if .Values.appsProvider.enabled }}
34
APPS_PROVIDER:
4-
FACTORY: azimuth.apps.{{ .Values.appsProvider.type }}.Provider
5+
FACTORY: azimuth.apps.app.Provider
56
PARAMS: {}
6-
{{- else }}
7-
APPS_PROVIDER: null
8-
{{- end }}
7+
{{- else if eq .Values.appsProvider.type "helmrelease" }}
8+
AZIMUTH:
9+
APPS_PROVIDER:
10+
FACTORY: azimuth.apps.helmrelease.Provider
11+
PARAMS: {}
12+
{{- else if ne .Values.appsProvider.type "default" }}
13+
{{- fail (printf "Unrecognised apps provider '%s'" .Values.appsProvider.type) }}
14+
{{- end }}
15+
{{- else }}
16+
AZIMUTH:
17+
APPS_PROVIDER: ~
18+
{{- end }}

chart/values.yaml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,9 @@ authentication:
250250

251251
# The cloud provider to use
252252
provider:
253-
# The type of provider to use (currently only openstack is supported)
253+
# The type of provider to use - openstack and null are supported
254+
# The null provider does not support any cloud functionality, but does support deploying
255+
# apps on a pre-configured Kubernetes cluster
254256
type: openstack
255257
# Parameters for the openstack provider
256258
openstack:
@@ -406,10 +408,13 @@ scheduling:
406408

407409
# Settings for the Kubernetes apps provider
408410
appsProvider:
409-
# Indicates whether the apps provider should be enabled
411+
# Indicates if the apps provider should be enabled
410412
enabled: true
411-
# The type of provider to use, one of "helmrelease" or "app"
412-
type: helmrelease
413+
# The apps provider to use - valid options are default, helmrelease and app
414+
# If default is specified then the helmrelease provider is used if Cluster
415+
# API is available (for backwards compatibility), and the app provider is
416+
# used when Cluster API is not available
417+
type: default
413418

414419
# Properties for applying themes
415420
theme:

ui/Dockerfile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ RUN gpg2 --keyserver hkp://keyserver.ubuntu.com:80 --keyserver-options timeout=1
2929

3030
FROM ubuntu:jammy
3131

32-
ARG NGINX_VERSION
33-
3432
# Copy the GPG key from the intermediate container
3533
COPY --from=nginx-gpg-key /usr/share/keyrings/nginx-archive-keyring.gpg /usr/share/keyrings/
3634

@@ -45,6 +43,7 @@ COPY --from=nginx-gpg-key /usr/share/keyrings/nginx-archive-keyring.gpg /usr/sha
4543
# 3. Use a subdirectory of /var/run for the pid file that is writable by the nginx user
4644
# When running with a RO filesystem we will mount a writable directory over it,
4745
# but we don't want to make all of /var/run writable
46+
RUN echo "Target NGINX version: ${NGINX_VERSION}"
4847
RUN apt-get update && \
4948
apt-get install --no-install-recommends --no-install-suggests -y ca-certificates && \
5049
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] https://nginx.org/packages/mainline/ubuntu/ jammy nginx" \

ui/src/application.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ const ConnectedTenancyResourcePage = connect(
140140

141141
// Ensure that we initialise the resources we need to decide if platforms are supported
142142
// Platforms are supported if:
143-
// The tenancy has access to at least one cluster type or Kubernetes template
143+
// The tenancy has access to at least one platform type (i.e. CaaS, Kubernetes, Kubernetes app)
144144
// OR
145145
// There is at least one platform deployed in the tenancy
146146
useResourceInitialised(
@@ -159,23 +159,35 @@ const ConnectedTenancyResourcePage = connect(
159159
currentTenancy.kubernetesClusters,
160160
boundTenancyActions.kubernetesCluster.fetchList
161161
);
162+
useResourceInitialised(
163+
currentTenancy.kubernetesAppTemplates,
164+
boundTenancyActions.kubernetesAppTemplate.fetchList
165+
);
166+
useResourceInitialised(
167+
currentTenancy.kubernetesApps,
168+
boundTenancyActions.kubernetesApp.fetchList
169+
);
162170

163171
// Decide whether we have enough information yet to decide if platforms are supported
164172
let supportsPlatforms;
165173
if(
166174
Object.keys(currentTenancy.clusterTypes.data || {}).length > 0 ||
167175
Object.keys(currentTenancy.kubernetesClusterTemplates.data || {}).length > 0 ||
176+
Object.keys(currentTenancy.kubernetesAppTemplates.data || {}).length > 0 ||
168177
Object.keys(currentTenancy.clusters.data || {}).length > 0 ||
169-
Object.keys(currentTenancy.kubernetesClusters.data || {}).length > 0
178+
Object.keys(currentTenancy.kubernetesClusters.data || {}).length > 0 ||
179+
Object.keys(currentTenancy.kubernetesApps.data || {}).length > 0
170180
) {
171181
// If one of the resources has some data, platforms are supported
172182
supportsPlatforms = true;
173183
}
174184
else if(
175185
currentTenancy.clusterTypes.initialised &&
176186
currentTenancy.kubernetesClusterTemplates.initialised &&
187+
currentTenancy.kubernetesAppTemplates.initialised &&
177188
currentTenancy.clusters.initialised &&
178-
currentTenancy.kubernetesClusters.initialised
189+
currentTenancy.kubernetesClusters.initialised &&
190+
currentTenancy.kubernetesApps.initialised
179191
) {
180192
// If none of the resources has any data but they are all initialised, platforms
181193
// are not supported
@@ -184,8 +196,10 @@ const ConnectedTenancyResourcePage = connect(
184196
else if(
185197
currentTenancy.clusterTypes.fetching ||
186198
currentTenancy.kubernetesClusterTemplates.fetching ||
199+
currentTenancy.kubernetesAppTemplates.fetching ||
187200
currentTenancy.clusters.fetching ||
188-
currentTenancy.kubernetesClusters.fetching
201+
currentTenancy.kubernetesClusters.fetching ||
202+
currentTenancy.kubernetesApps.fetching
189203
) {
190204
// If one of the resources is still fetching, we can't decide yet
191205
return (
@@ -204,8 +218,10 @@ const ConnectedTenancyResourcePage = connect(
204218
[
205219
currentTenancy.clusterTypes.fetchError,
206220
currentTenancy.kubernetesClusterTemplates.fetchError,
221+
currentTenancy.kubernetesAppTemplates.fetchError,
207222
currentTenancy.clusters.fetchError,
208-
currentTenancy.kubernetesClusters.fetchError
223+
currentTenancy.kubernetesClusters.fetchError,
224+
currentTenancy.kubernetesApps.fetchError
209225
].filter(e => !!e)
210226
);
211227
if( fetchErrors.length == 0 ) {

0 commit comments

Comments
 (0)