Skip to content

Commit b455a6b

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 b455a6b

10 files changed

Lines changed: 201 additions & 14 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: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import logging
2323

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

2726
from openserverless.config.app_config import AppConfig
2827
from openserverless.error.config_exception import ConfigException
@@ -34,7 +33,27 @@
3433

3534

3635
def _join_host_port(host, port):
36+
"""
37+
Join host and port into a URL format.
38+
>>> _join_host_port("localhost", "8080")
39+
'localhost:8080'
40+
>>> _join_host_port("localhost", 8080)
41+
'localhost:8080'
42+
>>> _join_host_port("localhost", "80")
43+
'localhost:80'
44+
>>> _join_host_port("localhost", "abcd")
45+
Traceback (most recent call last):
46+
...
47+
ValueError: Port must be numeric
48+
49+
"""
3750
template = "%s:%s"
51+
try:
52+
port_int = int(port)
53+
port = str(port_int)
54+
except (ValueError, TypeError):
55+
raise ValueError("Port must be numeric")
56+
3857
host_requires_bracketing = ":" in host or "%" in host
3958
if host_requires_bracketing:
4059
template = "[%s]:%s"
@@ -54,6 +73,13 @@ def _parse_b64(self, encoded_str):
5473
Decode b64 encoded string
5574
param: encoded_str a Base64 encoded string
5675
return: decoded string
76+
>>> oa = KubeApiClient()
77+
>>> oa._parse_b64("aGVsbG8gd29ybGQ=")
78+
'hello world'
79+
>>> oa._parse_b64("aGVsbG8gd29ybGQ")
80+
Traceback (most recent call last):
81+
...
82+
openserverless.error.config_exception.ConfigException: Could not decode base64 encoded value
5783
"""
5884
try:
5985
return b64decode(encoded_str).decode()
@@ -254,7 +280,13 @@ def get_config_map(self, cm_name, namespace="nuvolaris"):
254280
return None
255281

256282
def post_config_map(self, cm_name, file_or_dir, namespace="nuvolaris"):
257-
283+
"""
284+
Create a ConfigMap from a file or directory.
285+
:param cm_name: Name of the ConfigMap.
286+
:param file_or_dir: Path to the file or directory containing the data.
287+
:param namespace: Namespace where the ConfigMap will be created.
288+
:return: The created ConfigMap or None if failed.
289+
"""
258290
if not os.path.exists(file_or_dir):
259291
raise ConfigException(f"File or directory {file_or_dir} does not exist.")
260292

@@ -301,6 +333,12 @@ def post_config_map(self, cm_name, file_or_dir, namespace="nuvolaris"):
301333
return None
302334

303335
def delete_config_map(self, cm_name, namespace="nuvolaris"):
336+
"""
337+
Delete a ConfigMap by name.
338+
:param cm_name: Name of the ConfigMap to delete.
339+
:param namespace: Namespace where the ConfigMap is located.
340+
:return: True if deletion was successful, False otherwise.
341+
"""
304342
url = f"{self.host}/api/v1/namespaces/{namespace}/configmaps/{cm_name}"
305343
headers = {"Authorization": self.token}
306344

@@ -416,8 +454,14 @@ def delete_secret(self, secret_name, namespace="nuvolaris"):
416454
logging.error(f"delete_secret {ex}")
417455
return False
418456

419-
# --- CREA JOB ---
420-
def post_job(self, job_name, job_manifest, namespace="nuvolaris"):
457+
def post_job(self, job_name, job_manifest, namespace="nuvolaris"):
458+
"""
459+
Create a Kubernetes job.
460+
:param job_name: Name of the job.
461+
:param job_manifest: Dictionary containing the job manifest.
462+
:param namespace: Namespace where the job will be created.
463+
:return: The created job or None if failed.
464+
"""
421465
url = f"{self.host}/apis/batch/v1/namespaces/{namespace}/jobs"
422466
headers = {"Authorization": self.token}
423467
try:
@@ -437,8 +481,13 @@ def post_job(self, job_name, job_manifest, namespace="nuvolaris"):
437481
logging.error(f"post_job {ex}")
438482
return None
439483

440-
# --- OTTIENI POD ---
441484
def get_pod_by_job_name(self, job_name, namespace="nuvolaris"):
485+
"""
486+
Get the pod name associated with a job by its name.
487+
:param job_name: Name of the job.
488+
:param namespace: Namespace where the job is located.
489+
:return: The pod name if found, None otherwise.
490+
"""
442491
url = f"{self.host}/api/v1/namespaces/{namespace}/pods"
443492
headers = {"Authorization": self.token}
444493
try:
@@ -466,17 +515,26 @@ def get_pod_by_job_name(self, job_name, namespace="nuvolaris"):
466515
logging.error(f"get_pod_by_job_name {ex}")
467516
return None
468517

469-
# --- LEGGI LOG POD ---
470518
def stream_pod_logs(self, pod_name, namespace="nuvolaris"):
519+
"""
520+
Stream logs from a specific pod.
521+
:param pod_name: Name of the pod to stream logs from.
522+
:param namespace: Namespace where the pod is located.
523+
"""
471524
url = f"{self.host}/api/v1/namespaces/{namespace}/pods/{pod_name}/log?follow=true"
472525
headers = {"Authorization": self.token}
473526
with req.get(url, headers=headers, verify=self.ssl_ca_cert, stream=True) as r:
474527
for line in r.iter_lines():
475528
if line:
476529
print(line.decode())
477530

478-
# --- CHECK STATUS JOB ---
479531
def check_job_status(self, job_name, namespace="nuvolaris"):
532+
"""
533+
Check the status of a job by its name.
534+
:param job_name: Name of the job to check.
535+
:param namespace: Namespace where the job is located.
536+
:return: True if the job has succeeded, False otherwise.
537+
"""
480538
url = f"{self.host}/apis/batch/v1/namespaces/{namespace}/jobs/{job_name}"
481539
headers = {"Authorization": self.token}
482540
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: 18 additions & 0 deletions
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,6 +50,11 @@ 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:

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)