diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 29046a7c..7dc82a30 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -23,4 +23,5 @@ about: Create a report to help us improve ### Possible Solution -Any thoughts as to potential solutions or ideas to go about finding one. Please include links to any research. +Any thoughts as to potential solutions or ideas to go about finding one. +Please include links to any research. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6ae866a2..8323daec 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,19 +14,22 @@ orientation. Examples of behavior that contributes to creating a positive environment include: -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community -- Showing empathy towards other community members +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members Examples of unacceptable behavior by participants include: -- The use of sexualized language or imagery and unwelcome sexual attention or advances -- Trolling, insulting/derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or electronic address, without explicit permission -- Other conduct which could reasonably be considered inappropriate in a professional setting +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting ## Our Responsibilities @@ -52,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at products@grycap.upv.es. All +reported by contacting the project team at . All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/README.md b/README.md index a3d39b88..f01c67c9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # OSCAR - Open Source Serverless Computing for Data-Processing Applications -[![Go Report Card](https://goreportcard.com/badge/github.com/grycap/oscar)](https://goreportcard.com/report/github.com/grycap/oscar) -[![Codacy Badge](https://app.codacy.com/project/badge/Coverage/8145efdfb9d24af1b5b53e21c6e2df99)](https://www.codacy.com/gh/grycap/oscar/dashboard?utm_source=github.com&utm_medium=referral&utm_content=grycap/oscar&utm_campaign=Badge_Coverage) +[![Go Report Card](https://goreportcard.com/badge/github.com/grycap/oscar/v3)](https://goreportcard.com/report/github.com/grycap/oscar/v3) +[![Codacy Badge](https://app.codacy.com/project/badge/Coverage/8145efdfb9d24af1b5b53e21c6e2df99)](https://app.codacy.com/gh/grycap/oscar/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) [![tests](https://github.com/grycap/oscar/actions/workflows/tests.yaml/badge.svg?branch=master)](https://github.com/grycap/oscar/actions/workflows/tests.yaml) [![build](https://github.com/grycap/oscar/workflows/build/badge.svg)](https://github.com/grycap/oscar/actions?query=workflow%3Abuild) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/grycap/oscar)](https://github.com/grycap/oscar/pkgs/container/oscar) diff --git a/deploy/ansible/README.md b/deploy/ansible/README.md index cd10fd90..bb79f54a 100644 --- a/deploy/ansible/README.md +++ b/deploy/ansible/README.md @@ -1,3 +1,3 @@ # Ansible playbook to deploy K3s and the OSCAR platform -Please refer to the [docs](https://docs.oscar.grycap.net/deploy-ansible/) for instructions. \ No newline at end of file +Please refer to the [docs](https://docs.oscar.grycap.net/deploy-ansible/) for instructions. diff --git a/docs/api.md b/docs/api.md index 07afb9af..6bef9b69 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,4 +4,8 @@ OSCAR exposes a secure REST API available at the Kubernetes master's node IP through an Ingress Controller. This API has been described following the [OpenAPI Specification](https://www.openapis.org/) and it is available below. +> ℹ️ +> +> The bearer token used to run a service can be either the OSCAR [service access token](invoking-sync.md#service-access-tokens) or the [user's Access Token](integration-egi.md#obtaining-an-access-token) if the OSCAR cluster is integrated with EGI Check-in. + !!swagger api.yaml!! diff --git a/docs/api.yaml b/docs/api.yaml index 90d5be14..be36ec1a 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -33,6 +33,7 @@ paths: description: List all created services security: - basicAuth: [] + - token: [] tags: - services post: @@ -50,6 +51,7 @@ paths: description: Create a service security: - basicAuth: [] + - token: [] requestBody: content: application/json: @@ -74,6 +76,7 @@ paths: description: Update a service security: - basicAuth: [] + - token: [] requestBody: content: application/json: @@ -108,6 +111,7 @@ paths: operationId: ReadService security: - basicAuth: [] + - token: [] description: Read a service delete: summary: Delete service @@ -124,6 +128,7 @@ paths: description: Delete a service security: - basicAuth: [] + - token: [] tags: - services '/system/logs/{serviceName}': @@ -157,6 +162,7 @@ paths: operationId: ListJobs security: - basicAuth: [] + - token: [] description: List all jobs with their status delete: summary: Delete jobs @@ -173,6 +179,7 @@ paths: description: Delete all jobs from a service. security: - basicAuth: [] + - token: [] parameters: - schema: type: boolean @@ -214,6 +221,7 @@ paths: description: Get the logs from a job security: - basicAuth: [] + - token: [] parameters: - schema: type: boolean @@ -234,6 +242,7 @@ paths: description: Delete a job security: - basicAuth: [] + - token: [] tags: - logs /system/info: @@ -256,6 +265,7 @@ paths: description: Get system info security: - basicAuth: [] + - token: [] /health: get: summary: Health @@ -316,6 +326,7 @@ paths: description: Get system configuration security: - basicAuth: [] + - token: [] '/run/{serviceName}': parameters: - schema: @@ -607,4 +618,6 @@ servers: - url: 'https://localhost' description: 'Local testing' - url: 'https://inference.cloud.ai4eosc.eu' - description: 'AI4EOSC OSCAR cluster' \ No newline at end of file + description: 'AI4EOSC OSCAR cluster' + - url: 'https://inference-walton.cloud.imagine-ai.eu' + description: 'iMagine OSCAR cluster' \ No newline at end of file diff --git a/docs/images/oidc/egi-checkin-token-portal.png b/docs/images/oidc/egi-checkin-token-portal.png new file mode 100644 index 00000000..41e0d08b Binary files /dev/null and b/docs/images/oidc/egi-checkin-token-portal.png differ diff --git a/docs/integration-egi.md b/docs/integration-egi.md index 632897d0..4356f57f 100644 --- a/docs/integration-egi.md +++ b/docs/integration-egi.md @@ -67,7 +67,7 @@ grant access for all users from that VO. The static web interface of OSCAR has been integrated with EGI Check-in and published in [ui.oscar.grycap.net](https://ui.oscar.grycap.net) to facilitate -the authorization of users. To login through EGI Checkín using OIDC tokens, +the authorization of users. To login through EGI Check-In using OIDC tokens, users only have to put the endpoint of its OSCAR cluster and click on the "EGI CHECK-IN" button. @@ -87,3 +87,17 @@ create a new account configuration for the After that, clusters can be added with the command [`oscar-cli cluster add`](oscar-cli.md#add) specifying the oidc-agent account name with the `--oidc-account-name` flag. + +### Obtaining an Access Token + +Once logged in via EGI Check-In you can obtain an Access Token with one of this approaches: + +* From the command-line, using `oidc-agent` with the following command: + + ```sh + oidc-token + ``` + where `account-short-name` is the name of your account configuration. +* From the EGI Check-In Token Portal: [https://aai.egi.eu/token](https://aai.egi.eu/token) + +![egi-checkin-token-portal.png](images/oidc/egi-checkin-token-portal.png) diff --git a/docs/invoking-async.md b/docs/invoking-async.md index 60119924..b480cfb0 100644 --- a/docs/invoking-async.md +++ b/docs/invoking-async.md @@ -2,11 +2,11 @@ For event-driven file processing, OSCAR automatically manages the creation and [notification system](https://docs.min.io/minio/baremetal/monitoring/bucket-notifications/bucket-notifications.html#minio-bucket-notifications) -of MinIO buckets in order to allow the event-driven invocation of services -using asynchronous requests, generating a Kubernetes job for every file to be -processed. - +of MinIO buckets. This allow the event-driven invocation of services +using asynchronous requests for every file uploaded to the bucket, which generates a Kubernetes job for every file to be processed. ![oscar-async.png](images/oscar-async.png) +These jobs will be queued up in the Kubernetes scheduler and will be processed whenever there are resources available. If OSCAR cluster has been deployed as an elastic Kubernetes cluster (see [Deployment with IM](https://docs.oscar.grycap.net/deploy-im-dashboard/)), then new Virtual Machines will be provisioned (up to the maximum number of nodes defined) in the underlying Cloud platform and seamlessly integrated in the Kubernetes clusters to proceed with the execution of jobs. These nodes will be terminated as the worload is reduced. Notice that the output files can be stores in MinIO or in any other storage back-end supported by the [FaaS supervisor](oscar-service.md#faas-supervisor). +If you want to process a large number of data files, consider using [OSCAR Batch](https://github.com/grycap/oscar-batch), a tool designed to perform batch-based processing in OSCAR clusters. It includes a coordinator tool where the user provides a MinIO bucket containing files for processing. This service calculates the optimal number of parallel service invocations that can be accommodated within the cluster, according to its current status, and distributes the image processing workload accordingly among the service invocations. This is mainly intended to process large amounts of files, for example, historical data. diff --git a/docs/invoking-sync.md b/docs/invoking-sync.md index 56fcc000..9dd75329 100644 --- a/docs/invoking-sync.md +++ b/docs/invoking-sync.md @@ -83,8 +83,8 @@ base64 input.png | curl -X POST -H "Authorization: Bearer " \ ## Service access tokens -As detailed in the [API specification](api.md), invocation paths require the -service access token in the request header for authentication. Service access +As detailed in the [API specification](api.md), invocation paths require either the +service access token or the Access Token of the user when the cluster is integrated with EGI Check-in, in the request header for authentication (any of them is valid). Service access tokens are auto-generated in service creation and update, and MinIO eventing system is automatically configured to use them for event-driven file processing. Tokens can be obtained through the API, using the diff --git a/docs/invoking.md b/docs/invoking.md index 25a0664a..1c0c43b9 100644 --- a/docs/invoking.md +++ b/docs/invoking.md @@ -2,7 +2,16 @@ OSCAR services can be executed: - - [Synchronously](invoking-sync.md), so that the invocation to the service blocks the client until the response is obtained. Useful for short-lived service invocations. + - [Synchronously](invoking-sync.md), so that the invocation to the service blocks the client until the response is obtained. - [Asynchronously](invoking-async.md), typically in response to a file upload to MinIO or via the OSCAR API. - - As an [exposed service](exposed-services.md), where the application executed already provides its own API or user interface (e.g. a Jupyter Notebook) + - As an [exposed service](exposed-services.md), where the application executed already provides its own API or user interface (e.g. Jupyter Notebook) + + +After reading the different service execution types, take into account the following considerations to better decide the most appropriate execution type for your use case: + +* **Scalability**: Asynchronous invocations provide the best throughput when dealing with multiple concurrent data processing requests, since these are processed by independent jobs which are managed by the Kubernetes scheduler. A two-level elasticity approach is used (increase in the number of pods and increase in the number of Virtual Machines, if the OSCAR cluster was configured to be elastic). This is the recommended approach when each processing request exceeds the order of tens of seconds. + +* **Reduced Latency** Synchronous invocations are oriented for short-lived (< tens of seconds) bursty requests. A certain number of containers can be configured to be kept alive to avoid the performance penalty of spawning new ones while providing an upper bound limit (see [`min_scale` and `max_scale` in the FDL](fdl.md#synchronoussettings), at the expense of always consuming resources in the OSCAR cluster. If the processing file is in the order of several MBytes it may not fit in the payload of the HTTP request. + +* **Easy Access** For services that provide their own user interface or their own API, exposed services provide the ability to execute them in OSCAR and benefit for an auto-scaled configuration in case they are [stateless](https://en.wikipedia.org/wiki/Service_statelessness_principle). This way, users can directly access the service using its well-known interfaces by the users. diff --git a/docs/minio_usage.md b/docs/minio-usage.md similarity index 97% rename from docs/minio_usage.md rename to docs/minio-usage.md index a8d41b20..32fd22a6 100644 --- a/docs/minio_usage.md +++ b/docs/minio-usage.md @@ -1,4 +1,4 @@ -# Accessing and managing MinIO storage provider +# Using the MinIO Storage Provider Each OSCAR cluster includes a deployed MinIO instance, which is used to trigger service executions. When a service is configured to use MinIO as its storage provider, it monitors a specified input folder for new data. Whenever new data is added to this folder, it triggers the associated service to execute. @@ -30,7 +30,8 @@ MinIO buckets can also be managed through [oscar-cli command-line](https://githu - [put-file](https://docs.oscar.grycap.net/oscar-cli/#put-file): Upload a file on a service storage provider. An example of a put-file operation: - ``` sh + + ``` bash oscar-cli service put-file fish-detector.yaml minio .path/to/your/images ./fish-detector/input/ ``` @@ -38,11 +39,12 @@ MinIO buckets can also be managed through [oscar-cli command-line](https://githu - *Install the client*: Detailed instructions for installing the MinIO client (mc) are available in [the official documentation](https://min.io/docs/minio/linux/reference/minio-mc.html#install-mc). - *Configure the MinIO instance*: The client requires credentials to connect and interact with the MinIO instance. This configuration can be set with the following command: - ``` sh + ``` bash mc alias set myminio https://minio.gracious-varahamihira6.im.grycap.net YOUR-ACCESS-KEY YOUR-SECRET-KEY ``` Once the client is configured, users can perform various operations supported by the MinIO client. For a complete list of available commands and their usage, refer to the [MinIO client reference](https://min.io/docs/minio/linux/reference/minio-mc.html#command-quick-reference). The following example demonstrates a PUT operation, where a file is uploaded to a specific folder within a bucket. - ``` sh + + ```bash mc cp /path/to/your/images/*.jpg myminio/fish-detector/input/ ``` diff --git a/docs/oscar-service.md b/docs/oscar-service.md index 1dd0c584..afcee018 100644 --- a/docs/oscar-service.md +++ b/docs/oscar-service.md @@ -15,7 +15,7 @@ is in charge of: -### Input/Output +### FaaS Supervisor [FaaS Supervisor](https://github.com/grycap/faas-supervisor), the component in charge of managing the input and output of services, allows JSON or base64 @@ -37,6 +37,12 @@ The output of synchronous invocations will depend on the application itself: This way users can adapt OSCAR's services to their own needs. +The FaaS Supervisor supports the following storage back-ends: +* [MinIO](https://min.io) +* [Amazon S3](https://aws.amazon.com/s3/) +* Webdav (and, therefore, [dCache](https://dcache.org)) +* Onedata (and, therefore, [EGI DataHub](https://www.egi.eu/service/datahub/)) + ### Container images Container images on asynchronous services use the tag `imagePullPolicy: Always`, which means that Kubernetes will check for the image digest on the image registry and download it if it is not present. diff --git a/examples/plant-classification-theano/README.md b/examples/plant-classification-theano/README.md index 53dc5b5a..94248e20 100644 --- a/examples/plant-classification-theano/README.md +++ b/examples/plant-classification-theano/README.md @@ -63,5 +63,5 @@ To run this example you need: 1. Once the function is executed, the output is automatically copied to the output bucket in minio, in this case `plant-classifier-out`. You can - download the ouput from here for further processing. + download the output from here for further processing. ![minio-out.png](img/Minio-OUT.png) diff --git a/examples/plants-classification-tensorflow/script.sh b/examples/plants-classification-tensorflow/script.sh index ab76ecc8..26835bb6 100644 --- a/examples/plants-classification-tensorflow/script.sh +++ b/examples/plants-classification-tensorflow/script.sh @@ -1,6 +1,6 @@ #!/bin/bash -IMAGE_NAME=`basename "$INPUT_FILE_PATH"` +IMAGE_NAME=`basename "$INPUT_FILE_PATH" | cut -d. -f1` OUTPUT_FILE="$TMP_OUTPUT_DIR/output.json" deepaas-cli predict --files "$INPUT_FILE_PATH" 2>&1 | grep -Po '{.*}' > "$OUTPUT_FILE" diff --git a/examples/yolov8/README.md b/examples/yolov8/README.md new file mode 100644 index 00000000..a4645e5f --- /dev/null +++ b/examples/yolov8/README.md @@ -0,0 +1,24 @@ +# Object Detection with YOLOv8 + +Detect objects in images using the state-of-the-art YOLOv8 model. + +## About YOLO + +This node utilizes the YOLOv8 (You Only Look Once version 8) model to detect objects within images. YOLOv8 is a cutting-edge, real-time object detection system known for its speed and accuracy, capable of identifying thousands of object categories efficiently. + +## About YOLOV8 Service in OSCAR + +This service uses the pre-trained YOLOv8 model provided by DEEP-Hybrid-DataCloud for object detection. It is designed to handle synchronous invocations and real-time image processing with high scalability, managed automatically by an elastic Kubernetes cluster. + +In order to invoke the function, first you have to create a service, either by the OSCAR UI or by using the FDL within the following command. + + +``` sh +oscar-cli apply yolov8.yaml +``` + +Once the service is created you can make the invocation with the following +command, which will store the output on a minio bucket. + +``` sh +oscar-cli service put-file yolov8.yaml minio img/cat.jpg yolov8/input/cat.jpg \ No newline at end of file diff --git a/examples/yolov8/img/cat.jpg b/examples/yolov8/img/cat.jpg new file mode 100644 index 00000000..016cdd82 Binary files /dev/null and b/examples/yolov8/img/cat.jpg differ diff --git a/examples/yolov8/img/cat.jpg:Zone.Identifier b/examples/yolov8/img/cat.jpg:Zone.Identifier new file mode 100644 index 00000000..db4f2e84 --- /dev/null +++ b/examples/yolov8/img/cat.jpg:Zone.Identifier @@ -0,0 +1,4 @@ +[ZoneTransfer] +ZoneId=3 +ReferrerUrl=https://www.google.com/ +HostUrl=https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Cat_November_2010-1a.jpg/220px-Cat_November_2010-1a.jpg diff --git a/examples/yolov8/script.sh b/examples/yolov8/script.sh new file mode 100644 index 00000000..631e4552 --- /dev/null +++ b/examples/yolov8/script.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +IMAGE_NAME=`basename "$INPUT_FILE_PATH"` +OUTPUT_FILE="$TMP_OUTPUT_DIR/output.png" + +deepaas-cli --deepaas_method_output="$OUTPUT_FILE" predict --files "$INPUT_FILE_PATH" --accept image/png 2>&1 + +echo "Prediction was saved in: $OUTPUT_FILE" \ No newline at end of file diff --git a/examples/yolov8/yolov8.yaml b/examples/yolov8/yolov8.yaml new file mode 100644 index 00000000..0db5095c --- /dev/null +++ b/examples/yolov8/yolov8.yaml @@ -0,0 +1,17 @@ +functions: + oscar: + - oscar-cluster: + name: yolov8 + memory: 4Gi + cpu: '2.0' + image: ai4oshub/ai4os-yolov8-torch:latest + script: script.sh + vo: vo.imagine-ai.eu + allowed_users: [] + log_level: CRITICAL + input: + - storage_provider: minio.default + path: yolov8/input + output: + - storage_provider: minio.default + path: yolov8/output \ No newline at end of file diff --git a/go.mod b/go.mod index ddb974fc..508637a0 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( ) require ( + bou.ke/monkey v1.0.2 github.com/GehirnInc/crypt v0.0.0-20190301055215-6c0105aabd46 // indirect github.com/apache/yunikorn-scheduler-interface v1.2.0 // indirect github.com/blendle/zapdriver v1.3.1 // indirect diff --git a/go.sum b/go.sum index 3d436868..67ddba40 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +bou.ke/monkey v1.0.2 h1:kWcnsrCNUatbxncxR/ThdYqbytgOIArtYWqcQLQzKLI= +bou.ke/monkey v1.0.2/go.mod h1:OqickVX3tNx6t33n1xvtTtu85YN5s6cKwVug+oHMaIA= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= contrib.go.opencensus.io/exporter/ocagent v0.7.1-0.20200907061046-05415f1de66d h1:LblfooH1lKOpp1hIhukktmSAxFkqMPFk9KR6iZ0MJNI= @@ -149,8 +151,6 @@ github.com/grycap/cdmi-client-go v0.1.1/go.mod h1:ZqWeQS3YBJVXxg3HOIkAu1MLNJ4+7s github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= @@ -274,18 +274,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM= diff --git a/mkdocs.yml b/mkdocs.yml index 6a6457cd..18bb0df2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,7 +21,7 @@ nav: - oscar-cli.md - usage-ui.md - api.md - - minio-upload.md + - MinIO: minio-usage.md - Service Execution: - invoking.md @@ -64,4 +64,4 @@ plugins: - search - render_swagger -copyright: "© GRyCAP - I3M - Universitat Politècnica de València, Spain." \ No newline at end of file +copyright: "© GRyCAP - I3M - Universitat Politècnica de València, Spain." diff --git a/pkg/backends/fake.go b/pkg/backends/fake.go index d39b6bba..3c2ad304 100644 --- a/pkg/backends/fake.go +++ b/pkg/backends/fake.go @@ -31,7 +31,8 @@ var errFake = errors.New("fake error") // FakeBackend fake struct to mock the beahaviour of the ServerlessBackend interface type FakeBackend struct { - errors map[string][]error + errors map[string][]error + Service *types.Service // service to be returned by the ReadService function } // MakeFakeBackend returns the pointer of a new FakeBackend struct @@ -81,7 +82,12 @@ func (f *FakeBackend) CreateService(service types.Service) error { // ReadService returns a Service (fake) func (f *FakeBackend) ReadService(name string) (*types.Service, error) { - return &types.Service{Token: "11e387cf727630d899925d57fceb4578f478c44be6cde0ae3fe886d8be513acf"}, f.returnError(getCurrentFuncName()) + // default service returned by the function + service := &types.Service{Token: "11e387cf727630d899925d57fceb4578f478c44be6cde0ae3fe886d8be513acf"} + if f.Service != nil { + service = f.Service + } + return service, f.returnError(getCurrentFuncName()) } // UpdateService updates an existent service (fake) diff --git a/pkg/backends/openfaas.go b/pkg/backends/openfaas.go index d03db44c..5743e21a 100644 --- a/pkg/backends/openfaas.go +++ b/pkg/backends/openfaas.go @@ -43,7 +43,7 @@ var errOpenfaasOperator = errors.New("the OpenFaaS Operator is not creating the // OpenfaasBackend struct to represent an Openfaas client type OpenfaasBackend struct { kubeClientset kubernetes.Interface - ofClientset *ofclientset.Clientset + ofClientset ofclientset.Interface namespace string gatewayEndpoint string scaler *utils.OpenfaasScaler diff --git a/pkg/backends/openfaas_test.go b/pkg/backends/openfaas_test.go new file mode 100644 index 00000000..4228a6b6 --- /dev/null +++ b/pkg/backends/openfaas_test.go @@ -0,0 +1,326 @@ +package backends + +import ( + "testing" + "time" + + "github.com/grycap/oscar/v3/pkg/types" + ofv1 "github.com/openfaas/faas-netes/pkg/apis/openfaas/v1" + ofclientset "github.com/openfaas/faas-netes/pkg/client/clientset/versioned/fake" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" + k8stesting "k8s.io/client-go/testing" +) + +func TestMakeOpenfaasBackend(t *testing.T) { + kubeClientset := fake.NewSimpleClientset() + kubeConfig := &rest.Config{} + cfg := &types.Config{ + ServicesNamespace: "default", + OpenfaasNamespace: "openfaas", + OpenfaasPort: 8080, + } + + ofBackend := MakeOpenfaasBackend(kubeClientset, kubeConfig, cfg) + + if ofBackend.namespace != "default" { + t.Errorf("Expected namespace to be 'default', got '%s'", ofBackend.namespace) + } + if ofBackend.gatewayEndpoint != "gateway.openfaas:8080" { + t.Errorf("Expected gatewayEndpoint to be 'gateway.openfaas:8080', got '%s'", ofBackend.gatewayEndpoint) + } +} + +func TestGetInfo(t *testing.T) { + kubeClientset := fake.NewSimpleClientset() + cfg := &types.Config{ + ServicesNamespace: "default", + OpenfaasNamespace: "openfaas", + OpenfaasPort: 8080, + } + + ofClientset := ofclientset.NewSimpleClientset() + ofBackend := &OpenfaasBackend{ + kubeClientset: kubeClientset, + ofClientset: ofClientset, + namespace: cfg.ServicesNamespace, + config: cfg, + } + + info := ofBackend.GetInfo() + if info.Name != "OpenFaaS" { + t.Errorf("Expected Name to be 'OpenFaaS', got '%s'", info.Name) + } +} + +func TestCreateService(t *testing.T) { + ofClientset := ofclientset.NewSimpleClientset() + cfg := &types.Config{ + ServicesNamespace: "default", + OpenfaasNamespace: "openfaas", + OpenfaasPort: 8080, + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: cfg.ServicesNamespace, + }, + } + kubeClientset := fake.NewSimpleClientset(deployment) + + ofBackend := &OpenfaasBackend{ + kubeClientset: kubeClientset, + ofClientset: ofClientset, + namespace: cfg.ServicesNamespace, + config: cfg, + } + + service := types.Service{ + Name: "test-service", + Image: "test-image", + Labels: map[string]string{ + "test": "label", + }, + } + + // Create a fake watcher + fakeWatcher := watch.NewFake() + + // Set up a reactor to intercept the Watch action and return the fake watcher + kubeClientset.PrependWatchReactor("deployments", func(action k8stesting.Action) (handled bool, ret watch.Interface, err error) { + return true, fakeWatcher, nil + }) + + // Run watcher in a goroutine + go func() { + // Simulate the creation of the deployment by triggering an event on the fake watcher + time.Sleep(1 * time.Second) // Ensure the CreateService method is waiting on the watcher + fakeWatcher.Add(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: service.Name, + Namespace: cfg.ServicesNamespace, + }, + }) + + // Allow some time for the CreateService method to process the event + time.Sleep(1 * time.Second) + }() + + err := ofBackend.CreateService(service) + if err != nil { + t.Errorf("Expected no error, got '%v'", err) + } + + actions := ofClientset.Actions() + if len(actions) != 1 { + t.Errorf("Expected 1 action, got %d", len(actions)) + } + if actions[0].GetResource().Resource != "functions" || actions[0].GetVerb() != "create" { + t.Errorf("Expected action to be 'create functions', got '%s %s'", actions[0].GetVerb(), actions[0].GetResource().Resource) + } +} + +func TestReadService(t *testing.T) { + ofClientset := ofclientset.NewSimpleClientset() + cfg := &types.Config{ + ServicesNamespace: "default", + OpenfaasNamespace: "openfaas", + OpenfaasPort: 8080, + } + + cm := &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: cfg.ServicesNamespace, + }, + Data: map[string]string{ + types.FDLFileName: `{"name": "test-service"}`, + types.ScriptFileName: "script.sh", + }, + } + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: cfg.ServicesNamespace, + }, + } + kubeClientset := fake.NewSimpleClientset(cm, deployment) + + ofBackend := &OpenfaasBackend{ + kubeClientset: kubeClientset, + ofClientset: ofClientset, + namespace: cfg.ServicesNamespace, + config: cfg, + } + + service, err := ofBackend.ReadService("test-service") + if err != nil { + t.Errorf("Expected no error, got '%v'", err) + } + if service.Name != "test-service" { + t.Errorf("Expected service name to be 'test-service', got '%s'", service.Name) + } +} + +func TestDeleteService(t *testing.T) { + + kubeClientset := fake.NewSimpleClientset() + offunction := &ofv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "default", + }, + Spec: ofv1.FunctionSpec{ + Image: "test-image", + }, + } + ofClientset := ofclientset.NewSimpleClientset(offunction) + cfg := &types.Config{ + ServicesNamespace: "default", + OpenfaasNamespace: "openfaas", + OpenfaasPort: 8080, + } + + ofBackend := &OpenfaasBackend{ + kubeClientset: kubeClientset, + ofClientset: ofClientset, + namespace: cfg.ServicesNamespace, + config: cfg, + } + + service := types.Service{ + Name: "test-service", + Image: "test-image", + } + + // Delete the service + err := ofBackend.DeleteService(service) + if err != nil { + t.Errorf("Expected no error, got '%v'", err) + } + + actions := ofClientset.Actions() + if len(actions) != 1 { + t.Errorf("Expected 1 action, got %d", len(actions)) + } + if actions[0].GetResource().Resource != "functions" || actions[0].GetVerb() != "delete" { + t.Errorf("Expected action to be 'delete functions', got '%s %s'", actions[0].GetVerb(), actions[0].GetResource().Resource) + } +} + +func TestUpdateService(t *testing.T) { + ofClientset := ofclientset.NewSimpleClientset() + cfg := &types.Config{ + ServicesNamespace: "default", + OpenfaasNamespace: "openfaas", + OpenfaasPort: 8080, + } + + oldCm := &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: cfg.ServicesNamespace, + }, + Data: map[string]string{ + types.FDLFileName: `{"name": "test-service"}`, + types.ScriptFileName: "script.sh", + }, + } + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: cfg.ServicesNamespace, + }, + } + kubeClientset := fake.NewSimpleClientset(oldCm, deployment) + + ofBackend := &OpenfaasBackend{ + kubeClientset: kubeClientset, + ofClientset: ofClientset, + namespace: cfg.ServicesNamespace, + config: cfg, + } + + service := types.Service{ + Name: "test-service", + Image: "test-image", + Labels: map[string]string{ + "test": "label", + }, + } + + err := ofBackend.UpdateService(service) + if err != nil { + t.Errorf("Expected no error, got '%v'", err) + } + + actions := kubeClientset.Actions() + if len(actions) != 4 { + t.Errorf("Expected 4 actions, got %d", len(actions)) + } + if actions[0].GetResource().Resource != "configmaps" || actions[0].GetVerb() != "get" { + t.Errorf("Expected action to be 'get configmaps', got '%s %s'", actions[0].GetVerb(), actions[0].GetResource().Resource) + } + if actions[1].GetResource().Resource != "configmaps" || actions[1].GetVerb() != "update" { + t.Errorf("Expected action to be 'update configmaps', got '%s %s'", actions[2].GetVerb(), actions[2].GetResource().Resource) + } + if actions[2].GetResource().Resource != "deployments" || actions[2].GetVerb() != "get" { + t.Errorf("Expected action to be 'get deployments', got '%s %s'", actions[2].GetVerb(), actions[2].GetResource().Resource) + } + if actions[3].GetResource().Resource != "deployments" || actions[3].GetVerb() != "update" { + t.Errorf("Expected action to be 'update deployments', got '%s %s'", actions[3].GetVerb(), actions[3].GetResource().Resource) + } +} + +func TestListServices(t *testing.T) { + cfg := &types.Config{ + ServicesNamespace: "default", + OpenfaasNamespace: "openfaas", + OpenfaasPort: 8080, + } + + ofClientset := ofclientset.NewSimpleClientset() + + cml := &v1.ConfigMapList{ + Items: []v1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: cfg.ServicesNamespace, + Labels: map[string]string{ + "oscar_service": "true", + }, + }, + Data: map[string]string{ + types.FDLFileName: `{"name": "test-service"}`, + types.ScriptFileName: "script.sh", + }, + }, + }, + } + kubeClientset := fake.NewSimpleClientset(cml) + + ofBackend := &OpenfaasBackend{ + kubeClientset: kubeClientset, + ofClientset: ofClientset, + namespace: cfg.ServicesNamespace, + config: cfg, + } + + services, err := ofBackend.ListServices() + if err != nil { + t.Errorf("Expected no error, got '%v'", err) + } + if len(services) != 1 { + t.Errorf("Expected 1 service, got %d", len(services)) + } + if services[0].Name != "test-service" { + t.Errorf("Expected service name to be 'test-service', got '%s'", services[0].Name) + } +} diff --git a/pkg/backends/serverlessbackend_test.go b/pkg/backends/serverlessbackend_test.go new file mode 100644 index 00000000..01af08c1 --- /dev/null +++ b/pkg/backends/serverlessbackend_test.go @@ -0,0 +1,65 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package backends + +import ( + "fmt" + "testing" + + "github.com/grycap/oscar/v3/pkg/types" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" +) + +func TestMakeServerlessBackend(t *testing.T) { + kubeClientset := fake.NewSimpleClientset() + kubeConfig := &rest.Config{} + + tests := []struct { + name string + serverlessBackend string + expectedBackendType string + }{ + { + name: "OpenFaaS Backend", + serverlessBackend: "openfaas", + expectedBackendType: "*backends.OpenfaasBackend", + }, + { + name: "Knative Backend", + serverlessBackend: "knative", + expectedBackendType: "*backends.KnativeBackend", + }, + { + name: "Default Kube Backend", + serverlessBackend: "unknown", + expectedBackendType: "*backends.KubeBackend", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &types.Config{ + ServerlessBackend: tt.serverlessBackend, + } + backend := MakeServerlessBackend(kubeClientset, kubeConfig, cfg) + if backendType := fmt.Sprintf("%T", backend); backendType != tt.expectedBackendType { + t.Errorf("expected %s, got %s", tt.expectedBackendType, backendType) + } + }) + } +} diff --git a/pkg/handlers/config_test.go b/pkg/handlers/config_test.go new file mode 100644 index 00000000..7c74fcb5 --- /dev/null +++ b/pkg/handlers/config_test.go @@ -0,0 +1,165 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "bou.ke/monkey" + "github.com/gin-gonic/gin" + "github.com/grycap/oscar/v3/pkg/types" + "github.com/grycap/oscar/v3/pkg/utils/auth" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + testclient "k8s.io/client-go/kubernetes/fake" +) + +func createExpectedBody(access_key string, secret_key string, cfg *types.Config) map[string]interface{} { + return map[string]interface{}{ + "config": map[string]interface{}{ + "name": "", + "namespace": "", + "services_namespace": "", + "gpu_available": false, + "interLink_available": false, + "yunikorn_enable": false, + "oidc_groups": nil, + }, + "minio_provider": map[string]interface{}{ + "endpoint": cfg.MinIOProvider.Endpoint, + "verify": cfg.MinIOProvider.Verify, + "access_key": access_key, + "secret_key": secret_key, + "region": cfg.MinIOProvider.Region, + }, + } +} + +func TestMakeConfigHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &types.Config{ + // Initialize with necessary fields + MinIOProvider: &types.MinIOProvider{ + Endpoint: "http://minio.example.com", + Verify: true, + Region: "us-east-1", + AccessKey: "accessKey1", + SecretKey: "secretKey1", + }, + } + + t.Run("Without Authorization Header", func(t *testing.T) { + router := gin.New() + router.GET("/config", MakeConfigHandler(cfg)) + + req, _ := http.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status code 200, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "http://minio.example.com") { + t.Fatalf("Unexpected response body") + } + + }) + + K8sObjects := []runtime.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "somelonguserid", + Namespace: auth.ServicesNamespace, + }, + Data: map[string][]byte{ + "accessKey": []byte("accessKey"), + "secretKey": []byte("secretKey"), + }, + }, + } + + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + t.Run("With Bearer Authorization Header", func(t *testing.T) { + router := gin.New() + router.GET("/config", MakeConfigHandler(cfg)) + + req, _ := http.NewRequest("GET", "/config", nil) + req.Header.Set("Authorization", "Bearer some-token") + w := httptest.NewRecorder() + + // Mocking auth functions + monkey.Patch(auth.GetUIDFromContext, func(c *gin.Context) (string, error) { + return "somelonguserid@egi.eu", nil + }) + + monkey.Patch(auth.GetMultitenancyConfigFromContext, func(c *gin.Context) (*auth.MultitenancyConfig, error) { + return auth.NewMultitenancyConfig(kubeClientset, "somelonguserid@egi.eu"), nil + }) + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status code 200, got %d", w.Code) + } + + expected_body := createExpectedBody("accessKey", "secretKey", cfg) + + var responseBody map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &responseBody); err != nil { + t.Fatalf("Failed to parse response body: %v", err) + } + + if !reflect.DeepEqual(responseBody, expected_body) { + t.Fatalf("Unexpected response body: %s", w.Body.String()) + } + + defer monkey.Unpatch(auth.GetUIDFromContext) + defer monkey.Unpatch(auth.GetMultitenancyConfigFromContext) + }) + + t.Run("With Token Authorization Header", func(t *testing.T) { + router := gin.New() + router.GET("/config", MakeConfigHandler(cfg)) + + req, _ := http.NewRequest("GET", "/config", nil) + req.Header.Set("Authorization", "SomeToken") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status code 200, got %d", w.Code) + } + + expected_body := createExpectedBody("accessKey1", "secretKey1", cfg) + + var responseBody map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &responseBody); err != nil { + t.Fatalf("Failed to parse response body: %v", err) + } + + if !reflect.DeepEqual(responseBody, expected_body) { + t.Fatalf("Unexpected response body: %s", w.Body.String()) + } + }) +} diff --git a/pkg/handlers/create.go b/pkg/handlers/create.go index ca0f0291..5a5ad785 100644 --- a/pkg/handlers/create.go +++ b/pkg/handlers/create.go @@ -53,6 +53,7 @@ var isAdminUser = false func MakeCreateHandler(cfg *types.Config, back types.ServerlessBackend) gin.HandlerFunc { return func(c *gin.Context) { var service types.Service + isAdminUser = false authHeader := c.GetHeader("Authorization") if len(strings.Split(authHeader, "Bearer")) == 1 { isAdminUser = true @@ -122,7 +123,7 @@ func MakeCreateHandler(cfg *types.Config, back types.ServerlessBackend) gin.Hand if !ownerOnList { service.AllowedUsers = append(service.AllowedUsers, uid) } - // Check if the uid's from allowed_users have and asociated MinIO user + // Check if the uid's from allowed_users have and associated MinIO user // and create it if not uids := mc.CheckUsersInCache(service.AllowedUsers) if len(uids) > 0 { @@ -287,7 +288,7 @@ func createBuckets(service *types.Service, cfg *types.Config, minIOAdminClient * // Create group for the service and add users // Check if users in allowed_users have a MinIO associated user - // If new allowed users list is empty the service becames public + // If new allowed users list is empty the service becomes public if !isUpdate { if !isAdminUser { if len(allowed_users) == 0 { diff --git a/pkg/handlers/create_test.go b/pkg/handlers/create_test.go new file mode 100644 index 00000000..335ab2d3 --- /dev/null +++ b/pkg/handlers/create_test.go @@ -0,0 +1,119 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handlers + +import ( + "fmt" + "strings" + "testing" + + "net/http" + "net/http/httptest" + + "github.com/gin-gonic/gin" + "github.com/grycap/oscar/v3/pkg/backends" + "github.com/grycap/oscar/v3/pkg/types" + "github.com/grycap/oscar/v3/pkg/utils/auth" + testclient "k8s.io/client-go/kubernetes/fake" +) + +func TestMakeCreateHandler(t *testing.T) { + back := backends.MakeFakeBackend() + kubeClientset := testclient.NewSimpleClientset() + + // Create a fake MinIO server + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + + if hreq.URL.Path != "/test" && hreq.URL.Path != "/test/input/" && hreq.URL.Path != "/output" && !strings.HasPrefix(hreq.URL.Path, "/minio/admin/v3/") { + t.Errorf("Unexpected path in request, got: %s", hreq.URL.Path) + } + + if hreq.URL.Path == "/minio/admin/v3/info" { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"Mode": "local", "Region": "us-east-1"}`)) + } else { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"status": "success"}`)) + } + })) + + // and set the MinIO endpoint to the fake server + cfg := types.Config{ + MinIOProvider: &types.MinIOProvider{ + Endpoint: server.URL, + Region: "us-east-1", + AccessKey: "minioadmin", + SecretKey: "minioadmin", + Verify: false, + }, + } + r := gin.Default() + r.Use(func(c *gin.Context) { + c.Set("uidOrigin", "somelonguid@egi.eu") + c.Set("multitenancyConfig", auth.NewMultitenancyConfig(kubeClientset, "somelonguid@egi.eu")) + c.Next() + }) + r.POST("/system/services", MakeCreateHandler(&cfg, back)) + + w := httptest.NewRecorder() + body := strings.NewReader(` + { + "name": "cowsay", + "cluster_id": "oscar", + "memory": "1Gi", + "cpu": "1.0", + "log_level": "CRITICAL", + "image": "ghcr.io/grycap/cowsay", + "alpine": false, + "script": "test", + "input": [ + { + "storage_provider": "minio", + "path": "/test/input/" + } + ], + "output": [ + { + "storage_provider": "webdav.id", + "path": "/output" + } + ], + "storage_providers": { + "webdav": { + "id": { + "hostname": "` + server.URL + `", + "login": "user", + "password": "pass" + } + } + }, + "allowed_users": ["somelonguid@egi.eu", "somelonguid2@egi.eu"] + } + `) + + req, _ := http.NewRequest("POST", "/system/services", body) + req.Header.Add("Authorization", "Bearer token") + r.ServeHTTP(w, req) + + // Close the fake MinIO server + defer server.Close() + + if w.Code != http.StatusCreated { + fmt.Println(w.Body) + t.Errorf("expecting code %d, got %d", http.StatusCreated, w.Code) + } +} diff --git a/pkg/handlers/delete_test.go b/pkg/handlers/delete_test.go new file mode 100644 index 00000000..2f6bb0ca --- /dev/null +++ b/pkg/handlers/delete_test.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/grycap/oscar/v3/pkg/backends" + "github.com/grycap/oscar/v3/pkg/types" + k8serr "k8s.io/apimachinery/pkg/api/errors" +) + +func TestMakeDeleteHandler(t *testing.T) { + back := backends.MakeFakeBackend() + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + if hreq.URL.Path != "/input" && hreq.URL.Path != "/output" && !strings.HasPrefix(hreq.URL.Path, "/minio/admin/v3/") { + t.Errorf("Unexpected path in request, got: %s", hreq.URL.Path) + } + })) + + // and set the MinIO endpoint to the fake server + cfg := types.Config{ + MinIOProvider: &types.MinIOProvider{ + Endpoint: server.URL, + Region: "us-east-1", + AccessKey: "minioadmin", + SecretKey: "minioadmin", + Verify: false, + }, + } + + svc := &types.Service{ + Token: "11e387cf727630d899925d57fceb4578f478c44be6cde0ae3fe886d8be513acf", + Input: []types.StorageIOConfig{ + {Provider: "minio." + types.DefaultProvider, Path: "/input"}, + }, + Output: []types.StorageIOConfig{ + {Provider: "minio." + types.DefaultProvider, Path: "/output"}, + }, + StorageProviders: &types.StorageProviders{ + MinIO: map[string]*types.MinIOProvider{types.DefaultProvider: { + Region: "us-east-1", + Endpoint: server.URL, + AccessKey: "ak", + SecretKey: "sk"}}, + }} + back.Service = svc + + r := gin.Default() + r.DELETE("/system/services/:serviceName", MakeDeleteHandler(&cfg, back)) + + scenarios := []struct { + name string + returnError bool + errType string + }{ + {"valid", false, ""}, + {"Service Not Found test", true, "404"}, + {"Internal Server Error test", true, "500"}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + w := httptest.NewRecorder() + + if s.returnError { + switch s.errType { + case "404": + back.AddError("DeleteService", k8serr.NewGone("Not Found")) + case "500": + err := errors.New("Not found") + back.AddError("DeleteService", k8serr.NewInternalError(err)) + } + } + serviceName := "testName" + req, _ := http.NewRequest("DELETE", "/system/services/"+serviceName, nil) + + r.ServeHTTP(w, req) + + if s.returnError { + if s.errType == "404" && w.Code != http.StatusNotFound { + t.Errorf("expecting code %d, got %d", http.StatusNotFound, w.Code) + } + + if s.errType == "500" && w.Code != http.StatusInternalServerError { + t.Errorf("expecting code %d, got %d", http.StatusInternalServerError, w.Code) + } + } else { + if w.Code != http.StatusNoContent { + t.Errorf("expecting code %d, got %d", http.StatusNoContent, w.Code) + } + } + }) + } + + // Close the fake MinIO server + defer server.Close() +} diff --git a/pkg/handlers/health_test.go b/pkg/handlers/health_test.go new file mode 100644 index 00000000..ca405ad4 --- /dev/null +++ b/pkg/handlers/health_test.go @@ -0,0 +1,50 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestHealthHandler(t *testing.T) { + // Set up the Gin router + router := gin.Default() + router.GET("/health", HealthHandler) + + // Create a request to send to the above route + req, _ := http.NewRequest("GET", "/health", nil) + + // Create a response recorder to record the response + w := httptest.NewRecorder() + + // Perform the request + router.ServeHTTP(w, req) + + // Check the status code is what we expect + if w.Code != http.StatusOK { + t.Errorf("expected status OK, got %v", w.Code) + } + + // Check the response body is what we expect + if w.Body.String() != "Ok" { + t.Errorf("expected body 'Ok', got %v", w.Body.String()) + } +} diff --git a/pkg/handlers/job.go b/pkg/handlers/job.go index f74582b3..e1ebb847 100644 --- a/pkg/handlers/job.go +++ b/pkg/handlers/job.go @@ -62,8 +62,8 @@ const ( InterLinkTolerationOperator = "Exists" ) -// MakeJobHandler makes a han/home/slangarita/Escritorio/interlink-cluster/PodCern/PodCern.yamldler to manage async invocations -func MakeJobHandler(cfg *types.Config, kubeClientset *kubernetes.Clientset, back types.ServerlessBackend, rm resourcemanager.ResourceManager) gin.HandlerFunc { +// MakeJobHandler makes a handler to manage async invocations +func MakeJobHandler(cfg *types.Config, kubeClientset kubernetes.Interface, back types.ServerlessBackend, rm resourcemanager.ResourceManager) gin.HandlerFunc { return func(c *gin.Context) { service, err := back.ReadService(c.Param("serviceName")) if err != nil { diff --git a/pkg/handlers/job_test.go b/pkg/handlers/job_test.go new file mode 100644 index 00000000..6150617a --- /dev/null +++ b/pkg/handlers/job_test.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + testclient "k8s.io/client-go/kubernetes/fake" + + "github.com/gin-gonic/gin" + "github.com/grycap/oscar/v3/pkg/backends" + "github.com/grycap/oscar/v3/pkg/types" +) + +func TestMakeJobHandler(t *testing.T) { + back := backends.MakeFakeBackend() + cfg := types.Config{} + kubeClient := testclient.NewSimpleClientset() + + r := gin.Default() + r.POST("/job/:serviceName", MakeJobHandler(&cfg, kubeClient, back, nil)) + + w := httptest.NewRecorder() + body := strings.NewReader(`{"Records": [{"requestParameters": {"principalId": "uid", "sourceIPAddress": "ip"}}]}`) + serviceName := "testName" + req, _ := http.NewRequest("POST", "/job/services"+serviceName, body) + req.Header.Set("Authorization", "Bearer 11e387cf727630d899925d57fceb4578f478c44be6cde0ae3fe886d8be513acf") + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + fmt.Println(w.Body) + t.Errorf("expecting code %d, got %d", http.StatusCreated, w.Code) + } + + actions := kubeClient.Actions() + if len(actions) != 1 { + t.Errorf("Expected 1 action but got %d", len(actions)) + } + if actions[0].GetVerb() != "create" || actions[0].GetResource().Resource != "jobs" { + t.Errorf("Expected create job action but got %v", actions[0]) + } +} diff --git a/pkg/handlers/logs.go b/pkg/handlers/logs.go index 5767af31..cc809827 100644 --- a/pkg/handlers/logs.go +++ b/pkg/handlers/logs.go @@ -35,7 +35,7 @@ import ( // TODO Try using cookies to avoid excesive calls to the k8s API // // MakeJobsInfoHandler makes a handler for listing all existing jobs from a service and show their JobInfo -func MakeJobsInfoHandler(back types.ServerlessBackend, kubeClientset *kubernetes.Clientset, namespace string) gin.HandlerFunc { +func MakeJobsInfoHandler(back types.ServerlessBackend, kubeClientset kubernetes.Interface, namespace string) gin.HandlerFunc { return func(c *gin.Context) { jobsInfo := make(map[string]*types.JobInfo) // Get serviceName @@ -103,7 +103,7 @@ func MakeJobsInfoHandler(back types.ServerlessBackend, kubeClientset *kubernetes // MakeDeleteJobsHandler makes a handler for deleting all jobs created by the provided service. // If 'all' querystring is set to 'true' pending, running and failed jobs will also be deleted -func MakeDeleteJobsHandler(back types.ServerlessBackend, kubeClientset *kubernetes.Clientset, namespace string) gin.HandlerFunc { +func MakeDeleteJobsHandler(back types.ServerlessBackend, kubeClientset kubernetes.Interface, namespace string) gin.HandlerFunc { return func(c *gin.Context) { // Get serviceName and jobName serviceName := c.Param("serviceName") @@ -147,7 +147,7 @@ func MakeDeleteJobsHandler(back types.ServerlessBackend, kubeClientset *kubernet } // MakeGetLogsHandler makes a handler for getting logs from the 'oscar-container' inside the pod created by the specified job -func MakeGetLogsHandler(back types.ServerlessBackend, kubeClientset *kubernetes.Clientset, namespace string) gin.HandlerFunc { +func MakeGetLogsHandler(back types.ServerlessBackend, kubeClientset kubernetes.Interface, namespace string) gin.HandlerFunc { return func(c *gin.Context) { // Get serviceName and jobName serviceName := c.Param("serviceName") @@ -200,7 +200,7 @@ func MakeGetLogsHandler(back types.ServerlessBackend, kubeClientset *kubernetes. } // MakeDeleteJobHandler makes a handler for removing a job -func MakeDeleteJobHandler(back types.ServerlessBackend, kubeClientset *kubernetes.Clientset, namespace string) gin.HandlerFunc { +func MakeDeleteJobHandler(back types.ServerlessBackend, kubeClientset kubernetes.Interface, namespace string) gin.HandlerFunc { return func(c *gin.Context) { // Get serviceName and jobName serviceName := c.Param("serviceName") @@ -251,7 +251,7 @@ func isOIDCAuthorised(c *gin.Context, back types.ServerlessBackend, serviceName // If is oidc auth get service and check on allowed users authHeader := c.GetHeader("Authorization") if len(strings.Split(authHeader, "Bearer")) > 1 { - service, _ := back.ReadService(c.Param("serviceName")) + service, _ := back.ReadService(serviceName) uid, err := auth.GetUIDFromContext(c) if err != nil { c.String(http.StatusInternalServerError, fmt.Sprintln(err)) diff --git a/pkg/handlers/logs_test.go b/pkg/handlers/logs_test.go new file mode 100644 index 00000000..e500a29e --- /dev/null +++ b/pkg/handlers/logs_test.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/grycap/oscar/v3/pkg/backends" + "github.com/grycap/oscar/v3/pkg/types" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + testclient "k8s.io/client-go/kubernetes/fake" +) + +func TestMakeJobsInfoHandler(t *testing.T) { + back := backends.MakeFakeBackend() + now := time.Now() + + K8sObjects := []runtime.Object{ + &batchv1.Job{ + Status: batchv1.JobStatus{ + StartTime: &metav1.Time{Time: now}, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "job", + Namespace: "namespace", + Labels: map[string]string{ + types.ServiceLabel: "test", + }, + }, + }, + &corev1.PodList{ + Items: []corev1.Pod{ + { + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: types.ContainerName, + State: corev1.ContainerState{ + Running: &corev1.ContainerStateRunning{ + StartedAt: metav1.Time{Time: now}, + }, + }, + }, + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "pod", + Namespace: "namespace", + Labels: map[string]string{ + "oscar_service": "test", + "job-name": "job"}, + }, + }, + }, + }, + } + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + + r := gin.Default() + r.GET("/system/logs/:serviceName", MakeJobsInfoHandler(back, kubeClientset, "namespace")) + + w := httptest.NewRecorder() + serviceName := "test" + req, _ := http.NewRequest("GET", "/system/logs/"+serviceName, nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + fmt.Println(w.Body) + t.Errorf("expecting code %d, got %d", http.StatusOK, w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Errorf("response is not valid JSON: %v", err) + } + + expected := map[string]interface{}{ + "job": map[string]interface{}{ + "status": "Running", + "creation_time": now.UTC().Format(time.RFC3339), + "start_time": now.UTC().Format(time.RFC3339), + }, + } + + if !reflect.DeepEqual(response, expected) { + t.Errorf("expecting %v, got %v", expected, response) + } + + actions := kubeClientset.Actions() + if len(actions) != 2 { + t.Errorf("expecting 2 actions, got %d", len(actions)) + } + + if actions[0].GetVerb() != "list" || actions[0].GetResource().Resource != "jobs" { + t.Errorf("expecting list jobs, got %s %s", actions[0].GetVerb(), actions[0].GetResource().Resource) + } + if actions[1].GetVerb() != "list" || actions[1].GetResource().Resource != "pods" { + t.Errorf("expecting list pods, got %s %s", actions[1].GetVerb(), actions[1].GetResource().Resource) + } +} + +func TestMakeDeleteJobsHandler(t *testing.T) { + back := backends.MakeFakeBackend() + kubeClientset := testclient.NewSimpleClientset() + + r := gin.Default() + r.DELETE("/system/logs/:serviceName", MakeDeleteJobsHandler(back, kubeClientset, "namespace")) + + w := httptest.NewRecorder() + serviceName := "test" + req, _ := http.NewRequest("DELETE", "/system/logs/"+serviceName, nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + fmt.Println(w.Body) + t.Errorf("expecting code %d, got %d", http.StatusNoContent, w.Code) + } + + actions := kubeClientset.Actions() + if len(actions) != 1 { + t.Errorf("expecting 1 actions, got %d", len(actions)) + } + + if actions[0].GetVerb() != "delete-collection" || actions[0].GetResource().Resource != "jobs" { + t.Errorf("expecting list jobs, got %s %s", actions[0].GetVerb(), actions[0].GetResource().Resource) + } +} + +func TestMakeGetLogsHandler(t *testing.T) { + back := backends.MakeFakeBackend() + + K8sObjects := []runtime.Object{ + &corev1.PodList{ + Items: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod", + Namespace: "namespace", + Labels: map[string]string{ + "oscar_service": "test", + "job-name": "job"}, + }, + }, + }, + }, + } + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + + r := gin.Default() + r.GET("/system/logs/:serviceName/:jobName", MakeGetLogsHandler(back, kubeClientset, "namespace")) + + w := httptest.NewRecorder() + serviceName := "test" + jobName := "job" + req, _ := http.NewRequest("GET", "/system/logs/"+serviceName+"/"+jobName, nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + fmt.Println(w.Body) + t.Errorf("expecting code %d, got %d", http.StatusOK, w.Code) + } + if w.Body.String() != "fake logs" { + t.Errorf("expecting 'fake logs', got %s", w.Body.String()) + } + + actions := kubeClientset.Actions() + if len(actions) != 2 { + t.Errorf("expecting 2 actions, got %d", len(actions)) + } + + if actions[0].GetVerb() != "list" || actions[0].GetResource().Resource != "pods" { + t.Errorf("expecting list pods, got %s %s", actions[0].GetVerb(), actions[0].GetResource().Resource) + } + if actions[1].GetVerb() != "get" || actions[1].GetResource().Resource != "pods" { + t.Errorf("expecting get pods, got %s %s", actions[1].GetVerb(), actions[1].GetResource().Resource) + } +} +func TestMakeDeleteJobHandler(t *testing.T) { + back := backends.MakeFakeBackend() + + K8sObjects := []runtime.Object{ + &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job", + Namespace: "namespace", + Labels: map[string]string{ + types.ServiceLabel: "test", + }, + }, + }, + } + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + + r := gin.Default() + r.Use(func(c *gin.Context) { + c.Set("uidOrigin", "some-uid-value") + c.Next() + }) + r.DELETE("/system/logs/:serviceName/:jobName", MakeDeleteJobHandler(back, kubeClientset, "namespace")) + + w := httptest.NewRecorder() + serviceName := "test" + jobName := "job" + req, _ := http.NewRequest("DELETE", "/system/logs/"+serviceName+"/"+jobName, nil) + req.Header.Set("Authorization", "Bearer 11e387cf727630d899925d57fceb4578f478c44be6cde0ae3fe886d8be513acf") + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + fmt.Println(w.Body) + t.Errorf("expecting code %d, got %d", http.StatusNoContent, w.Code) + } + + actions := kubeClientset.Actions() + if len(actions) != 2 { + t.Errorf("expecting 2 actions, got %d", len(actions)) + } + + if actions[0].GetVerb() != "get" || actions[0].GetResource().Resource != "jobs" { + t.Errorf("expecting get jobs, got %s %s", actions[0].GetVerb(), actions[0].GetResource().Resource) + } + + if actions[1].GetVerb() != "delete" || actions[1].GetResource().Resource != "jobs" { + t.Errorf("expecting delete jobs, got %s %s", actions[1].GetVerb(), actions[1].GetResource().Resource) + } +} diff --git a/pkg/handlers/run_test.go b/pkg/handlers/run_test.go index e7c9fe3b..3d9d647d 100644 --- a/pkg/handlers/run_test.go +++ b/pkg/handlers/run_test.go @@ -76,7 +76,7 @@ func TestMakeRunHandler(t *testing.T) { err := errors.New("Not found") back.AddError("ReadService", k8serr.NewInternalError(err)) case "splitErr": - req.Header.Set("Authorization", "AbCdEf123456") + req.Header.Set("Authorization", "11e387cf727630d899925d57fceb4578f478c44be6cde0ae3fe886d8be513acf") case "diffErr": req.Header.Set("Authorization", "Bearer 11e387cf727630d899925d57fceb4578f478c44be6cde0ae3fe886d8be513dfg") } diff --git a/pkg/handlers/status.go b/pkg/handlers/status.go index 52061ecb..17fed02b 100644 --- a/pkg/handlers/status.go +++ b/pkg/handlers/status.go @@ -49,7 +49,7 @@ type NodeInfo struct { } // MakeStatusHandler Status handler for kubernetes deployment. -func MakeStatusHandler(kubeClientset *kubernetes.Clientset, metricsClientset *versioned.MetricsV1beta1Client) gin.HandlerFunc { +func MakeStatusHandler(kubeClientset kubernetes.Interface, metricsClientset versioned.MetricsV1beta1Interface) gin.HandlerFunc { return func(c *gin.Context) { // Get nodes list nodes, err := kubeClientset.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{}) diff --git a/pkg/handlers/status_test.go b/pkg/handlers/status_test.go new file mode 100644 index 00000000..d211583f --- /dev/null +++ b/pkg/handlers/status_test.go @@ -0,0 +1,132 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/gin-gonic/gin" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1" + metricsfake "k8s.io/metrics/pkg/client/clientset/versioned/fake" +) + +func TestMakeStatusHandler(t *testing.T) { + // Create a fake Kubernetes clientset + kubeClientset := fake.NewSimpleClientset( + &v1.NodeList{ + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node1"}, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + "cpu": *resource.NewMilliQuantity(2000, resource.DecimalSI), + "memory": *resource.NewQuantity(8*1024*1024*1024, resource.BinarySI), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node2"}, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + "cpu": *resource.NewMilliQuantity(4000, resource.DecimalSI), + "memory": *resource.NewQuantity(16*1024*1024*1024, resource.BinarySI), + }, + }, + }, + }, + }, + ) + + // Create a fake Metrics clientset + metricsClientset := metricsfake.NewSimpleClientset() + // Add NodeMetrics objects to the fake clientset's store + metricsClientset.Fake.PrependReactor("list", "nodes", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + return true, &metricsv1beta1api.NodeMetricsList{ + Items: []metricsv1beta1api.NodeMetrics{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node1"}, + Usage: v1.ResourceList{ + "cpu": *resource.NewMilliQuantity(1000, resource.DecimalSI), + "memory": *resource.NewQuantity(4*1024*1024*1024, resource.BinarySI), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node2"}, + Usage: v1.ResourceList{ + "cpu": *resource.NewMilliQuantity(2000, resource.DecimalSI), + "memory": *resource.NewQuantity(8*1024*1024*1024, resource.BinarySI), + }, + }, + }, + }, nil + }) + + // Create a new Gin router + router := gin.Default() + router.GET("/status", MakeStatusHandler(kubeClientset, metricsClientset.MetricsV1beta1())) + + // Create a new HTTP request + req, _ := http.NewRequest("GET", "/status", nil) + w := httptest.NewRecorder() + + // Perform the request + router.ServeHTTP(w, req) + + // Check the response + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, but got %d", http.StatusOK, w.Code) + } + + var jsonResponse map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &jsonResponse) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + expectedResponse := map[string]interface{}{ + "numberNodes": 1.0, + "cpuFreeTotal": 2000.0, + "cpuMaxFree": 2000.0, + "memoryFreeTotal": 16.0 * 1024 * 1024 * 1024, + "memoryMaxFree": 8.0 * 1024 * 1024 * 1024, + "detail": []interface{}{ + map[string]interface{}{ + "nodeName": "node2", + "cpuCapacity": "4000", + "cpuUsage": "2000", + "cpuPercentage": "50.00", + "memoryCapacity": "17179869184", + "memoryUsage": "8589934592", + "memoryPercentage": "50.00", + }, + }, + } + + if !reflect.DeepEqual(jsonResponse, expectedResponse) { + t.Errorf("Expected response %v, but got %v", expectedResponse, jsonResponse) + } +} diff --git a/pkg/handlers/update.go b/pkg/handlers/update.go index cd97f826..e7dea13e 100644 --- a/pkg/handlers/update.go +++ b/pkg/handlers/update.go @@ -76,7 +76,7 @@ func MakeUpdateHandler(cfg *types.Config, back types.ServerlessBackend) gin.Hand // Set the owner on the new service definition newService.Owner = oldService.Owner - // If the service has changed VO check permisions again + // If the service has changed VO check permission again if newService.VO != "" && newService.VO != oldService.VO { for _, vo := range cfg.OIDCGroups { if vo == newService.VO { diff --git a/pkg/handlers/update_test.go b/pkg/handlers/update_test.go new file mode 100644 index 00000000..636caa9a --- /dev/null +++ b/pkg/handlers/update_test.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/grycap/oscar/v3/pkg/backends" + "github.com/grycap/oscar/v3/pkg/types" +) + +func TestMakeUpdateHandler(t *testing.T) { + back := backends.MakeFakeBackend() + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + if hreq.URL.Path != "/input" && hreq.URL.Path != "/output" && !strings.HasPrefix(hreq.URL.Path, "/minio/admin/v3/") { + t.Errorf("Unexpected path in request, got: %s", hreq.URL.Path) + } + if hreq.URL.Path == "/minio/admin/v3/info" { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"Mode": "local", "Region": "us-east-1"}`)) + } else { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"status": "success"}`)) + } + })) + + svc := &types.Service{ + Token: "11e387cf727630d899925d57fceb4578f478c44be6cde0ae3fe886d8be513acf", + Input: []types.StorageIOConfig{ + {Provider: "minio." + types.DefaultProvider, Path: "/input"}, + }, + Output: []types.StorageIOConfig{ + {Provider: "minio." + types.DefaultProvider, Path: "/output"}, + }, + StorageProviders: &types.StorageProviders{ + MinIO: map[string]*types.MinIOProvider{types.DefaultProvider: { + Region: "us-east-1", + Endpoint: server.URL, + AccessKey: "ak", + SecretKey: "sk"}}, + }, + Owner: "somelonguid@egi.eu", + AllowedUsers: []string{"somelonguid1@egi.eu"}} + back.Service = svc + + // and set the MinIO endpoint to the fake server + cfg := types.Config{ + MinIOProvider: &types.MinIOProvider{ + Region: "us-east-1", + Endpoint: server.URL, + AccessKey: "ak", + SecretKey: "sk", + }, + } + + r := gin.Default() + r.Use(func(c *gin.Context) { + c.Set("uidOrigin", "somelonguid@egi.eu") + c.Next() + }) + r.PUT("/system/services", MakeUpdateHandler(&cfg, back)) + + w := httptest.NewRecorder() + body := strings.NewReader(` + { + "name": "cowsay", + "cluster_id": "oscar", + "memory": "1Gi", + "cpu": "1.0", + "log_level": "CRITICAL", + "image": "ghcr.io/grycap/cowsay", + "alpine": false, + "script": "test", + "input": [ + { + "storage_provider": "minio", + "path": "/input" + } + ], + "output": [ + { + "storage_provider": "webdav.id", + "path": "/output" + } + ], + "storage_providers": { + "webdav": { + "id": { + "hostname": "` + server.URL + `", + "login": "user", + "password": "pass" + } + } + }, + "allowed_users": ["user1", "user2"] + } + `) + req, _ := http.NewRequest("PUT", "/system/services", body) + req.Header.Set("Authorization", "Bearer token") + r.ServeHTTP(w, req) + + // Close the fake MinIO server + defer server.Close() + + if w.Code != http.StatusNoContent { + fmt.Println(w.Body) + t.Errorf("expecting code %d, got %d", http.StatusNoContent, w.Code) + } + +} diff --git a/pkg/imagepuller/daemonset_test.go b/pkg/imagepuller/daemonset_test.go new file mode 100644 index 00000000..1b29e709 --- /dev/null +++ b/pkg/imagepuller/daemonset_test.go @@ -0,0 +1,74 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package imagepuller + +import ( + "testing" + + "bou.ke/monkey" + "github.com/grycap/oscar/v3/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" +) + +func TestCreateDaemonset(t *testing.T) { + cfg := &types.Config{ + ServicesNamespace: "default", + } + service := types.Service{ + Name: "test-service", + Image: "test-image", + ImagePullSecrets: []string{"test-secret"}, + } + kubeClientset := fake.NewSimpleClientset() + + // Patch the watchPods function to return a mock result + monkey.Patch(watchPods, func(kubernetes.Interface, *types.Config) { + }) + + err := CreateDaemonset(cfg, service, kubeClientset) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + actions := kubeClientset.Actions() + if len(actions) != 2 { + t.Errorf("Expected 2 action but got %d", len(actions)) + } + if actions[0].GetVerb() != "list" || actions[0].GetResource().Resource != "nodes" { + t.Errorf("Expected create job action but got %v", actions[0]) + } + if actions[1].GetVerb() != "create" || actions[1].GetResource().Resource != "daemonsets" { + t.Errorf("Expected create job action but got %v", actions[1]) + } + + daemonset := getDaemonset(cfg, service) + + if daemonset.Name != "image-puller-test-service" { + t.Errorf("expected daemonset name to be 'image-puller-test-service', got %s", daemonset.Name) + } + + if daemonset.Namespace != cfg.ServicesNamespace { + t.Errorf("expected daemonset namespace to be '%s', got %s", cfg.ServicesNamespace, daemonset.Namespace) + } + + if daemonset.Spec.Template.Spec.Containers[0].Image != service.Image { + t.Errorf("expected container image to be '%s', got %s", service.Image, daemonset.Spec.Template.Spec.Containers[0].Image) + } + + defer monkey.Unpatch(watchPods) +} diff --git a/pkg/resourcemanager/delegate_test.go b/pkg/resourcemanager/delegate_test.go new file mode 100644 index 00000000..03ee2581 --- /dev/null +++ b/pkg/resourcemanager/delegate_test.go @@ -0,0 +1,204 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resourcemanager + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grycap/oscar/v3/pkg/types" +) + +func TestDelegateJob(t *testing.T) { + logger := log.New(bytes.NewBuffer([]byte{}), "", log.LstdFlags) + event := "test-event" + + // Mock server to simulate the cluster endpoint + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/" { + w.WriteHeader(http.StatusOK) + return + } + if r.Method == http.MethodPost && r.URL.Path == "/job/test-service" { + w.WriteHeader(http.StatusCreated) + return + } + if r.Method == http.MethodGet && r.URL.Path == "/system/services/test-service" { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(&types.Service{Token: "test-token"}) + return + } + if r.Method == http.MethodGet && r.URL.Path == "/system/status" { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(&GeneralInfo{ + CPUMaxFree: 1000, + CPUFreeTotal: 2000, + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + service := &types.Service{ + Name: "test-service", + ClusterID: "test-cluster", + CPU: "1", + Delegation: "static", + Replicas: []types.Replica{ + { + Type: "oscar", + ClusterID: "test-cluster", + ServiceName: "test-service", + Priority: 50, + Headers: map[string]string{"Content-Type": "application/json"}, + }, + }, + Clusters: map[string]types.Cluster{ + "test-cluster": { + Endpoint: server.URL, + AuthUser: "user", + AuthPassword: "password", + SSLVerify: false, + }, + }, + } + + t.Run("Replica type oscar", func(t *testing.T) { + err := DelegateJob(service, event, logger) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) + + t.Run("Replica type oscar with delegation random", func(t *testing.T) { + service.Delegation = "random" + err := DelegateJob(service, event, logger) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) + + t.Run("Replica type oscar with delegation load-based", func(t *testing.T) { + service.Delegation = "load-based" + err := DelegateJob(service, event, logger) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) + + t.Run("Replica type endpoint", func(t *testing.T) { + service.Replicas[0].Type = "endpoint" + service.Replicas[0].URL = server.URL + err := DelegateJob(service, event, logger) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) +} + +func TestWrapEvent(t *testing.T) { + providerID := "test-provider" + event := "test-event" + + expected := DelegatedEvent{ + StorageProviderID: providerID, + Event: event, + } + + result := WrapEvent(providerID, event) + + if result != expected { + t.Errorf("Expected %v, got %v", expected, result) + } +} + +func TestGetServiceToken(t *testing.T) { + replica := types.Replica{ + ServiceName: "test-service", + } + cluster := types.Cluster{ + Endpoint: "http://localhost:8080", + AuthUser: "user", + AuthPassword: "password", + SSLVerify: false, + } + + // Mock server to simulate the cluster endpoint + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/system/services/test-service" { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(&types.Service{Token: "test-token"}) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + // Update the cluster endpoint to the mock server URL + cluster.Endpoint = server.URL + + token, err := getServiceToken(replica, cluster) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + expectedToken := "test-token" + if token != expectedToken { + t.Errorf("Expected %v, got %v", expectedToken, token) + } +} + +func TestUpdateServiceToken(t *testing.T) { + replica := types.Replica{ + ServiceName: "test-service", + } + cluster := types.Cluster{ + Endpoint: "http://localhost:8080", + AuthUser: "user", + AuthPassword: "password", + SSLVerify: false, + } + + // Mock server to simulate the cluster endpoint + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/system/services/test-service" { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(&types.Service{Token: "test-token"}) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + // Update the cluster endpoint to the mock server URL + cluster.Endpoint = server.URL + + token, err := updateServiceToken(replica, cluster) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + expectedToken := "test-token" + if token != expectedToken { + t.Errorf("Expected %v, got %v", expectedToken, token) + } +} diff --git a/pkg/resourcemanager/rescheduler_test.go b/pkg/resourcemanager/rescheduler_test.go new file mode 100644 index 00000000..6f3939b7 --- /dev/null +++ b/pkg/resourcemanager/rescheduler_test.go @@ -0,0 +1,193 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resourcemanager + +import ( + "bytes" + "log" + "testing" + "time" + + "bou.ke/monkey" + "github.com/grycap/oscar/v3/pkg/backends" + "github.com/grycap/oscar/v3/pkg/types" + jobv1 "k8s.io/api/batch/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestGetReSchedulablePods(t *testing.T) { + // Define test namespace + namespace := "test-namespace" + + // Create test pods + pods := &v1.PodList{ + Items: []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: namespace, + Labels: map[string]string{ + types.ServiceLabel: "service1", + types.ReSchedulerLabelKey: "10", + }, + CreationTimestamp: metav1.Time{Time: time.Now().Add(-15 * time.Second)}, + }, + Status: v1.PodStatus{ + Phase: v1.PodPending, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod2", + Namespace: namespace, + Labels: map[string]string{ + types.ServiceLabel: "service2", + types.ReSchedulerLabelKey: "20", + }, + CreationTimestamp: metav1.Time{Time: time.Now().Add(-5 * time.Second)}, + }, + Status: v1.PodStatus{ + Phase: v1.PodPending, + }, + }, + }, + } + + // Create a fake Kubernetes client + kubeClientset := fake.NewSimpleClientset(pods) + + // Call the function to test + reSchedulablePods, err := getReSchedulablePods(kubeClientset, namespace) + if err != nil { + t.Fatalf("error getting reschedulable pods: %v", err) + } + + // Check the results + if len(reSchedulablePods) != 1 { + t.Errorf("expected 1 reschedulable pod, got %d", len(reSchedulablePods)) + } + + if reSchedulablePods[0].Name != "pod1" { + t.Errorf("expected pod1 to be reschedulable, got %s", reSchedulablePods[0].Name) + } +} + +func TestGetReScheduleInfos(t *testing.T) { + // Define test namespace + namespace := "test-namespace" + + // Create test pods + pods := []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: namespace, + Labels: map[string]string{ + types.ServiceLabel: "service1", + types.ReSchedulerLabelKey: "10", + }, + CreationTimestamp: metav1.Time{Time: time.Now().Add(-15 * time.Second)}, + }, + Status: v1.PodStatus{ + Phase: v1.PodPending, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod2", + Namespace: namespace, + Labels: map[string]string{ + types.ServiceLabel: "service2", + types.ReSchedulerLabelKey: "20", + }, + CreationTimestamp: metav1.Time{Time: time.Now().Add(-5 * time.Second)}, + }, + Status: v1.PodStatus{ + Phase: v1.PodPending, + }, + }, + } + + back := backends.MakeFakeBackend() + // Call the function to test + reScheduleInfos := getReScheduleInfos(pods, back) + if reScheduleInfos == nil { + t.Fatalf("error getting reschedule infos") + } + +} + +func TestStartReScheduler(t *testing.T) { + // Define test namespace + namespace := "test-namespace" + + // Create test pods + pods := &v1.PodList{ + Items: []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: namespace, + Labels: map[string]string{ + types.ServiceLabel: "service1", + types.ReSchedulerLabelKey: "10", + "job-name": "job1", + }, + CreationTimestamp: metav1.Time{Time: time.Now().Add(-15 * time.Second)}, + }, + Status: v1.PodStatus{ + Phase: v1.PodPending, + }, + }, + }, + } + jobs := &jobv1.JobList{ + Items: []jobv1.Job{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "job1", + Namespace: namespace, + }, + }, + }, + } + + // Create a fake Kubernetes client + kubeClientset := fake.NewSimpleClientset(pods, jobs) + back := backends.MakeFakeBackend() + cfg := &types.Config{ + ReSchedulerInterval: 5, + ServicesNamespace: namespace, + } + + // Mock the Delegate function using monkey patching + monkey.Patch(DelegateJob, func(service *types.Service, event string, logger *log.Logger) error { + return nil + }) + var buf bytes.Buffer + reSchedulerLogger = log.New(&buf, "[RE-SCHEDULER] ", log.Flags()) + // Call the function to test + go StartReScheduler(cfg, back, kubeClientset) + time.Sleep(2 * time.Second) + + defer monkey.Unpatch(DelegateJob) + if buf.String() != "" { + t.Fatalf("error starting rescheduler: %v", buf.String()) + } +} diff --git a/pkg/types/config.go b/pkg/types/config.go index b871ba86..6ad0a3a9 100644 --- a/pkg/types/config.go +++ b/pkg/types/config.go @@ -183,7 +183,7 @@ type Config struct { // OIDCGroups OpenID comma-separated group list to grant access in the cluster. // Groups defined in the "eduperson_entitlement" OIDC scope, // as described here: https://docs.egi.eu/providers/check-in/sp/#10-groups - OIDCGroups []string `json:"-"` + OIDCGroups []string `json:"oidc_groups"` // IngressHost string `json:"-"` diff --git a/pkg/types/expose.go b/pkg/types/expose.go index 0be38895..09b758b4 100644 --- a/pkg/types/expose.go +++ b/pkg/types/expose.go @@ -152,8 +152,8 @@ func UpdateExpose(service Service, kubeClientset kubernetes.Interface, cfg *Conf // TODO check and refactor // Main function that list all the kubernetes components -// This function is not used, in the future could be usefull -func ListExpose(service Service, kubeClientset kubernetes.Interface, cfg *Config) error { +// This function is not used, in the future could be useful +func ListExpose(kubeClientset kubernetes.Interface, cfg *Config) error { deploy, hpa, err := listDeployments(kubeClientset, cfg) services, err2 := listServices(kubeClientset, cfg) @@ -246,7 +246,7 @@ func getHortizontalAutoScaleSpec(service Service, cfg *Config) *autos.Horizontal func getPodTemplateSpec(service Service, cfg *Config) v1.PodTemplateSpec { podSpec, _ := service.ToPodSpec(cfg) - for i, _ := range podSpec.Containers { + for i := range podSpec.Containers { podSpec.Containers[i].Ports = []v1.ContainerPort{ { Name: podPortName, @@ -414,7 +414,7 @@ func deleteService(name string, kubeClientset kubernetes.Interface, cfg *Config) func createIngress(service Service, kubeClientset kubernetes.Interface, cfg *Config) error { // Create Secret - ingress := getIngressSpec(service, kubeClientset, cfg) + ingress := getIngressSpec(service, cfg) _, err := kubeClientset.NetworkingV1().Ingresses(cfg.ServicesNamespace).Create(context.TODO(), ingress, metav1.CreateOptions{}) if err != nil { return err @@ -432,7 +432,7 @@ func updateIngress(service Service, kubeClientset kubernetes.Interface, cfg *Con //if exist continue and need -> Update //if exist and not need -> delete //if not exist create - kube_ingress := getIngressSpec(service, kubeClientset, cfg) + kube_ingress := getIngressSpec(service, cfg) _, err := kubeClientset.NetworkingV1().Ingresses(cfg.ServicesNamespace).Update(context.TODO(), kube_ingress, metav1.UpdateOptions{}) if err != nil { return err @@ -455,7 +455,7 @@ func updateIngress(service Service, kubeClientset kubernetes.Interface, cfg *Con } // Return a kubernetes ingress component, ready to deploy or update -func getIngressSpec(service Service, kubeClientset kubernetes.Interface, cfg *Config) *net.Ingress { +func getIngressSpec(service Service, cfg *Config) *net.Ingress { name_ingress := getIngressName(service.Name) pathofapi := getAPIPath(service.Name) name_service := getServiceName(service.Name) @@ -554,7 +554,7 @@ func deleteIngress(name string, kubeClientset kubernetes.Interface, cfg *Config) // Secret func createSecret(service Service, kubeClientset kubernetes.Interface, cfg *Config) error { - secret := getSecretSpec(service, kubeClientset, cfg) + secret := getSecretSpec(service, cfg) _, err := kubeClientset.CoreV1().Secrets(cfg.ServicesNamespace).Create(context.TODO(), secret, metav1.CreateOptions{}) if err != nil { return err @@ -563,7 +563,7 @@ func createSecret(service Service, kubeClientset kubernetes.Interface, cfg *Conf } func updateSecret(service Service, kubeClientset kubernetes.Interface, cfg *Config) error { - secret := getSecretSpec(service, kubeClientset, cfg) + secret := getSecretSpec(service, cfg) _, err := kubeClientset.CoreV1().Secrets(cfg.ServicesNamespace).Update(context.TODO(), secret, metav1.UpdateOptions{}) if err != nil { return err @@ -579,12 +579,12 @@ func deleteSecret(name string, kubeClientset kubernetes.Interface, cfg *Config) } return nil } -func getSecretSpec(service Service, kubeClientset kubernetes.Interface, cfg *Config) *v1.Secret { +func getSecretSpec(service Service, cfg *Config) *v1.Secret { //setPassword hash := make(htpasswd.HashedPasswords) err := hash.SetPassword(service.Name, service.Token, htpasswd.HashAPR1) if err != nil { - ExposeLogger.Printf(err.Error()) + ExposeLogger.Print(err.Error()) } //Create Secret inmutable := false @@ -620,10 +620,7 @@ func existsSecret(serviceName string, kubeClientset kubernetes.Interface, cfg *C func existsIngress(serviceName string, namespace string, kubeClientset kubernetes.Interface) bool { _, err := kubeClientset.NetworkingV1().Ingresses(namespace).Get(context.TODO(), getIngressName(serviceName), metav1.GetOptions{}) - if err == nil { - return true - } - return false + return err == nil } /// These are auxiliary functions diff --git a/pkg/types/expose_test.go b/pkg/types/expose_test.go new file mode 100644 index 00000000..e0185f09 --- /dev/null +++ b/pkg/types/expose_test.go @@ -0,0 +1,231 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +import ( + "testing" + + appsv1 "k8s.io/api/apps/v1" + autoscalingv1 "k8s.io/api/autoscaling/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + testclient "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +type Action struct { + Verb string + Resource string +} + +func CompareActions(actions []k8stesting.Action, expected_actions []Action) bool { + if len(actions) != len(expected_actions) { + return false + } + + for i, action := range actions { + if action.GetVerb() != expected_actions[i].Verb || action.GetResource().Resource != expected_actions[i].Resource { + return false + } + } + return true +} + +func TestCreateExpose(t *testing.T) { + + kubeClientset := testclient.NewSimpleClientset() + + service := Service{ + Name: "test-service", + Expose: Expose{ + MinScale: 1, + MaxScale: 3, + CpuThreshold: 80, + SetAuth: true, + }, + } + cfg := &Config{ServicesNamespace: "namespace"} + + err := CreateExpose(service, kubeClientset, cfg) + + if err != nil { + t.Errorf("Error creating expose: %v", err) + } + + actions := kubeClientset.Actions() + expected_actions := []Action{ + {Verb: "create", Resource: "deployments"}, + {Verb: "create", Resource: "horizontalpodautoscalers"}, + {Verb: "create", Resource: "services"}, + {Verb: "create", Resource: "ingresses"}, + {Verb: "create", Resource: "secrets"}, + } + + if CompareActions(actions, expected_actions) == false { + t.Errorf("Expected %v actions but got %v", expected_actions, actions) + } +} + +func TestDeleteExpose(t *testing.T) { + + K8sObjects := []runtime.Object{ + &autoscalingv1.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-hpa", + Namespace: "namespace", + }, + }, + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-dlp", + Namespace: "namespace", + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-svc", + Namespace: "namespace", + }, + }, + } + + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + cfg := &Config{ServicesNamespace: "namespace"} + + err := DeleteExpose("service", kubeClientset, cfg) + + if err != nil { + t.Errorf("Error creating expose: %v", err) + } + + actions := kubeClientset.Actions() + + expected_actions := []Action{ + {Verb: "delete", Resource: "horizontalpodautoscalers"}, + {Verb: "delete", Resource: "deployments"}, + {Verb: "delete", Resource: "services"}, + {Verb: "get", Resource: "ingresses"}, + {Verb: "delete-collection", Resource: "pods"}, + } + + if CompareActions(actions, expected_actions) == false { + t.Errorf("Expected %v actions but got %v", expected_actions, actions) + } +} + +func TestUpdateExpose(t *testing.T) { + + K8sObjects := []runtime.Object{ + &autoscalingv1.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-hpa", + Namespace: "namespace", + }, + }, + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-dlp", + Namespace: "namespace", + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-svc", + Namespace: "namespace", + }, + }, + } + + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + cfg := &Config{ServicesNamespace: "namespace"} + + service := Service{ + Name: "service", + Expose: Expose{ + MinScale: 1, + MaxScale: 3, + CpuThreshold: 80, + SetAuth: true, + }, + } + + err := UpdateExpose(service, kubeClientset, cfg) + + if err != nil { + t.Errorf("Error creating expose: %v", err) + } + + actions := kubeClientset.Actions() + + expected_actions := []Action{ + {Verb: "get", Resource: "deployments"}, + {Verb: "update", Resource: "deployments"}, + {Verb: "get", Resource: "horizontalpodautoscalers"}, + {Verb: "update", Resource: "horizontalpodautoscalers"}, + {Verb: "update", Resource: "services"}, + {Verb: "get", Resource: "ingresses"}, + {Verb: "create", Resource: "ingresses"}, + {Verb: "create", Resource: "secrets"}, + } + + if CompareActions(actions, expected_actions) == false { + t.Errorf("Expected %v actions but got %v", expected_actions, actions) + } +} + +func TestServiceSpec(t *testing.T) { + + service := Service{ + Name: "test-service", + Expose: Expose{ + MinScale: 1, + MaxScale: 3, + CpuThreshold: 40, + APIPort: 8080, + SetAuth: true, + }, + } + cfg := &Config{Namespace: "namespace"} + res := getServiceSpec(service, cfg) + if res.Spec.Ports[0].TargetPort.IntVal != 8080 { + t.Errorf("Expected port 8080 but got %d", res.Spec.Ports[0].Port) + } +} + +func TestHortizontalAutoScaleSpec(t *testing.T) { + + service := Service{ + Name: "test-service", + Expose: Expose{ + MinScale: 1, + MaxScale: 3, + CpuThreshold: 40, + }, + } + cfg := &Config{Namespace: "namespace"} + res := getHortizontalAutoScaleSpec(service, cfg) + if *res.Spec.MinReplicas != 1 { + t.Errorf("Expected min replicas 1 but got %d", res.Spec.MinReplicas) + } + if res.Spec.MaxReplicas != 3 { + t.Errorf("Expected max replicas 3 but got %d", res.Spec.MaxReplicas) + } + if *res.Spec.TargetCPUUtilizationPercentage != 40 { + t.Errorf("Expected target cpu 40 but got %d", res.Spec.TargetCPUUtilizationPercentage) + } +} diff --git a/pkg/types/interlink_test.go b/pkg/types/interlink_test.go new file mode 100644 index 00000000..3b15f8ba --- /dev/null +++ b/pkg/types/interlink_test.go @@ -0,0 +1,120 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package types + +import ( + "encoding/base64" + "testing" + + v1 "k8s.io/api/core/v1" +) + +func TestSetInterlinkJob(t *testing.T) { + podSpec := &v1.PodSpec{} + service := &Service{InterLinkNodeName: "test-node"} + cfg := &Config{SupervisorKitImage: "test-image"} + eventBytes := []byte("test-event") + + command, event, args := SetInterlinkJob(podSpec, service, cfg, eventBytes) + + if len(command) != 2 || command[0] != "/bin/sh" || command[1] != "-c" { + t.Errorf("Unexpected command: %v", command) + } + + expectedEventValue := base64.StdEncoding.EncodeToString(eventBytes) + if event.Name != EventVariable || event.Value != expectedEventValue { + t.Errorf("Unexpected event: %v", event) + } + + expectedArgs := "echo $EVENT | base64 -d | " + SupervisorMountPath + "/supervisor" + if len(args) != 1 || args[0] != expectedArgs { + t.Errorf("Unexpected args: %v", args) + } + + if podSpec.NodeSelector[NodeSelectorKey] != service.InterLinkNodeName { + t.Errorf("Unexpected NodeSelector: %v", podSpec.NodeSelector) + } + + if podSpec.DNSPolicy != InterLinkDNSPolicy { + t.Errorf("Unexpected DNSPolicy: %v", podSpec.DNSPolicy) + } + + if podSpec.RestartPolicy != InterLinkRestartPolicy { + t.Errorf("Unexpected RestartPolicy: %v", podSpec.RestartPolicy) + } + + if len(podSpec.Tolerations) != 1 || podSpec.Tolerations[0].Key != InterLinkTolerationKey || podSpec.Tolerations[0].Operator != InterLinkTolerationOperator { + t.Errorf("Unexpected Tolerations: %v", podSpec.Tolerations) + } +} + +func TestSetInterlinkService(t *testing.T) { + podSpec := &v1.PodSpec{ + Containers: []v1.Container{ + {}, + }, + } + + SetInterlinkService(podSpec) + + if podSpec.Containers[0].ImagePullPolicy != "Always" { + t.Errorf("Unexpected ImagePullPolicy: %v", podSpec.Containers[0].ImagePullPolicy) + } + + if len(podSpec.Containers[0].VolumeMounts) != 1 || podSpec.Containers[0].VolumeMounts[0].Name != NameSupervisorVolume || podSpec.Containers[0].VolumeMounts[0].MountPath != SupervisorMountPath { + t.Errorf("Unexpected VolumeMounts: %v", podSpec.Containers[0].VolumeMounts) + } + + if len(podSpec.Volumes) != 1 || podSpec.Volumes[0].Name != NameSupervisorVolume || podSpec.Volumes[0].VolumeSource.EmptyDir == nil { + t.Errorf("Unexpected Volumes: %v", podSpec.Volumes) + } +} + +func TestAddInitContainer(t *testing.T) { + podSpec := &v1.PodSpec{} + cfg := &Config{SupervisorKitImage: "test-image"} + + addInitContainer(podSpec, cfg) + + if len(podSpec.InitContainers) != 1 { + t.Fatalf("Expected 1 init container, got %d", len(podSpec.InitContainers)) + } + + initContainer := podSpec.InitContainers[0] + if initContainer.Name != ContainerSupervisorName { + t.Errorf("Unexpected init container name: %v", initContainer.Name) + } + + if len(initContainer.Command) != 2 || initContainer.Command[0] != "/bin/sh" || initContainer.Command[1] != "-c" { + t.Errorf("Unexpected init container command: %v", initContainer.Command) + } + + if len(initContainer.Args) != 1 || initContainer.Args[0] != SupervisorArg { + t.Errorf("Unexpected init container args: %v", initContainer.Args) + } + + if initContainer.Image != cfg.SupervisorKitImage { + t.Errorf("Unexpected init container image: %v", initContainer.Image) + } + + if initContainer.ImagePullPolicy != v1.PullIfNotPresent { + t.Errorf("Unexpected init container image pull policy: %v", initContainer.ImagePullPolicy) + } + + if len(initContainer.VolumeMounts) != 1 || initContainer.VolumeMounts[0].Name != NameSupervisorVolume || initContainer.VolumeMounts[0].MountPath != SupervisorMountPath { + t.Errorf("Unexpected init container volume mounts: %v", initContainer.VolumeMounts) + } +} diff --git a/pkg/types/mount_test.go b/pkg/types/mount_test.go new file mode 100644 index 00000000..df7a3945 --- /dev/null +++ b/pkg/types/mount_test.go @@ -0,0 +1,157 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package types + +import ( + "testing" + + v1 "k8s.io/api/core/v1" +) + +func TestSetMount(t *testing.T) { + podSpec := &v1.PodSpec{} + service := Service{ + Mount: StorageIOConfig{ + Provider: "minio.provider", + Path: "test-bucket", + }, + StorageProviders: &StorageProviders{ + MinIO: map[string]*MinIOProvider{ + "provider": { + AccessKey: "test-access-key", + SecretKey: "test-secret-key", + Endpoint: "test-endpoint", + }, + }, + }, + } + cfg := &Config{} + + SetMount(podSpec, service, cfg) + + if len(podSpec.Containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(podSpec.Containers)) + } + + container := podSpec.Containers[0] + if container.Name != rcloneContainerName { + t.Errorf("expected container name %s, got %s", rcloneContainerName, container.Name) + } + + if container.Image != rcloneContainerImage { + t.Errorf("expected container image %s, got %s", rcloneContainerImage, container.Image) + } + + expectedEnvVars := map[string]string{ + "MNT_POINT": rcloneFolderMount, + "MINIO_BUCKET": "test-bucket", + "AWS_ACCESS_KEY_ID": "test-access-key", + "AWS_SECRET_ACCESS_KEY": "test-secret-key", + "MINIO_ENDPOINT": "test-endpoint", + } + + for _, envVar := range container.Env { + if expectedValue, ok := expectedEnvVars[envVar.Name]; ok { + if envVar.Value != expectedValue { + t.Errorf("expected env var %s to have value %s, got %s", envVar.Name, expectedValue, envVar.Value) + } + } else { + t.Errorf("unexpected env var %s", envVar.Name) + } + } + + if len(container.VolumeMounts) != 4 { + t.Fatalf("expected 4 volume mounts, got %d", len(container.VolumeMounts)) + } + + if len(podSpec.Volumes) != 2 { + t.Fatalf("expected 2 volumes, got %d", len(podSpec.Volumes)) + } +} + +func TestSetMinIOEnvVars(t *testing.T) { + service := Service{ + Mount: StorageIOConfig{ + Path: "test-bucket", + }, + StorageProviders: &StorageProviders{ + MinIO: map[string]*MinIOProvider{ + "provider": { + AccessKey: "test-access-key", + SecretKey: "test-secret-key", + Endpoint: "test-endpoint", + }, + }, + }, + } + providerId := "provider" + + envVars := setMinIOEnvVars(service, providerId) + + expectedEnvVars := map[string]string{ + "MINIO_BUCKET": "test-bucket", + "AWS_ACCESS_KEY_ID": "test-access-key", + "AWS_SECRET_ACCESS_KEY": "test-secret-key", + "MINIO_ENDPOINT": "test-endpoint", + } + + for _, envVar := range envVars { + if expectedValue, ok := expectedEnvVars[envVar.Name]; ok { + if envVar.Value != expectedValue { + t.Errorf("expected env var %s to have value %s, got %s", envVar.Name, expectedValue, envVar.Value) + } + } else { + t.Errorf("unexpected env var %s", envVar.Name) + } + } +} + +func TestSetWebDavEnvVars(t *testing.T) { + service := Service{ + Mount: StorageIOConfig{ + Path: "test-folder", + }, + StorageProviders: &StorageProviders{ + WebDav: map[string]*WebDavProvider{ + "provider": { + Login: "test-login", + Password: "test-password", + Hostname: "test-hostname", + }, + }, + }, + } + providerId := "provider" + + envVars := setWebDavEnvVars(service, providerId) + + expectedEnvVars := map[string]string{ + "WEBDAV_FOLDER": "test-folder", + "WEBDAV_LOGIN": "test-login", + "WEBDAV_PASSWORD": "test-password", + "WEBDAV_HOSTNAME": "https://test-hostname", + } + + for _, envVar := range envVars { + if expectedValue, ok := expectedEnvVars[envVar.Name]; ok { + if envVar.Value != expectedValue { + t.Errorf("expected env var %s to have value %s, got %s", envVar.Name, expectedValue, envVar.Value) + } + } else { + t.Errorf("unexpected env var %s", envVar.Name) + } + } +} diff --git a/pkg/types/replica_test.go b/pkg/types/replica_test.go new file mode 100644 index 00000000..efa5d896 --- /dev/null +++ b/pkg/types/replica_test.go @@ -0,0 +1,69 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +import ( + "sort" + "testing" +) + +func TestReplicaList_Len(t *testing.T) { + replicas := ReplicaList{ + {Type: "oscar", Priority: 1}, + {Type: "endpoint", Priority: 2}, + } + expected := 2 + if replicas.Len() != expected { + t.Errorf("expected %d, got %d", expected, len(replicas)) + } +} + +func TestReplicaList_Swap(t *testing.T) { + replicas := ReplicaList{ + {Type: "oscar", Priority: 1}, + {Type: "endpoint", Priority: 2}, + } + replicas.Swap(0, 1) + if replicas[0].Priority != 2 || replicas[1].Priority != 1 { + t.Errorf("Swap did not work as expected") + } +} + +func TestReplicaList_Less(t *testing.T) { + replicas := ReplicaList{ + {Type: "oscar", Priority: 1}, + {Type: "endpoint", Priority: 2}, + } + if !replicas.Less(0, 1) { + t.Errorf("expected replicas[0] to be less than replicas[1]") + } + if replicas.Less(1, 0) { + t.Errorf("expected replicas[1] to not be less than replicas[0]") + } +} + +func TestReplicaList_Sort(t *testing.T) { + replicas := ReplicaList{ + {Type: "endpoint", Priority: 2}, + {Type: "oscar", Priority: 1}, + {Type: "oscar", Priority: 0}, + } + sort.Sort(replicas) + if replicas[0].Priority != 0 || replicas[1].Priority != 1 || replicas[2].Priority != 2 { + t.Errorf("Sort did not work as expected") + } +} diff --git a/pkg/types/service.go b/pkg/types/service.go index 1ed5c5d3..20098ab3 100644 --- a/pkg/types/service.go +++ b/pkg/types/service.go @@ -108,6 +108,17 @@ const ( // YAMLMarshal package-level yaml marshal function var YAMLMarshal = yaml.Marshal +type Expose struct { + MinScale int32 `json:"min_scale" default:"1"` + MaxScale int32 `json:"max_scale" default:"10"` + APIPort int `json:"api_port,omitempty" ` + CpuThreshold int32 `json:"cpu_threshold" default:"80" ` + RewriteTarget bool `json:"rewrite_target" default:"false" ` + NodePort int32 `json:"nodePort" default:"0" ` + DefaultCommand bool `json:"default_command" ` + SetAuth bool `json:"set_auth" ` +} + // Service represents an OSCAR service following the SCAR Function Definition Language type Service struct { // Name the name of the service @@ -213,16 +224,7 @@ type Service struct { // Optional ImagePullSecrets []string `json:"image_pull_secrets,omitempty"` - Expose struct { - MinScale int32 `json:"min_scale" default:"1"` - MaxScale int32 `json:"max_scale" default:"10"` - APIPort int `json:"api_port,omitempty" ` - CpuThreshold int32 `json:"cpu_threshold" default:"80" ` - RewriteTarget bool `json:"rewrite_target" default:"false" ` - NodePort int32 `json:"nodePort" default:"0" ` - DefaultCommand bool `json:"default_command" ` - SetAuth bool `json:"set_auth" ` - } `json:"expose"` + Expose Expose `json:"expose"` // The user-defined environment variables assigned to the service // Optional @@ -258,7 +260,7 @@ type Service struct { InterLinkNodeName string `json:"interlink_node_name"` // List of EGI UID's identifying the users that will have visibility of the service and its MinIO storage provider - // Optional (If the list is empty we asume the visibility is public for all cluster users) + // Optional (If the list is empty we assume the visibility is public for all cluster users) AllowedUsers []string `json:"allowed_users"` // Configuration to create a storage provider as a volume inside the service container diff --git a/pkg/utils/auth/multitenancy.go b/pkg/utils/auth/multitenancy.go index 8e5a8118..a9f50d6e 100644 --- a/pkg/utils/auth/multitenancy.go +++ b/pkg/utils/auth/multitenancy.go @@ -32,12 +32,12 @@ const ServicesNamespace = "oscar-svc" const ServiceLabelLength = 8 type MultitenancyConfig struct { - kubeClientset *kubernetes.Clientset + kubeClientset kubernetes.Interface owner_uid string usersCache []string } -func NewMultitenancyConfig(kubeClientset *kubernetes.Clientset, uid string) *MultitenancyConfig { +func NewMultitenancyConfig(kubeClientset kubernetes.Interface, uid string) *MultitenancyConfig { return &MultitenancyConfig{ kubeClientset: kubeClientset, owner_uid: uid, diff --git a/pkg/utils/minio_test.go b/pkg/utils/minio_test.go new file mode 100644 index 00000000..8121c579 --- /dev/null +++ b/pkg/utils/minio_test.go @@ -0,0 +1,123 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/grycap/oscar/v3/pkg/types" +) + +func createMinIOConfig() (types.Config, *httptest.Server) { + // Create a fake MinIO server + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + if !strings.HasPrefix(hreq.URL.Path, "/minio/admin/v3/") { + rw.WriteHeader(http.StatusNotFound) + } + + if hreq.URL.Path == "/minio/admin/v3/info-canned-policy" { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"PolicyName": "testpolicy", "Policy": {"Version": "version","Statement": [{"Resource": ["res"]}]}}`)) + } else { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"Status": "success"}`)) + } + })) + + cfg := types.Config{ + MinIOProvider: &types.MinIOProvider{ + Endpoint: server.URL, + Region: "us-east-1", + AccessKey: "minioadmin", + SecretKey: "minioadmin", + Verify: false, + }, + Name: "test", + Namespace: "default", + ServicePort: 8080, + } + + return cfg, server +} + +func TestCreateMinIOUser(t *testing.T) { + // Create a fake MinIO server + cfg, server := createMinIOConfig() + + client, err := MakeMinIOAdminClient(&cfg) + + if err != nil { + t.Errorf("Error creating MinIO client: %v", err) + } + + err = client.CreateMinIOUser("testuser", "testpassword") + + if err != nil { + t.Errorf("Error creating MinIO user: %v", err) + } + + // Close the fake MinIO server + defer server.Close() +} + +func TestPublicToPrivateBucket(t *testing.T) { + // Create a fake MinIO server + cfg, server := createMinIOConfig() + + client, _ := MakeMinIOAdminClient(&cfg) + err := client.PublicToPrivateBucket("testbucket", []string{"testuser"}) + + if err != nil { + t.Errorf("Error creating MinIO user: %v", err) + } + + // Close the fake MinIO server + defer server.Close() +} + +func TestCreateServiceGroup(t *testing.T) { + // Create a fake MinIO server + cfg, server := createMinIOConfig() + + client, _ := MakeMinIOAdminClient(&cfg) + err := client.CreateServiceGroup("bucket") + + if err != nil { + t.Errorf("Error creating MinIO user: %v", err) + } + + // Close the fake MinIO server + defer server.Close() +} + +func TestPrivateToPublicBucket(t *testing.T) { + // Create a fake MinIO server + cfg, server := createMinIOConfig() + + client, _ := MakeMinIOAdminClient(&cfg) + err := client.PrivateToPublicBucket("testbucket") + + if err != nil { + t.Errorf("Error creating MinIO user: %v", err) + } + + // Close the fake MinIO server + defer server.Close() +} diff --git a/pkg/utils/of_scaler_test.go b/pkg/utils/of_scaler_test.go new file mode 100644 index 00000000..1a35370b --- /dev/null +++ b/pkg/utils/of_scaler_test.go @@ -0,0 +1,196 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package utils + +import ( + "bytes" + "log" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/grycap/oscar/v3/pkg/types" + "github.com/prometheus/client_golang/api" + v1 "github.com/prometheus/client_golang/api/prometheus/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestNewOFScaler(t *testing.T) { + kubeClientset := fake.NewSimpleClientset() + cfg := &types.Config{ + OpenfaasNamespace: "openfaas", + ServicesNamespace: "default", + OpenfaasPort: 8080, + OpenfaasBasicAuthSecret: "basic-auth", + OpenfaasPrometheusPort: 9090, + OpenfaasScalerInactivityDuration: "5m", + OpenfaasScalerInterval: "1m", + } + + scaler := NewOFScaler(kubeClientset, cfg) + + if scaler.openfaasNamespace != "openfaas" { + t.Errorf("Expected openfaasNamespace to be 'openfaas', got %s", scaler.openfaasNamespace) + } + if scaler.namespace != "default" { + t.Errorf("Expected namespace to be 'default', got %s", scaler.namespace) + } + if scaler.gatewayEndpoint != "http://gateway.openfaas:8080" { + t.Errorf("Expected gatewayEndpoint to be 'http://gateway.openfaas:8080', got %s", scaler.gatewayEndpoint) + } + if scaler.prometheusEndpoint != "http://prometheus.openfaas:9090" { + t.Errorf("Expected prometheusEndpoint to be 'http://prometheus.openfaas:9090', got %s", scaler.prometheusEndpoint) + } +} + +func TestGetScalableFunctions(t *testing.T) { + // Create a deployment with the label "com.openfaas.scale.zero" set to "true" + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-function", + Namespace: "default", + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "com.openfaas.scale.zero": "true", + }, + }, + }, + }, + Status: appsv1.DeploymentStatus{ + Replicas: 1, + }, + } + + kubeClientset := fake.NewSimpleClientset(deployment) + scaler := &OpenfaasScaler{ + kubeClientset: kubeClientset, + namespace: "default", + } + + functions, err := scaler.getScalableFunctions() + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if len(functions) != 1 { + t.Errorf("Expected 1 function, got %d", len(functions)) + } + if functions[0] != "test-function" { + t.Errorf("Expected function name to be 'test-function', got %s", functions[0]) + } +} + +func TestScaleToZero(t *testing.T) { + kubeClientset := fake.NewSimpleClientset() + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + })) + + scaler := &OpenfaasScaler{ + kubeClientset: kubeClientset, + gatewayEndpoint: server.URL, + } + + err := scaler.scaleToZero("test-function", "user", "pass", server.Client()) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } +} + +func TestIsIdle(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + if hreq.URL.Path == "/api/v1/query" { + rw.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[{"metric":{},"value":[1620810000,"0"]}]},"error":null}`)) + } + })) + + prometheusClient, _ := api.NewClient(api.Config{ + Address: server.URL, + }) + prometheusAPIClient := v1.NewAPI(prometheusClient) + + idle := isIdle("test-function", "default", "5m", prometheusAPIClient) + if !idle { + t.Errorf("Expected function to be idle") + } +} + +func TestStart(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-auth", + Namespace: "openfaas", + }, + Data: map[string][]byte{ + "basic-auth-user": []byte("user"), + "basic-auth-password": []byte("pass"), + }, + } + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-function", + Namespace: "default", + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "com.openfaas.scale.zero": "true", + }, + }, + }, + }, + Status: appsv1.DeploymentStatus{ + Replicas: 1, + }, + } + kubeClientset := fake.NewSimpleClientset(secret, deployment) + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + if hreq.URL.Path == "/api/v1/query" { + rw.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[{"metric":{},"value":[1620810000,"1"]}]},"error":null}`)) + } + })) + + cfg := &types.Config{ + OpenfaasNamespace: "openfaas", + ServicesNamespace: "default", + OpenfaasPort: 8080, + OpenfaasBasicAuthSecret: "basic-auth", + OpenfaasPrometheusPort: 9090, + OpenfaasScalerInactivityDuration: "5m", + OpenfaasScalerInterval: "0.5s", + } + + scaler := NewOFScaler(kubeClientset, cfg) + scaler.gatewayEndpoint = server.URL + scaler.prometheusEndpoint = server.URL + + var buf bytes.Buffer + scalerLogger = log.New(&buf, "[OF-SCALER] ", log.Flags()) + + go scaler.Start() + time.Sleep(1 * time.Second) + + if buf.String() != "" { + t.Errorf("Unexpected log output: %s", buf.String()) + } +} diff --git a/pkg/utils/token_test.go b/pkg/utils/token_test.go new file mode 100644 index 00000000..c2b25867 --- /dev/null +++ b/pkg/utils/token_test.go @@ -0,0 +1,49 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "encoding/hex" + "testing" +) + +// TestGenerateTokenLength checks if the generated token has the correct length +func TestGenerateTokenLength(t *testing.T) { + token := GenerateToken() + expectedLength := 64 // 32 bytes * 2 (hex encoding) + if len(token) != expectedLength { + t.Errorf("Expected token length of %d, but got %d", expectedLength, len(token)) + } +} + +// TestGenerateTokenUniqueness checks if multiple generated tokens are unique +func TestGenerateTokenUniqueness(t *testing.T) { + token1 := GenerateToken() + token2 := GenerateToken() + if token1 == token2 { + t.Error("Expected tokens to be unique, but they are the same") + } +} + +// TestGenerateTokenHexEncoding checks if the generated token is a valid hex string +func TestGenerateTokenHexEncoding(t *testing.T) { + token := GenerateToken() + _, err := hex.DecodeString(token) + if err != nil { + t.Errorf("Expected a valid hex string, but got an error: %v", err) + } +} diff --git a/pkg/utils/yunikorn.go b/pkg/utils/yunikorn.go index 38f3f6f0..fbf9fb07 100644 --- a/pkg/utils/yunikorn.go +++ b/pkg/utils/yunikorn.go @@ -151,7 +151,7 @@ func DeleteYunikornQueue(cfg *types.Config, kubeClientset kubernetes.Interface, // getOscarQueue returns a pointer to the OSCAR's Yunikorn queue (configs.QueueConfig) // If the Queue doesn't exists, create a new one in the SchedulerConfig -// (the existance of the default partition and the root queue is assumed) +// (the existence of the default partition and the root queue is assumed) func getOscarQueue(schedulerConfig *configs.SchedulerConfig) *configs.QueueConfig { // First get a pointer to the root queue root := &configs.QueueConfig{} diff --git a/pkg/utils/yunikorn_test.go b/pkg/utils/yunikorn_test.go new file mode 100644 index 00000000..bef3181d --- /dev/null +++ b/pkg/utils/yunikorn_test.go @@ -0,0 +1,122 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package utils + +import ( + "testing" + + "github.com/apache/yunikorn-core/pkg/common/configs" + "github.com/grycap/oscar/v3/pkg/types" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func getFakeClientset() (*types.Config, *fake.Clientset) { + cfg := &types.Config{ + YunikornNamespace: "default", + YunikornConfigMap: "yunikorn-config", + YunikornConfigFileName: "yunikorn.yaml", + } + + cfgmap := &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: cfg.YunikornConfigMap, + Namespace: cfg.YunikornNamespace, + }, + Data: map[string]string{ + cfg.YunikornConfigFileName: ` +partitions: + - name: default + queues: + - name: root + queues: + - name: oscar + queues: + - name: test-service +`, + }, + } + return cfg, fake.NewSimpleClientset(cfgmap) +} + +func TestReadYunikornConfig(t *testing.T) { + cfg, clientset := getFakeClientset() + + schedulerConfig, err := readYunikornConfig(cfg, clientset) + if err != nil { + t.Errorf("Error Reading Yunikorn config: %v", err) + } + + if schedulerConfig.Partitions[0].Name != "default" { + t.Errorf("Error Reading Yunikorn config. SchedulerConfig is nil") + } +} + +func TestUpdateYunikornConfig(t *testing.T) { + cfg, clientset := getFakeClientset() + + schedulerConfig := &configs.SchedulerConfig{ + Partitions: []configs.PartitionConfig{ + { + Name: "default", + Queues: []configs.QueueConfig{ + { + Name: "root", + Queues: []configs.QueueConfig{ + { + Name: "oscar", + }, + }, + }, + }, + }, + }, + } + + err := updateYunikornConfig(cfg, clientset, schedulerConfig) + if err != nil { + t.Errorf("Error Updating Yunikorn config: %v", err) + } +} + +func TestAddYunikornQueue(t *testing.T) { + cfg, clientset := getFakeClientset() + + svc := &types.Service{ + Name: "test-service", + TotalMemory: "4Gi", + TotalCPU: "2", + } + + err := AddYunikornQueue(cfg, clientset, svc) + if err != nil { + t.Errorf("Error Adding Yunikorn config: %v", err) + } +} + +func TestDeleteYunikornQueue(t *testing.T) { + cfg, clientset := getFakeClientset() + + svc := &types.Service{ + Name: "test-service", + } + + err := DeleteYunikornQueue(cfg, clientset, svc) + if err != nil { + t.Errorf("Error Deleting Yunikorn config: %v", err) + } +}