Skip to content

Commit 0255ef9

Browse files
committed
test: added tests
added tests and more documentation in docstrings. Added a TODO.md file to track the new things to do
1 parent d2edcff commit 0255ef9

10 files changed

Lines changed: 204 additions & 31 deletions

File tree

.github/cisetup.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@
1818
#
1919
sudo sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin
2020
sudo apt-get -y install curl wget jq
21+
pip install uv

.github/workflows/check.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,12 @@ jobs:
3434
submodules: recursive
3535
- name: License
3636
uses: apache/skywalking-eyes@main
37+
- name: Set up Python 3.12
38+
uses: actions/setup-python@v4
39+
with:
40+
python-version: 3.12
41+
- name: Setup
42+
run: bash .github/cisetup.sh
43+
- name: Unit Tests
44+
run: task utest
45+
continue-on-error: false

.licenserc.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ header:
2828
- 'LICENSE'
2929
- 'NOTICE'
3030
- 'DISCLAIMER'
31+
- 'deploy/samples/requirements.txt'
3132
- '**/*.json'
3233
- '**/*.service'
3334
- '**/*.txt'

TODO.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<!--
2+
~ Licensed to the Apache Software Foundation (ASF) under one
3+
~ or more contributor license agreements. See the NOTICE file
4+
~ distributed with this work for additional information
5+
~ regarding copyright ownership. The ASF licenses this file
6+
~ to you under the Apache License, Version 2.0 (the
7+
~ "License"); you may not use this file except in compliance
8+
~ with the License. You may obtain a copy of the License at
9+
~
10+
~ http://www.apache.org/licenses/LICENSE-2.0
11+
~
12+
~ Unless required by applicable law or agreed to in writing,
13+
~ software distributed under the License is distributed on an
14+
~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
~ KIND, either express or implied. See the License for the
16+
~ specific language governing permissions and limitations
17+
~ under the License.
18+
~
19+
-->
20+
# TODO
21+
22+
## Tests
23+
Add integration and unit tests
24+
25+
## Various
26+
27+
- [ ] `openserverless.common.whis_user_data.py` - Add `with_` blocks for other new OpenServerless Services
28+
- [ ] `openserverless.common.whisk_user_generator` - Check if `generate_whisk_user_yaml` is complete

Taskfile.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,12 @@ tasks:
126126
BASEIMG=$(task base-image-name)
127127
IMG="$BASEIMG:{{.TAG}}"
128128
kind load docker-image $IMG --name=nuvolaris
129+
130+
utest:
131+
cmds:
132+
- |
133+
for test in openserverless/common/{{.T}}*.py
134+
do echo "*** [{{.KUBE}}] $test"
135+
uv run python3 -m doctest -o ELLIPSIS $test {{.CLI_ARGS}}
136+
done
137+
silent: true

deploy/buildkit/buildkitd.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
#
118
# =========================
219
# Worker OCI (rootlesskit)
320
# =========================

openserverless/common/kube_api_client.py

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,15 @@
2222
import logging
2323

2424
from base64 import b64decode, b64encode
25-
from .validation import is_empty_arg
2625

26+
from openserverless.common.utils import join_host_port
2727
from openserverless.config.app_config import AppConfig
2828
from openserverless.error.config_exception import ConfigException
2929

3030
SERVICE_HOST_ENV_NAME = "KUBERNETES_SERVICE_HOST"
3131
SERVICE_PORT_ENV_NAME = "KUBERNETES_SERVICE_PORT"
3232
SERVICE_TOKEN_FILENAME = "/var/run/secrets/kubernetes.io/serviceaccount/token"
3333
SERVICE_CERT_FILENAME = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
34-
35-
36-
def _join_host_port(host, port):
37-
template = "%s:%s"
38-
host_requires_bracketing = ":" in host or "%" in host
39-
if host_requires_bracketing:
40-
template = "[%s]:%s"
41-
return template % (host, port)
42-
43-
4434
class KubeApiClient:
4535

4636
def __init__(self, environ=os.environ):
@@ -50,11 +40,6 @@ def __init__(self, environ=os.environ):
5040
self._load_incluster_config()
5141

5242
def _parse_b64(self, encoded_str):
53-
"""
54-
Decode b64 encoded string
55-
param: encoded_str a Base64 encoded string
56-
return: decoded string
57-
"""
5843
try:
5944
return b64decode(encoded_str).decode()
6045
except:
@@ -73,7 +58,7 @@ def _load_incluster_config(self):
7358
):
7459
raise ConfigException("Service host/port is not set.")
7560

76-
self.host = "https://" + _join_host_port(
61+
self.host = "https://" + join_host_port(
7762
self._environ.get(SERVICE_HOST_ENV_NAME),
7863
self._environ.get(SERVICE_PORT_ENV_NAME),
7964
)
@@ -254,7 +239,13 @@ def get_config_map(self, cm_name, namespace="nuvolaris"):
254239
return None
255240

256241
def post_config_map(self, cm_name, file_or_dir, namespace="nuvolaris"):
257-
242+
"""
243+
Create a ConfigMap from a file or directory.
244+
:param cm_name: Name of the ConfigMap.
245+
:param file_or_dir: Path to the file or directory containing the data.
246+
:param namespace: Namespace where the ConfigMap will be created.
247+
:return: The created ConfigMap or None if failed.
248+
"""
258249
if not os.path.exists(file_or_dir):
259250
raise ConfigException(f"File or directory {file_or_dir} does not exist.")
260251

@@ -301,6 +292,12 @@ def post_config_map(self, cm_name, file_or_dir, namespace="nuvolaris"):
301292
return None
302293

303294
def delete_config_map(self, cm_name, namespace="nuvolaris"):
295+
"""
296+
Delete a ConfigMap by name.
297+
:param cm_name: Name of the ConfigMap to delete.
298+
:param namespace: Namespace where the ConfigMap is located.
299+
:return: True if deletion was successful, False otherwise.
300+
"""
304301
url = f"{self.host}/api/v1/namespaces/{namespace}/configmaps/{cm_name}"
305302
headers = {"Authorization": self.token}
306303

@@ -416,8 +413,14 @@ def delete_secret(self, secret_name, namespace="nuvolaris"):
416413
logging.error(f"delete_secret {ex}")
417414
return False
418415

419-
# --- CREA JOB ---
420-
def post_job(self, job_name, job_manifest, namespace="nuvolaris"):
416+
def post_job(self, job_name, job_manifest, namespace="nuvolaris"):
417+
"""
418+
Create a Kubernetes job.
419+
:param job_name: Name of the job.
420+
:param job_manifest: Dictionary containing the job manifest.
421+
:param namespace: Namespace where the job will be created.
422+
:return: The created job or None if failed.
423+
"""
421424
url = f"{self.host}/apis/batch/v1/namespaces/{namespace}/jobs"
422425
headers = {"Authorization": self.token}
423426
try:
@@ -437,8 +440,13 @@ def post_job(self, job_name, job_manifest, namespace="nuvolaris"):
437440
logging.error(f"post_job {ex}")
438441
return None
439442

440-
# --- OTTIENI POD ---
441443
def get_pod_by_job_name(self, job_name, namespace="nuvolaris"):
444+
"""
445+
Get the pod name associated with a job by its name.
446+
:param job_name: Name of the job.
447+
:param namespace: Namespace where the job is located.
448+
:return: The pod name if found, None otherwise.
449+
"""
442450
url = f"{self.host}/api/v1/namespaces/{namespace}/pods"
443451
headers = {"Authorization": self.token}
444452
try:
@@ -466,17 +474,26 @@ def get_pod_by_job_name(self, job_name, namespace="nuvolaris"):
466474
logging.error(f"get_pod_by_job_name {ex}")
467475
return None
468476

469-
# --- LEGGI LOG POD ---
470477
def stream_pod_logs(self, pod_name, namespace="nuvolaris"):
478+
"""
479+
Stream logs from a specific pod.
480+
:param pod_name: Name of the pod to stream logs from.
481+
:param namespace: Namespace where the pod is located.
482+
"""
471483
url = f"{self.host}/api/v1/namespaces/{namespace}/pods/{pod_name}/log?follow=true"
472484
headers = {"Authorization": self.token}
473485
with req.get(url, headers=headers, verify=self.ssl_ca_cert, stream=True) as r:
474486
for line in r.iter_lines():
475487
if line:
476488
print(line.decode())
477489

478-
# --- CHECK STATUS JOB ---
479490
def check_job_status(self, job_name, namespace="nuvolaris"):
491+
"""
492+
Check the status of a job by its name.
493+
:param job_name: Name of the job to check.
494+
:param namespace: Namespace where the job is located.
495+
:return: True if the job has succeeded, False otherwise.
496+
"""
480497
url = f"{self.host}/apis/batch/v1/namespaces/{namespace}/jobs/{job_name}"
481498
headers = {"Authorization": self.token}
482499
try:

openserverless/common/openwhisk_authorize.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ class OpenwhiskAuthorize:
3333
def __init__(self, environ=os.environ):
3434
self._db = CouchDB()
3535
self._environ = environ
36-
3736

3837
def encode(self, username, password):
3938
"""Returns an HTTP basic authentication encrypted string given a valid
@@ -46,23 +45,52 @@ def encode(self, username, password):
4645
return f"Basic {b64encode(username_password.encode()).decode()}"
4746

4847
def _parse_b64(self, encoded_str):
48+
"""
49+
Parse a base64 encoded string and return the username and password.
50+
If the string is not base64 encoded, it will try to split it by ':'.
51+
Raises DecodeError if the string cannot be decoded or parsed.
52+
>>> oa = OpenwhiskAuthorize()
53+
>>> oa._parse_b64("dXNlcm5hbWU6cGFzc3dvcmQ=")
54+
('username', 'password')
55+
>>> oa._parse_b64("username:password")
56+
('username', 'password')
57+
>>> oa._parse_b64("invalid_base64_string")
58+
Traceback (most recent call last):
59+
...
60+
openserverless.error.api_error.DecodeError: authentication token does not seems to be b64 encoded
61+
"""
62+
username = None
63+
password = None
4964
try:
50-
username, password = b64decode(encoded_str).decode().split(":", 1)
65+
decoded = b64decode(encoded_str)
66+
67+
credentials = decoded.decode()
68+
if credentials.count(":") != 1:
69+
raise DecodeError("authentication token does not seems to be b64 encoded")
70+
username, password = credentials.split(":", 1)
5171
except:
5272
# fallback in case the token is not bas64 encoded
53-
username, password = encoded_str.split(":", 1)
73+
if encoded_str.count(":") == 1:
74+
username, password = encoded_str.split(":", 1)
5475

55-
if not username or not password:
56-
raise DecodeError(
57-
"authentication token does not seems to be b64 encoded"
58-
)
76+
if not username or not password:
77+
raise DecodeError("authentication token does not seems to be b64 encoded")
5978

6079
return username, password
6180

6281
def decode(self, encoded_str):
6382
"""Decode an encrypted HTTP basic authentication string. Returns a tuple of
6483
the form (username, password), and raises a DecodeError exception if
6584
nothing could be decoded.
85+
>>> oa = OpenwhiskAuthorize()
86+
>>> oa.decode("Basic dXNlcm5hbWU6cGFzc3dvcmQ=")
87+
('username', 'password')
88+
>>> oa.decode("dXNlcm5hbWU6cGFzc3dvcmQ=")
89+
('username', 'password')
90+
>>> oa.decode("invalid_base64_string")
91+
Traceback (most recent call last):
92+
...
93+
openserverless.error.api_error.DecodeError: authentication token does not seems to be b64 encoded
6694
"""
6795
split = encoded_str.strip().split(" ")
6896

openserverless/common/utils.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ def env_to_dict(user_data, key="env"):
2121
2222
Keyword arguments:
2323
key -- the key to extract the env from
24+
25+
>>> env_to_dict({"env": [{"key": "VAR1", "value": "value1"}, {"key": "VAR2", "value": "value2"}]})
26+
{'VAR1': 'value1', 'VAR2': 'value2'}
27+
>>> env_to_dict({"env": []})
28+
{}
29+
>>> env_to_dict({"other_key": [{"key": "VAR1", "value": "value1"}]}, key="other_key")
30+
{'VAR1': 'value1'}
31+
>>> env_to_dict({"env": [{"key": "VAR1", "value": "value1"}]}, key="env")
32+
{'VAR1': 'value1'}
33+
>>> env_to_dict({"env": [{"key": "VAR1", "value": "value1"}, {"key": "VAR2", "value": "value2"}]}, key="non_existent_key")
34+
{}
35+
>>> env_to_dict({"env": [{"key": "VAR1", "value": "value1"}]}, key="env")
36+
{'VAR1': 'value1'}
2437
"""
2538
body = {}
2639
if key in user_data:
@@ -37,9 +50,41 @@ def env_to_dict(user_data, key="env"):
3750
def dict_to_env(env):
3851
"""
3952
converts an env to a key/pair suitable for user_data storage
53+
54+
>>> dict_to_env({"VAR1": "value1", "VAR2": "value2"})
55+
[{'key': 'VAR1', 'value': 'value1'}, {'key': 'VAR2', 'value': 'value2'}]
56+
>>> dict_to_env({})
57+
[]
4058
"""
4159
body = []
4260
for key in env:
4361
body.append({"key": key, "value": env[key]})
4462

45-
return body
63+
return body
64+
65+
def join_host_port(host, port):
66+
"""
67+
Join host and port into a URL format.
68+
>>> join_host_port("localhost", "8080")
69+
'localhost:8080'
70+
>>> join_host_port("localhost", 8080)
71+
'localhost:8080'
72+
>>> join_host_port("localhost", "80")
73+
'localhost:80'
74+
>>> join_host_port("localhost", "abcd")
75+
Traceback (most recent call last):
76+
...
77+
ValueError: Port must be numeric
78+
79+
"""
80+
template = "%s:%s"
81+
try:
82+
port_int = int(port)
83+
port = str(port_int)
84+
except (ValueError, TypeError):
85+
raise ValueError("Port must be numeric")
86+
87+
host_requires_bracketing = ":" in host or "%" in host
88+
if host_requires_bracketing:
89+
template = "[%s]:%s"
90+
return template % (host, port)

openserverless/common/validation.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@
2121
def is_valid_username(username):
2222
"""
2323
Verifies the given username follows nuvolaris rule
24+
>>> is_valid_username("bruno")
25+
True
26+
>>> is_valid_username("bruno123")
27+
True
28+
>>> is_valid_username("bruno-123")
29+
False
30+
>>> is_valid_username("bruno_123")
31+
False
32+
>>> is_valid_username("bruno@123")
33+
False
34+
>>> is_valid_username("bruno 123")
35+
False
36+
>>> is_valid_username("brun")
37+
False
2438
"""
2539
pat = re.compile(r"^[a-z0-9]{5,60}(?:[a-z0-9])?$")
2640
if re.fullmatch(pat, username):
@@ -35,6 +49,10 @@ def is_empty_arg(args, arg_name):
3549
param: args
3650
param: arg_name
3751
return: True if the argument is not contained in the input args array or if it is an empty string value
52+
>>> is_empty_arg({"arg1": "value1"}, "arg1")
53+
False
54+
>>> is_empty_arg({"arg1": "value1"}, "arg2")
55+
True
3856
"""
3957

4058
if arg_name not in args:

0 commit comments

Comments
 (0)