Skip to content

Commit fff6a12

Browse files
authored
add support for using ssl (#2)
* add support for using ssl also changing the default port to 8443 to mimic other servers i have seen! Signed-off-by: vsoch <[email protected]>
1 parent 092b8a0 commit fff6a12

File tree

10 files changed

+268
-43
lines changed

10 files changed

+268
-43
lines changed

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# CHANGELOG
2+
3+
This is a manually generated log to track changes to the repository for each release.
4+
Each section should include general headers such as **Implemented enhancements**
5+
and **Merged pull requests**. Critical items to know are:
6+
7+
- renamed commands
8+
- deprecated / removed commands
9+
- changed defaults
10+
- backward incompatible changes
11+
- migration guidance
12+
- changed behaviour
13+
14+
The versions coincide with releases on pip. Only major versions will be released as tags on Github.
15+
16+
## [0.0.x](https://github.com/converged-computing/flux-metrics-api/tree/main) (0.0.x)
17+
- Support for certificates for uvicorn and change default port to 8443 (0.0.1)
18+
- Skelton release (0.0.0)

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
FROM fluxrm/flux-sched:focal
22

33
# docker build -t flux_metrics_api .
4-
# docker run -it -p 8080:8080 flux_metrics_api
4+
# docker run -it -p 8443:8443 flux_metrics_api
55

66
LABEL maintainer="Vanessasaurus <@vsoch>"
77

README.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ You'll want to be running in a Flux instance, as we need to connect to the broke
4646
$ flux start --test-size=4
4747
```
4848

49-
And then start the server. This will use a default port and host (0.0.0.0:8080) that you can customize
49+
And then start the server. This will use a default port and host (0.0.0.0:8443) that you can customize
5050
if desired.
5151

5252
```bash
@@ -56,6 +56,12 @@ $ flux-metrics-api start
5656
$ flux-metrics-api start --port 9000 --host 127.0.0.1
5757
```
5858

59+
If you want ssl (port 443) you can provide the path to a certificate and keyfile:
60+
61+
```bash
62+
$ flux-metrics-api start --ssl-certfile /etc/certs/tls.crt --ssl-keyfile /etc/certs/tls.key
63+
```
64+
5965
See `--help` to see other options available.
6066

6167
### Endpoints
@@ -67,7 +73,7 @@ See `--help` to see other options available.
6773
Here is an example to get the "node_up_count" metric:
6874

6975
```bash
70-
curl -s http://localhost:8080/apis/custom.metrics.k8s.io/v1beta2/namespaces/flux-operator/metrics/node_up_count | jq
76+
curl -s http://localhost:8443/apis/custom.metrics.k8s.io/v1beta2/namespaces/flux-operator/metrics/node_up_count | jq
7177
```
7278
```console
7379
{
@@ -101,15 +107,20 @@ be a demo. You can either build it yourself, or use our build.
101107

102108
```bash
103109
$ docker build -t flux_metrics_api .
104-
$ docker run -it -p 8080:8080 flux_metrics_api
110+
$ docker run -it -p 8443:8443 flux_metrics_api
105111
```
106112
or
107113

108114
```bash
109-
$ docker run -it -p 8080:8080 ghcr.io/converged-computing/flux-metrics-api
115+
$ docker run -it -p 8443:8443 ghcr.io/converged-computing/flux-metrics-api
110116
```
111117

112-
You can then open up the browser at [http://localhost:8080/metrics/](http://localhost:8080/metrics) to see
118+
### Development
119+
120+
Note that this is implemented in Python, but (I found this after) we could [also use Go](https://github.com/kubernetes-sigs/custom-metrics-apiserver).
121+
Specifically, I found this repository useful to see the [spec format](https://github.com/kubernetes-sigs/custom-metrics-apiserver/blob/master/pkg/generated/openapi/custommetrics/zz_generated.openapi.go).
122+
123+
You can then open up the browser at [http://localhost:8443/metrics/](http://localhost:8443/metrics) to see
113124
the metrics!
114125

115126
## 😁️ Contributors 😁️

flux_metrics_api/apis.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Copyright 2023 Lawrence Livermore National Security, LLC and other
2+
# HPCIC DevTools Developers. See the top-level COPYRIGHT file for details.
3+
#
4+
# SPDX-License-Identifier: (MIT)
5+
6+
import json
7+
import os
8+
import subprocess
9+
10+
import flux_metrics_api.defaults as defaults
11+
import flux_metrics_api.utils as utils
12+
13+
# Global cache of responses
14+
cache = {}
15+
16+
17+
def get_kubernetes_endpoint(endpoint):
18+
"""
19+
Get an endpoint from the cluster.
20+
"""
21+
if defaults.USE_CACHE and endpoint in cache:
22+
return cache[endpoint]
23+
24+
# Point to the internal API server hostname
25+
api_server = "https://kubernetes.default.svc"
26+
27+
# Path to ServiceAccount directory
28+
sa_account_dir = "/var/run/secrets/kubernetes.io/serviceaccount"
29+
namespace_file = os.path.join(sa_account_dir, "namespace")
30+
cert_file = os.path.join(sa_account_dir, "ca.crt")
31+
token_file = os.path.join(sa_account_dir, "token")
32+
33+
# Cut out early if we aren't running in the pod
34+
if not all(
35+
map(os.path.exists, [sa_account_dir, namespace_file, token_file, cert_file])
36+
):
37+
return {}
38+
39+
# Get the token to do the request
40+
token = utils.read_file(token_file)
41+
42+
# Using subprocess to not add extra dependency - yes requires curl
43+
# res = requests.get(f"{api_server}/apis", headers=headers, verify=cert_file)
44+
# Kids don't do this at home
45+
output = subprocess.check_output(
46+
f'curl --cacert {cert_file} --header "Authorization: Bearer {token}" -X GET {api_server}/{endpoint}',
47+
shell=True,
48+
)
49+
try:
50+
output = json.loads(output)
51+
cache[endpoint] = output
52+
except Exception:
53+
return {}
54+
return output

flux_metrics_api/defaults.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
#
44
# SPDX-License-Identifier: (MIT)
55

6-
API_VERSION = "custom.metrics.k8s.io/v1beta2"
6+
API_ENDPOINT = "custom.metrics.k8s.io/v1beta2"
77
API_ROOT = "/apis/custom.metrics.k8s.io/v1beta2"
8-
NAMESPACES = None
8+
NAMESPACE = "flux-operator"
9+
SERVICE_NAME = "custom-metrics-apiserver"
10+
USE_CACHE = True
11+
12+
13+
def API_VERSION():
14+
"""
15+
Derive the api version from the endpoint
16+
"""
17+
global API_ENDPOINT
18+
return API_ENDPOINT.rstrip("/").rsplit("/")[-1]

flux_metrics_api/routes.py

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,42 @@
33
#
44
# SPDX-License-Identifier: (MIT)
55

6+
from apispec import APISpec
67
from starlette.endpoints import HTTPEndpoint
78
from starlette.responses import JSONResponse
89
from starlette.routing import Route
9-
from starlette.schemas import SchemaGenerator
10+
from starlette_apispec import APISpecSchemaGenerator
1011

1112
import flux_metrics_api.defaults as defaults
1213
import flux_metrics_api.types as types
1314
import flux_metrics_api.version as version
1415
from flux_metrics_api.metrics import metrics
1516

16-
schemas = SchemaGenerator(
17-
{
18-
"openapi": "3.0.0",
19-
"info": {"title": "Flux Metrics API", "version": version.__version__},
20-
}
17+
schemas = APISpecSchemaGenerator(
18+
APISpec(
19+
title="Flux Metrics API",
20+
version=version.__version__,
21+
openapi_version="3.0.0",
22+
info={"description": "Export Flux custom metrics."},
23+
)
24+
)
25+
26+
not_found_response = JSONResponse(
27+
{"detail": "The metric server is not running in a Kubernetes pod."},
28+
status_code=404,
2129
)
2230

2331

2432
class Root(HTTPEndpoint):
2533
"""
2634
Root of the API
2735
28-
This needs to return 200 for a health check
36+
This needs to return 200 for a health check. I later discovered it also needs
37+
to return the listing of available metrics!
2938
"""
3039

3140
async def get(self, request):
32-
return JSONResponse({})
41+
return JSONResponse(types.new_resource_list())
3342

3443

3544
def get_metric(request):
@@ -41,18 +50,13 @@ def get_metric(request):
4150
"""
4251
metric_name = request.path_params["metric_name"]
4352
namespace = request.path_params.get("namespace")
53+
print(f"Requested metric {metric_name} in namespace {namespace}")
4454

45-
if (
46-
namespace is not None
47-
and defaults.NAMESPACES is not None
48-
and namespace not in defaults.NAMESPACES
49-
):
50-
return JSONResponse(
51-
{"detail": "This namespace is not known to the server."}, status_code=404
52-
)
53-
55+
# TODO we don't do anything with namespace currently, we assume we won't
56+
# be able to hit this if running in the wrong one
5457
# Unknown metric
5558
if metric_name not in metrics:
59+
print(f"Unknown metric requested {metric_name}")
5660
return JSONResponse(
5761
{"detail": "This metric is not known to the server."}, status_code=404
5862
)
@@ -63,7 +67,10 @@ def get_metric(request):
6367
# Get the value from Flux, assemble into listing
6468
value = metrics[metric_name]()
6569
metric_value = types.new_metric(metric, value=value)
66-
listing = types.new_metric_list([metric_value])
70+
71+
# Give the endpoint for the service as metadata
72+
metadata = {"selfLink": defaults.API_ROOT}
73+
listing = types.new_metric_list([metric_value], metadata=metadata)
6774
return JSONResponse(listing)
6875

6976

@@ -79,24 +86,50 @@ async def get(self, request):
7986
return get_metric(request)
8087

8188

89+
class APIGroupList(HTTPEndpoint):
90+
"""
91+
Service a faux resource list just for our custom metrics endpoint.
92+
"""
93+
94+
async def get(self, request):
95+
listing = types.new_group_list()
96+
if not listing:
97+
return not_found_response
98+
return JSONResponse(listing)
99+
100+
101+
class OpenAPI(HTTPEndpoint):
102+
"""
103+
Forward the cluster openapi endpoint
104+
"""
105+
106+
async def get(self, request):
107+
version = request.path_params["version"]
108+
openapi = types.get_cluster_schema(version)
109+
if not openapi:
110+
return not_found_response
111+
return JSONResponse(openapi)
112+
113+
82114
def openapi_schema(request):
83115
"""
84116
Get the openapi spec from the endpoints
85-
86-
TODO: debug why paths empty
87117
"""
88118
return JSONResponse(schemas.get_schema(routes=routes))
89119

90120

91121
# STOPPED HERE - make open api spec s we can see endpoints and query
92122
routes = [
93123
Route(defaults.API_ROOT, Root),
94-
# Optional for openapi, we could add if needed
124+
# This is a faux route so we can get the preferred resource version
125+
Route("/apis", APIGroupList),
126+
Route("/openapi/{version}", OpenAPI),
95127
Route(defaults.API_ROOT + "/namespaces/{namespace}/metrics/{metric_name}", Metric),
96128
Route(defaults.API_ROOT + "/{resource}/{name}/{metric_name}", Metric),
97129
Route(
98130
defaults.API_ROOT + "/namespaces/{namespace}/{resource}/{name}/{metric_name}",
99131
Metric,
100132
),
133+
# Route("/openapi/v2", openapi_schema, include_in_schema=False),
101134
Route(f"{defaults.API_ROOT}/openapi/v2", openapi_schema, include_in_schema=False),
102135
]

flux_metrics_api/server.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,20 @@ def get_parser():
6565
)
6666
start.add_argument(
6767
"--port",
68-
help="Port to run application",
69-
default=8080,
68+
help="Port to run application (defaults to 8443)",
69+
default=8443,
7070
type=int,
7171
)
72+
start.add_argument("--namespace", help="Namespace the API is running in")
7273
start.add_argument(
73-
"--namespace", help="Scope to running in these namespace(s)", action="append"
74+
"--service-name", help="Service name the metrics service is running from"
7475
)
7576
start.add_argument(
7677
"--api-path",
7778
dest="api_path",
7879
help="Custom API path (defaults to /apis/custom.metrics.k8s.io/v1beta2)",
7980
default=None,
8081
)
81-
8282
start.add_argument(
8383
"--host",
8484
help="Host address to run application",
@@ -90,15 +90,35 @@ def get_parser():
9090
default=False,
9191
action="store_true",
9292
)
93+
start.add_argument(
94+
"--no-cache",
95+
help="Do not cache Kubernetes API responses.",
96+
default=False,
97+
action="store_true",
98+
)
99+
start.add_argument("--ssl-keyfile", help="full path to ssl keyfile")
100+
start.add_argument("--ssl-certfile", help="full path to ssl certfile")
93101
return parser
94102

95103

96104
def start(args):
97105
"""
98106
Start the server with uvicorn
99107
"""
108+
# Validate certificates if provided
109+
if args.ssl_keyfile and not args.ssl_certfile:
110+
sys.exit("A --ssl-keyfile was provided without a --ssl-certfile.")
111+
if args.ssl_certfile and not args.ssl_keyfile:
112+
sys.exit("A --ssl-certfile was provided without a --ssl-keyfile.")
113+
100114
app = Starlette(debug=args.debug, routes=routes)
101-
uvicorn.run(app, host=args.host, port=args.port)
115+
uvicorn.run(
116+
app,
117+
host=args.host,
118+
port=args.port,
119+
ssl_keyfile=args.ssl_keyfile,
120+
ssl_certfile=args.ssl_certfile,
121+
)
102122

103123

104124
def main():
@@ -131,14 +151,22 @@ def help(return_code=0):
131151
)
132152

133153
# Setup the registry - non verbose is default
134-
print(f"API endpoint is at {defaults.API_ROOT}")
135154
if args.api_path is not None:
136-
print(f"Setting API endpoint to {args.api_path}")
137155
defaults.API_ROOT = args.api_path
156+
print(f"API endpoint is at {defaults.API_ROOT}")
138157

139-
# Limit to specific namespaces?
158+
# Do not cache responses
159+
if args.no_cache is True:
160+
defaults.USE_CACHE = False
161+
162+
# Set namespace or service name to be different than defaults
140163
if args.namespace:
141-
defaults.NAMESPACES = args.namespace
164+
defaults.NAMESPACE = args.namespace
165+
print(f"Running from namespace {defaults.NAMESPACE}")
166+
167+
if args.service_name:
168+
defaults.SERVICE_NAME = args.service_name
169+
print(f"Service name {defaults.SERVICE_NAME}")
142170

143171
# Does the user want a shell?
144172
if args.command == "start":

0 commit comments

Comments
 (0)