Merge branch 'dev' into 'main'

1.3

See merge request oscar.krause/fastapi-dls!17
This commit is contained in:
Oscar Krause 2023-01-05 07:21:28 +01:00
commit 5fc8d4091b
14 changed files with 550 additions and 271 deletions

View File

@ -1 +1,2 @@
/etc/fastapi-dls/env /etc/fastapi-dls/env
/etc/systemd/system/fastapi-dls.service

27
.DEBIAN/env.default Normal file
View File

@ -0,0 +1,27 @@
# Toggle debug mode
#DEBUG=false
# Where the client can find the DLS server
DLS_URL=127.0.0.1
DLS_PORT=443
# CORS configuration
## comma separated list without spaces
#CORS_ORIGINS="https://$DLS_URL:$DLS_PORT"
# Lease expiration in days
LEASE_EXPIRE_DAYS=90
LEASE_RENEWAL_PERIOD=0.2
# Database location
## https://docs.sqlalchemy.org/en/14/core/engines.html
DATABASE=sqlite:////etc/fastapi-dls/db.sqlite
# UUIDs for identifying the instance
#SITE_KEY_XID="00000000-0000-0000-0000-000000000000"
#INSTANCE_REF="10000000-0000-0000-0000-000000000001"
#ALLOTMENT_REF="20000000-0000-0000-0000-000000000001"
# Site-wide signing keys
INSTANCE_KEY_RSA=/etc/fastapi-dls/instance.private.pem
INSTANCE_KEY_PUB=/etc/fastapi-dls/instance.public.pem

View File

@ -0,0 +1,25 @@
[Unit]
Description=Service for fastapi-dls
Documentation=https://git.collinwebdesigns.de/oscar.krause/fastapi-dls
After=network.target
[Service]
User=www-data
Group=www-data
AmbientCapabilities=CAP_NET_BIND_SERVICE
WorkingDirectory=/usr/share/fastapi-dls/app
EnvironmentFile=/etc/fastapi-dls/env
ExecStart=uvicorn main:app \
--env-file /etc/fastapi-dls/env \
--host $DLS_URL --port $DLS_PORT \
--app-dir /usr/share/fastapi-dls/app \
--ssl-keyfile /etc/fastapi-dls/webserver.key \
--ssl-certfile /etc/fastapi-dls/webserver.crt \
--proxy-headers
Restart=always
KillSignal=SIGQUIT
Type=simple
NotifyAccess=all
[Install]
WantedBy=multi-user.target

View File

@ -3,89 +3,26 @@
WORKING_DIR=/usr/share/fastapi-dls WORKING_DIR=/usr/share/fastapi-dls
CONFIG_DIR=/etc/fastapi-dls CONFIG_DIR=/etc/fastapi-dls
echo "> Create config directory ..." if [[ ! -f $CONFIG_DIR/instance.private.pem ]]; then
mkdir -p $CONFIG_DIR echo "> Create dls-instance keypair ..."
openssl genrsa -out $CONFIG_DIR/instance.private.pem 2048
# normally we would define services in `conffiles` and as separate file, but we like to keep thinks simple. openssl rsa -in $CONFIG_DIR/instance.private.pem -outform PEM -pubout -out $CONFIG_DIR/instance.public.pem
echo "> Install service ..." else
cat <<EOF >/etc/systemd/system/fastapi-dls.service echo "> Create dls-instance keypair skipped! (exists)"
[Unit]
Description=Service for fastapi-dls
Documentation=https://git.collinwebdesigns.de/oscar.krause/fastapi-dls
After=network.target
[Service]
User=www-data
Group=www-data
AmbientCapabilities=CAP_NET_BIND_SERVICE
WorkingDirectory=$WORKING_DIR/app
EnvironmentFile=$CONFIG_DIR/env
ExecStart=uvicorn main:app \\
--env-file /etc/fastapi-dls/env \\
--host \$DLS_URL --port \$DLS_PORT \\
--app-dir $WORKING_DIR/app \\
--ssl-keyfile /etc/fastapi-dls/webserver.key \\
--ssl-certfile /etc/fastapi-dls/webserver.crt \\
--proxy-headers
Restart=always
KillSignal=SIGQUIT
Type=simple
NotifyAccess=all
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
# normally we would define configfiles in `conffiles` and as separate file, but we like to keep thinks simple.
if [[ ! -f $CONFIG_DIR/env ]]; then
echo "> Writing initial config ..."
touch $CONFIG_DIR/env
cat <<EOF >$CONFIG_DIR/env
# Toggle debug mode
#DEBUG=false
# Where the client can find the DLS server
DLS_URL=127.0.0.1
DLS_PORT=443
# CORS configuration
## comma separated list without spaces
#CORS_ORIGINS="https://$DLS_URL:$DLS_PORT"
# Lease expiration in days
LEASE_EXPIRE_DAYS=90
# Database location
## https://docs.sqlalchemy.org/en/14/core/engines.html
DATABASE=sqlite:///$CONFIG_DIR/db.sqlite
# UUIDs for identifying the instance
#SITE_KEY_XID="00000000-0000-0000-0000-000000000000"
#INSTANCE_REF="00000000-0000-0000-0000-000000000000"
# Site-wide signing keys
INSTANCE_KEY_RSA=$CONFIG_DIR/instance.private.pem
INSTANCE_KEY_PUB=$CONFIG_DIR/instance.public.pem
EOF
fi fi
echo "> Create dls-instance keypair ..."
openssl genrsa -out $CONFIG_DIR/instance.private.pem 2048
openssl rsa -in $CONFIG_DIR/instance.private.pem -outform PEM -pubout -out $CONFIG_DIR/instance.public.pem
while true; do while true; do
read -p "> Do you wish to create self-signed webserver certificate? [Y/n]" yn [[ -f $CONFIG_DIR/webserver.key ]] && default_answer="N" || default_answer="Y"
yn=${yn:-y} # ${parameter:-word} If parameter is unset or null, the expansion of word is substituted. Otherwise, the value of parameter is substituted. [[ $default_answer == "Y" ]] && V="Y/n" || V="y/N"
read -p "> Do you wish to create self-signed webserver certificate? [${V}]" yn
yn=${yn:-$default_answer} # ${parameter:-word} If parameter is unset or null, the expansion of word is substituted. Otherwise, the value of parameter is substituted.
case $yn in case $yn in
[Yy]*) [Yy]*)
echo "> Generating keypair ..."
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout $CONFIG_DIR/webserver.key -out $CONFIG_DIR/webserver.crt openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout $CONFIG_DIR/webserver.key -out $CONFIG_DIR/webserver.crt
break break
;; ;;
[Nn]*) break ;; [Nn]*) echo "> Generating keypair skipped! (exists)"; break ;;
*) echo "Please answer [y] or [n]." ;; *) echo "Please answer [y] or [n]." ;;
esac esac
done done
@ -115,7 +52,7 @@ cat <<EOF
# Service should be up and running. # # Service should be up and running. #
# Webservice is listen to https://localhost # # Webservice is listen to https://localhost #
# # # #
# Configuration is stored in ${CONFIG_DIR}/env # # Configuration is stored in /etc/fastapi-dls/env. #
# # # #
# # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

View File

@ -46,7 +46,10 @@ build:apt:
- cp README.md version.env build/usr/share/fastapi-dls - cp README.md version.env build/usr/share/fastapi-dls
# create conf file # create conf file
- mkdir -p build/etc/fastapi-dls - mkdir -p build/etc/fastapi-dls
- touch build/etc/fastapi-dls/env - cp .DEBIAN/env.default build/etc/fastapi-dls/env
# create service file
- mkdir -p build/etc/systemd/system
- cp .DEBIAN/fastapi-dls.service build/etc/systemd/system/fastapi-dls.service
# cd into "build/" # cd into "build/"
- cd build/ - cd build/
script: script:
@ -142,6 +145,7 @@ test:
--proxy-headers & --proxy-headers &
- FASTAPI_DLS_PID=$! - FASTAPI_DLS_PID=$!
- echo "Started service with pid $FASTAPI_DLS_PID" - echo "Started service with pid $FASTAPI_DLS_PID"
- cat /etc/fastapi-dls/env
# testing service # testing service
- if [ "`curl --insecure -s https://127.0.0.1/-/health | jq .status`" != "up" ]; then echo "Success"; else "Error"; fi - if [ "`curl --insecure -s https://127.0.0.1/-/health | jq .status`" != "up" ]; then echo "Success"; else "Error"; fi
# cleanup # cleanup
@ -267,20 +271,31 @@ deploy:pacman:
- 'echo "EXPORT_NAME: ${EXPORT_NAME}"' - 'echo "EXPORT_NAME: ${EXPORT_NAME}"'
- 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file ${EXPORT_NAME} "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${PACKAGE_NAME}/${PACKAGE_VERSION}/${EXPORT_NAME}"' - 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file ${EXPORT_NAME} "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${PACKAGE_NAME}/${PACKAGE_VERSION}/${EXPORT_NAME}"'
release: release:prepare:
image: registry.gitlab.com/gitlab-org/release-cli:latest stage: .pre
stage: .post
rules: rules:
- if: $CI_COMMIT_TAG - if: $CI_COMMIT_TAG
when: never when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
before_script: script:
- set -a # make variables from "source" command available to release-cli
- source version.env - source version.env
- echo &VERSION
artifacts:
reports:
dotenv: version.env
release:
image: registry.gitlab.com/gitlab-org/release-cli:latest
stage: .post
needs:
- job: release:prepare
artifacts: true
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script: script:
- echo "Running release-job for $VERSION" - echo "Running release-job for $VERSION"
after_script:
- set +a
release: release:
name: $CI_PROJECT_TITLE $version name: $CI_PROJECT_TITLE $version
description: Release of $CI_PROJECT_TITLE version $VERSION description: Release of $CI_PROJECT_TITLE version $VERSION

191
README.md
View File

@ -2,72 +2,13 @@
Minimal Delegated License Service (DLS). Minimal Delegated License Service (DLS).
Compatibility tested with official DLS 2.0.1.
This service can be used without internet connection. This service can be used without internet connection.
Only the clients need a connection to this service on configured port. Only the clients need a connection to this service on configured port.
[[_TOC_]] [[_TOC_]]
## ToDo's
- Support http mode for using external https proxy (disable uvicorn ssl for using behind proxy)
## Endpoints
### [`GET /`](/)
Redirect to `/-/readme`.
### [`GET /status`](/status) (deprecated: use `/-/health`)
Status endpoint, used for *healthcheck*. Shows also current version and commit hash.
### [`GET /-/health`](/-/health)
Status endpoint, used for *healthcheck*. Shows also current version and commit hash.
### [`GET /-/readme`](/-/readme)
HTML rendered README.md.
### [`GET /-/docs`](/-/docs), [`GET /-/redoc`](/-/redoc)
OpenAPI specifications rendered from `GET /-/openapi.json`.
### [`GET /-/manage`](/-/manage)
Shows a very basic UI to delete origins or leases.
### `GET /-/origins?leases=false`
List registered origins.
| Query Parameter | Default | Usage |
|-----------------|---------|--------------------------------------|
| `leases` | `false` | Include referenced leases per origin |
### `DELETE /-/origins`
Deletes all origins and their leases.
### `GET /-/leases?origin=false`
List current leases.
| Query Parameter | Default | Usage |
|-----------------|---------|-------------------------------------|
| `origin` | `false` | Include referenced origin per lease |
### `DELETE /-/lease/{lease_ref}`
Deletes an lease.
### `GET /client-token`
Generate client token, (see [installation](#installation)).
### Others
There are some more internal api endpoints for handling authentication and lease process.
# Setup (Service) # Setup (Service)
@ -93,6 +34,8 @@ openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout $WORKING_DIR/webse
**Start container** **Start container**
To test if everything is set up properly you can start container as following:
```shell ```shell
docker volume create dls-db docker volume create dls-db
docker run -e DLS_URL=`hostname -i` -e DLS_PORT=443 -p 443:443 -v $WORKING_DIR:/app/cert -v dls-db:/app/database collinwebdesigns/fastapi-dls:latest docker run -e DLS_URL=`hostname -i` -e DLS_PORT=443 -p 443:443 -v $WORKING_DIR:/app/cert -v dls-db:/app/database collinwebdesigns/fastapi-dls:latest
@ -100,11 +43,13 @@ docker run -e DLS_URL=`hostname -i` -e DLS_PORT=443 -p 443:443 -v $WORKING_DIR:/
**Docker-Compose / Deploy stack** **Docker-Compose / Deploy stack**
Goto [`docker-compose.yml`](docker-compose.yml) for more advanced example (with reverse proxy usage).
```yaml ```yaml
version: '3.9' version: '3.9'
x-dls-variables: &dls-variables x-dls-variables: &dls-variables
DLS_URL: localhost # REQUIRED DLS_URL: localhost # REQUIRED, change to your ip or hostname
DLS_PORT: 443 DLS_PORT: 443
LEASE_EXPIRE_DAYS: 90 LEASE_EXPIRE_DAYS: 90
DATABASE: sqlite:////app/database/db.sqlite DATABASE: sqlite:////app/database/db.sqlite
@ -281,19 +226,28 @@ After first success you have to replace `--issue` with `--renew`.
# Configuration # Configuration
| Variable | Default | Usage | | Variable | Default | Usage |
|---------------------|----------------------------------------|-------------------------------------------------------------------------------------| |------------------------|----------------------------------------|------------------------------------------------------------------------------------------------------|
| `DEBUG` | `false` | Toggles `fastapi` debug mode | | `DEBUG` | `false` | Toggles `fastapi` debug mode |
| `DLS_URL` | `localhost` | Used in client-token to tell guest driver where dls instance is reachable | | `DLS_URL` | `localhost` | Used in client-token to tell guest driver where dls instance is reachable |
| `DLS_PORT` | `443` | Used in client-token to tell guest driver where dls instance is reachable | | `DLS_PORT` | `443` | Used in client-token to tell guest driver where dls instance is reachable |
| `TOKEN_EXPIRE_DAYS` | `1` | Client auth-token validity (used for authenticate client against api, **not `.tok` file!**) |
| `LEASE_EXPIRE_DAYS` | `90` | Lease time in days | | `LEASE_EXPIRE_DAYS` | `90` | Lease time in days |
| `LEASE_RENEWAL_PERIOD` | `0.15` | The percentage of the lease period that must elapse before a licensed client can renew a license \*1 |
| `DATABASE` | `sqlite:///db.sqlite` | See [official SQLAlchemy docs](https://docs.sqlalchemy.org/en/14/core/engines.html) | | `DATABASE` | `sqlite:///db.sqlite` | See [official SQLAlchemy docs](https://docs.sqlalchemy.org/en/14/core/engines.html) |
| `CORS_ORIGINS` | `https://{DLS_URL}` | Sets `Access-Control-Allow-Origin` header (comma separated string) \* | | `CORS_ORIGINS` | `https://{DLS_URL}` | Sets `Access-Control-Allow-Origin` header (comma separated string) \*2 |
| `SITE_KEY_XID` | `00000000-0000-0000-0000-000000000000` | Site identification uuid | | `SITE_KEY_XID` | `00000000-0000-0000-0000-000000000000` | Site identification uuid |
| `INSTANCE_REF` | `00000000-0000-0000-0000-000000000000` | Instance identification uuid | | `INSTANCE_REF` | `10000000-0000-0000-0000-000000000001` | Instance identification uuid |
| `INSTANCE_KEY_RSA` | `<app-dir>/cert/instance.private.pem` | Site-wide private RSA key for singing JWTs | | `ALLOTMENT_REF` | `20000000-0000-0000-0000-000000000001` | Allotment identification uuid |
| `INSTANCE_KEY_PUB` | `<app-dir>/cert/instance.public.pem` | Site-wide public key | | `INSTANCE_KEY_RSA` | `<app-dir>/cert/instance.private.pem` | Site-wide private RSA key for singing JWTs \*3 |
| `INSTANCE_KEY_PUB` | `<app-dir>/cert/instance.public.pem` | Site-wide public key \*3 |
\* Always use `https`, since guest-drivers only support secure connections! \*1 For example, if the lease period is one day and the renewal period is 20%, the client attempts to renew its license
every 4.8 hours. If network connectivity is lost, the loss of connectivity is detected during license renewal and the
client has 19.2 hours in which to re-establish connectivity before its license expires.
\*2 Always use `https`, since guest-drivers only support secure connections!
\*3 If you recreate instance keys you need to **recreate client-token for each guest**!
# Setup (Client) # Setup (Client)
@ -308,7 +262,7 @@ Successfully tested with this package versions:
## Linux ## Linux
```shell ```shell
curl --insecure -X GET https://<dls-hostname-or-ip>/client-token -o /etc/nvidia/ClientConfigToken/client_configuration_token.tok curl --insecure -L -X GET https://<dls-hostname-or-ip>/client-token -o /etc/nvidia/ClientConfigToken/client_configuration_token_$(date '+%d-%m-%Y-%H-%M-%S').tok
service nvidia-gridd restart service nvidia-gridd restart
nvidia-smi -q | grep "License" nvidia-smi -q | grep "License"
``` ```
@ -321,13 +275,73 @@ Now restart `NvContainerLocalSystem` service.
**Power-Shell** **Power-Shell**
```Shell ```Shell
curl.exe --insecure -X GET https://<dls-hostname-or-ip>/client-token -o "C:\Program Files\NVIDIA Corporation\vGPU Licensing\ClientConfigToken\client_configuration_token_$($(Get-Date).tostring('dd-MM-yy-hh-mm-ss')).tok" curl.exe --insecure -L -X GET https://<dls-hostname-or-ip>/client-token -o "C:\Program Files\NVIDIA Corporation\vGPU Licensing\ClientConfigToken\client_configuration_token_$($(Get-Date).tostring('dd-MM-yy-hh-mm-ss')).tok"
Restart-Service NVDisplay.ContainerLocalSystem Restart-Service NVDisplay.ContainerLocalSystem
'C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe' -q | Select-String "License" 'C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe' -q | Select-String "License"
``` ```
## Endpoints
### `GET /`
Redirect to `/-/readme`.
### `GET /-/health`
Status endpoint, used for *healthcheck*.
### `GET /-/config`
Shows current runtime environment variables and their values.
### `GET /-/readme`
HTML rendered README.md.
### `GET /-/docs`, `GET /-/redoc`
OpenAPI specifications rendered from `GET /-/openapi.json`.
### `GET /-/manage`
Shows a very basic UI to delete origins or leases.
### `GET /-/origins?leases=false`
List registered origins.
| Query Parameter | Default | Usage |
|-----------------|---------|--------------------------------------|
| `leases` | `false` | Include referenced leases per origin |
### `DELETE /-/origins`
Deletes all origins and their leases.
### `GET /-/leases?origin=false`
List current leases.
| Query Parameter | Default | Usage |
|-----------------|---------|-------------------------------------|
| `origin` | `false` | Include referenced origin per lease |
### `DELETE /-/lease/{lease_ref}`
Deletes an lease.
### `GET /-/client-token`
Generate client token, (see [installation](#installation)).
### Others
There are many other internal api endpoints for handling authentication and lease process.
# Troubleshoot # Troubleshoot
**Please make sure that fastapi-dls and your guests are on the same timezone!**
## Linux ## Linux
Logs are available with `journalctl -u nvidia-gridd -f`. Logs are available with `journalctl -u nvidia-gridd -f`.
@ -346,6 +360,9 @@ This message can be ignored.
- Ref. https://github.com/encode/uvicorn/issues/441 - Ref. https://github.com/encode/uvicorn/issues/441
<details>
<summary>Log example</summary>
``` ```
WARNING:uvicorn.error:Invalid HTTP request received. WARNING:uvicorn.error:Invalid HTTP request received.
Traceback (most recent call last): Traceback (most recent call last):
@ -364,6 +381,8 @@ Traceback (most recent call last):
h11._util.RemoteProtocolError: no request line received h11._util.RemoteProtocolError: no request line received
``` ```
</details>
## Windows ## Windows
### Required cipher on Windows Guests (e.g. managed by domain controller with GPO) ### Required cipher on Windows Guests (e.g. managed by domain controller with GPO)
@ -431,6 +450,38 @@ Dec 20 17:53:34 ubuntu-grid-server nvidia-gridd[10354]: License acquired success
</details> </details>
### Error on releasing leases on shutdown (can be ignored and/or fixed with reverse proxy)
The driver wants to release current leases on shutting down windows. This endpoint needs to be a http endpoint.
The error message can safely be ignored (since we have no license limitation :P) and looks like this:
<details>
<summary>Log example</summary>
```
<1>:NLS initialized
<1>:License acquired successfully. (Info: 192.168.178.110, NVIDIA RTX Virtual Workstation; Expiry: 2023-3-30 23:0:22 GMT)
<0>:Failed to return license to 192.168.178.110 (Error: Generic network communication failure)
<0>:End Logging
```
#### log with nginx as reverse proxy (see [docker-compose.yml](docker-compose.yml))
```
<1>:NLS initialized
<2>:NLS initialized
<1>:Valid GRID license not found. GPU features and performance will be fully degraded. To enable full functionality please configure licensing details.
<1>:License acquired successfully. (Info: 192.168.178.33, NVIDIA RTX Virtual Workstation; Expiry: 2023-1-4 16:48:20 GMT)
<2>:Valid GRID license not found. GPU features and performance will be fully degraded. To enable full functionality please configure licensing details.
<2>:License acquired successfully from local trusted store. (Info: 192.168.178.33, NVIDIA RTX Virtual Workstation; Expiry: 2023-1-4 16:48:20 GMT)
<2>:End Logging
<1>:End Logging
<0>:License returned successfully. (Info: 192.168.178.33)
<0>:End Logging
```
</details>
# Credits # Credits
Thanks to vGPU community and all who uses this project and report bugs. Thanks to vGPU community and all who uses this project and report bugs.

View File

@ -6,16 +6,16 @@ from os.path import join, dirname
from os import getenv as env from os import getenv as env
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException from fastapi import FastAPI
from fastapi.requests import Request from fastapi.requests import Request
import json from json import loads as json_loads
from datetime import datetime from datetime import datetime
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from calendar import timegm from calendar import timegm
from jose import jws, jwk, jwt from jose import jws, jwk, jwt, JWTError
from jose.constants import ALGORITHMS from jose.constants import ALGORITHMS
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.responses import StreamingResponse, JSONResponse, HTMLResponse, Response, RedirectResponse from starlette.responses import StreamingResponse, JSONResponse as JSONr, HTMLResponse as HTMLr, Response, RedirectResponse
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@ -32,14 +32,17 @@ app = FastAPI(title='FastAPI-DLS', description='Minimal Delegated License Servic
db = create_engine(str(env('DATABASE', 'sqlite:///db.sqlite'))) db = create_engine(str(env('DATABASE', 'sqlite:///db.sqlite')))
db_init(db), migrate(db) db_init(db), migrate(db)
# everything prefixed with "INSTANCE_*" is used as "SERVICE_INSTANCE_*" or "SI_*" in official dls service
DLS_URL = str(env('DLS_URL', 'localhost')) DLS_URL = str(env('DLS_URL', 'localhost'))
DLS_PORT = int(env('DLS_PORT', '443')) DLS_PORT = int(env('DLS_PORT', '443'))
SITE_KEY_XID = str(env('SITE_KEY_XID', '00000000-0000-0000-0000-000000000000')) SITE_KEY_XID = str(env('SITE_KEY_XID', '00000000-0000-0000-0000-000000000000'))
INSTANCE_REF = str(env('INSTANCE_REF', '00000000-0000-0000-0000-000000000000')) INSTANCE_REF = str(env('INSTANCE_REF', '10000000-0000-0000-0000-000000000001'))
ALLOTMENT_REF = str(env('ALLOTMENT_REF', '20000000-0000-0000-0000-000000000001'))
INSTANCE_KEY_RSA = load_key(str(env('INSTANCE_KEY_RSA', join(dirname(__file__), 'cert/instance.private.pem')))) INSTANCE_KEY_RSA = load_key(str(env('INSTANCE_KEY_RSA', join(dirname(__file__), 'cert/instance.private.pem'))))
INSTANCE_KEY_PUB = load_key(str(env('INSTANCE_KEY_PUB', join(dirname(__file__), 'cert/instance.public.pem')))) INSTANCE_KEY_PUB = load_key(str(env('INSTANCE_KEY_PUB', join(dirname(__file__), 'cert/instance.public.pem'))))
TOKEN_EXPIRE_DELTA = relativedelta(hours=1) # days=1 TOKEN_EXPIRE_DELTA = relativedelta(days=int(env('TOKEN_EXPIRE_DAYS', 1)), hours=int(env('TOKEN_EXPIRE_HOURS', 0)))
LEASE_EXPIRE_DELTA = relativedelta(days=int(env('LEASE_EXPIRE_DAYS', 90))) LEASE_EXPIRE_DELTA = relativedelta(days=int(env('LEASE_EXPIRE_DAYS', 90)), hours=int(env('LEASE_EXPIRE_HOURS', 0)))
LEASE_RENEWAL_PERIOD = float(env('LEASE_RENEWAL_PERIOD', 0.15))
CORS_ORIGINS = str(env('CORS_ORIGINS', '')).split(',') if (env('CORS_ORIGINS')) else [f'https://{DLS_URL}'] CORS_ORIGINS = str(env('CORS_ORIGINS', '')).split(',') if (env('CORS_ORIGINS')) else [f'https://{DLS_URL}']
jwt_encode_key = jwk.construct(INSTANCE_KEY_RSA.export_key().decode('utf-8'), algorithm=ALGORITHMS.RS256) jwt_encode_key = jwk.construct(INSTANCE_KEY_RSA.export_key().decode('utf-8'), algorithm=ALGORITHMS.RS256)
@ -68,11 +71,6 @@ async def index():
return RedirectResponse('/-/readme') return RedirectResponse('/-/readme')
@app.get('/status', summary='* Status', description='returns current service status, version (incl. git-commit) and some variables.', deprecated=True)
async def status(request: Request):
return JSONResponse({'status': 'up', 'version': VERSION, 'commit': COMMIT, 'debug': DEBUG})
@app.get('/-/', summary='* Index') @app.get('/-/', summary='* Index')
async def _index(): async def _index():
return RedirectResponse('/-/readme') return RedirectResponse('/-/readme')
@ -80,14 +78,32 @@ async def _index():
@app.get('/-/health', summary='* Health') @app.get('/-/health', summary='* Health')
async def _health(request: Request): async def _health(request: Request):
return JSONResponse({'status': 'up', 'version': VERSION, 'commit': COMMIT, 'debug': DEBUG}) return JSONr({'status': 'up'})
@app.get('/-/config', summary='* Config', description='returns environment variables.')
async def _config():
return JSONr({
'VERSION': str(VERSION),
'COMMIT': str(COMMIT),
'DEBUG': str(DEBUG),
'DLS_URL': str(DLS_URL),
'DLS_PORT': str(DLS_PORT),
'SITE_KEY_XID': str(SITE_KEY_XID),
'INSTANCE_REF': str(INSTANCE_REF),
'ALLOTMENT_REF': [str(ALLOTMENT_REF)],
'TOKEN_EXPIRE_DELTA': str(TOKEN_EXPIRE_DELTA),
'LEASE_EXPIRE_DELTA': str(LEASE_EXPIRE_DELTA),
'LEASE_RENEWAL_PERIOD': str(LEASE_RENEWAL_PERIOD),
'CORS_ORIGINS': str(CORS_ORIGINS),
})
@app.get('/-/readme', summary='* Readme') @app.get('/-/readme', summary='* Readme')
async def _readme(): async def _readme():
from markdown import markdown from markdown import markdown
content = load_file('../README.md').decode('utf-8') content = load_file('../README.md').decode('utf-8')
return HTMLResponse(markdown(text=content, extensions=['tables', 'fenced_code', 'md_in_html', 'nl2br', 'toc'])) return HTMLr(markdown(text=content, extensions=['tables', 'fenced_code', 'md_in_html', 'nl2br', 'toc']))
@app.get('/-/manage', summary='* Management UI') @app.get('/-/manage', summary='* Management UI')
@ -99,15 +115,19 @@ async def _manage(request: Request):
<title>FastAPI-DLS Management</title> <title>FastAPI-DLS Management</title>
</head> </head>
<body> <body>
<button onclick="deleteOrigins()">delete origins and their leases</button> <button onclick="deleteOrigins()">delete ALL origins and their leases</button>
<button onclick="deleteLease()">delete specific lease</button> <button onclick="deleteLease()">delete specific lease</button>
<script> <script>
function deleteOrigins() { function deleteOrigins() {
const response = confirm('Are you sure you want to delete all origins and their leases?');
if (response) {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open("DELETE", '/-/origins', true); xhr.open("DELETE", '/-/origins', true);
xhr.send(); xhr.send();
} }
}
function deleteLease(lease_ref) { function deleteLease(lease_ref) {
if(lease_ref === undefined) if(lease_ref === undefined)
lease_ref = window.prompt("Please enter 'lease_ref' which should be deleted"); lease_ref = window.prompt("Please enter 'lease_ref' which should be deleted");
@ -121,7 +141,7 @@ async def _manage(request: Request):
</body> </body>
</html> </html>
''' '''
return HTMLResponse(response) return HTMLr(response)
@app.get('/-/origins', summary='* Origins') @app.get('/-/origins', summary='* Origins')
@ -134,7 +154,7 @@ async def _origins(request: Request, leases: bool = False):
x['leases'] = list(map(lambda _: _.serialize(), Lease.find_by_origin_ref(db, origin.origin_ref))) x['leases'] = list(map(lambda _: _.serialize(), Lease.find_by_origin_ref(db, origin.origin_ref)))
response.append(x) response.append(x)
session.close() session.close()
return JSONResponse(response) return JSONr(response)
@app.delete('/-/origins', summary='* Origins') @app.delete('/-/origins', summary='* Origins')
@ -154,14 +174,14 @@ async def _leases(request: Request, origin: bool = False):
x['origin'] = session.query(Origin).filter(Origin.origin_ref == lease.origin_ref).first().serialize() x['origin'] = session.query(Origin).filter(Origin.origin_ref == lease.origin_ref).first().serialize()
response.append(x) response.append(x)
session.close() session.close()
return JSONResponse(response) return JSONr(response)
@app.delete('/-/lease/{lease_ref}', summary='* Lease') @app.delete('/-/lease/{lease_ref}', summary='* Lease')
async def _lease_delete(request: Request, lease_ref: str): async def _lease_delete(request: Request, lease_ref: str):
if Lease.delete(db, lease_ref) == 1: if Lease.delete(db, lease_ref) == 1:
return Response(status_code=201) return Response(status_code=201)
raise HTTPException(status_code=404, detail='lease not found') return JSONr(status_code=404, content={'status': 404, 'detail': 'lease not found'})
# venv/lib/python3.9/site-packages/nls_core_service_instance/service_instance_token_manager.py # venv/lib/python3.9/site-packages/nls_core_service_instance/service_instance_token_manager.py
@ -178,7 +198,7 @@ async def _client_token():
"nbf": timegm(cur_time.timetuple()), "nbf": timegm(cur_time.timetuple()),
"exp": timegm(exp_time.timetuple()), "exp": timegm(exp_time.timetuple()),
"update_mode": "ABSOLUTE", "update_mode": "ABSOLUTE",
"scope_ref_list": [str(uuid4())], # this is our LEASE_REF "scope_ref_list": [ALLOTMENT_REF],
"fulfillment_class_ref_list": [], "fulfillment_class_ref_list": [],
"service_instance_configuration": { "service_instance_configuration": {
"nls_service_instance_ref": INSTANCE_REF, "nls_service_instance_ref": INSTANCE_REF,
@ -210,32 +230,26 @@ async def _client_token():
return response return response
@app.get('/client-token', summary='* Client-Token', description='creates a new messenger token for this service instance', deprecated=True)
async def client_token():
return RedirectResponse('/-/client-token')
# venv/lib/python3.9/site-packages/nls_services_auth/test/test_origins_controller.py # venv/lib/python3.9/site-packages/nls_services_auth/test/test_origins_controller.py
# {"candidate_origin_ref":"00112233-4455-6677-8899-aabbccddeeff","environment":{"fingerprint":{"mac_address_list":["ff:ff:ff:ff:ff:ff"]},"hostname":"my-hostname","ip_address_list":["192.168.178.123","fe80::","fe80::1%enp6s18"],"guest_driver_version":"510.85.02","os_platform":"Debian GNU/Linux 11 (bullseye) 11","os_version":"11 (bullseye)"},"registration_pending":false,"update_pending":false}
@app.post('/auth/v1/origin', description='find or create an origin') @app.post('/auth/v1/origin', description='find or create an origin')
async def auth_v1_origin(request: Request): async def auth_v1_origin(request: Request):
j, cur_time = json.loads((await request.body()).decode('utf-8')), datetime.utcnow() j, cur_time = json_loads((await request.body()).decode('utf-8')), datetime.utcnow()
origin_ref = j['candidate_origin_ref'] origin_ref = j.get('candidate_origin_ref')
logging.info(f'> [ origin ]: {origin_ref}: {j}') logging.info(f'> [ origin ]: {origin_ref}: {j}')
data = Origin( data = Origin(
origin_ref=origin_ref, origin_ref=origin_ref,
hostname=j['environment']['hostname'], hostname=j.get('environment').get('hostname'),
guest_driver_version=j['environment']['guest_driver_version'], guest_driver_version=j.get('environment').get('guest_driver_version'),
os_platform=j['environment']['os_platform'], os_version=j['environment']['os_version'], os_platform=j.get('environment').get('os_platform'), os_version=j.get('environment').get('os_version'),
) )
Origin.create_or_update(db, data) Origin.create_or_update(db, data)
response = { response = {
"origin_ref": origin_ref, "origin_ref": origin_ref,
"environment": j['environment'], "environment": j.get('environment'),
"svc_port_set_list": None, "svc_port_set_list": None,
"node_url_list": None, "node_url_list": None,
"node_query_order": None, "node_query_order": None,
@ -243,44 +257,42 @@ async def auth_v1_origin(request: Request):
"sync_timestamp": cur_time.isoformat() "sync_timestamp": cur_time.isoformat()
} }
return JSONResponse(response) return JSONr(response)
# venv/lib/python3.9/site-packages/nls_services_auth/test/test_origins_controller.py # venv/lib/python3.9/site-packages/nls_services_auth/test/test_origins_controller.py
# { "environment" : { "guest_driver_version" : "guest_driver_version", "hostname" : "myhost", "ip_address_list" : [ "192.168.1.129" ], "os_version" : "os_version", "os_platform" : "os_platform", "fingerprint" : { "mac_address_list" : [ "e4:b9:7a:e5:7b:ff" ] }, "host_driver_version" : "host_driver_version" }, "origin_ref" : "00112233-4455-6677-8899-aabbccddeeff" }
@app.post('/auth/v1/origin/update', description='update an origin evidence') @app.post('/auth/v1/origin/update', description='update an origin evidence')
async def auth_v1_origin_update(request: Request): async def auth_v1_origin_update(request: Request):
j, cur_time = json.loads((await request.body()).decode('utf-8')), datetime.utcnow() j, cur_time = json_loads((await request.body()).decode('utf-8')), datetime.utcnow()
origin_ref = j['origin_ref'] origin_ref = j.get('origin_ref')
logging.info(f'> [ update ]: {origin_ref}: {j}') logging.info(f'> [ update ]: {origin_ref}: {j}')
data = Origin( data = Origin(
origin_ref=origin_ref, origin_ref=origin_ref,
hostname=j['environment']['hostname'], hostname=j.get('environment').get('hostname'),
guest_driver_version=j['environment']['guest_driver_version'], guest_driver_version=j.get('environment').get('guest_driver_version'),
os_platform=j['environment']['os_platform'], os_version=j['environment']['os_version'], os_platform=j.get('environment').get('os_platform'), os_version=j.get('environment').get('os_version'),
) )
Origin.create_or_update(db, data) Origin.create_or_update(db, data)
response = { response = {
"environment": j['environment'], "environment": j.get('environment'),
"prompts": None, "prompts": None,
"sync_timestamp": cur_time.isoformat() "sync_timestamp": cur_time.isoformat()
} }
return JSONResponse(response) return JSONr(response)
# venv/lib/python3.9/site-packages/nls_services_auth/test/test_auth_controller.py # venv/lib/python3.9/site-packages/nls_services_auth/test/test_auth_controller.py
# venv/lib/python3.9/site-packages/nls_core_auth/auth.py - CodeResponse # venv/lib/python3.9/site-packages/nls_core_auth/auth.py - CodeResponse
# {"code_challenge":"...","origin_ref":"00112233-4455-6677-8899-aabbccddeeff"}
@app.post('/auth/v1/code', description='get an authorization code') @app.post('/auth/v1/code', description='get an authorization code')
async def auth_v1_code(request: Request): async def auth_v1_code(request: Request):
j, cur_time = json.loads((await request.body()).decode('utf-8')), datetime.utcnow() j, cur_time = json_loads((await request.body()).decode('utf-8')), datetime.utcnow()
origin_ref = j['origin_ref'] origin_ref = j.get('origin_ref')
logging.info(f'> [ code ]: {origin_ref}: {j}') logging.info(f'> [ code ]: {origin_ref}: {j}')
delta = relativedelta(minutes=15) delta = relativedelta(minutes=15)
@ -289,8 +301,8 @@ async def auth_v1_code(request: Request):
payload = { payload = {
'iat': timegm(cur_time.timetuple()), 'iat': timegm(cur_time.timetuple()),
'exp': timegm(expires.timetuple()), 'exp': timegm(expires.timetuple()),
'challenge': j['code_challenge'], 'challenge': j.get('code_challenge'),
'origin_ref': j['origin_ref'], 'origin_ref': j.get('origin_ref'),
'key_ref': SITE_KEY_XID, 'key_ref': SITE_KEY_XID,
'kid': SITE_KEY_XID 'kid': SITE_KEY_XID
} }
@ -303,23 +315,27 @@ async def auth_v1_code(request: Request):
"prompts": None "prompts": None
} }
return JSONResponse(response) return JSONr(response)
# venv/lib/python3.9/site-packages/nls_services_auth/test/test_auth_controller.py # venv/lib/python3.9/site-packages/nls_services_auth/test/test_auth_controller.py
# venv/lib/python3.9/site-packages/nls_core_auth/auth.py - TokenResponse # venv/lib/python3.9/site-packages/nls_core_auth/auth.py - TokenResponse
# {"auth_code":"...","code_verifier":"..."}
@app.post('/auth/v1/token', description='exchange auth code and verifier for token') @app.post('/auth/v1/token', description='exchange auth code and verifier for token')
async def auth_v1_token(request: Request): async def auth_v1_token(request: Request):
j, cur_time = json.loads((await request.body()).decode('utf-8')), datetime.utcnow() j, cur_time = json_loads((await request.body()).decode('utf-8')), datetime.utcnow()
payload = jwt.decode(token=j['auth_code'], key=jwt_decode_key)
origin_ref = payload['origin_ref'] try:
payload = jwt.decode(token=j.get('auth_code'), key=jwt_decode_key)
except JWTError as e:
return JSONr(status_code=400, content={'status': 400, 'title': 'invalid token', 'detail': str(e)})
origin_ref = payload.get('origin_ref')
logging.info(f'> [ auth ]: {origin_ref}: {j}') logging.info(f'> [ auth ]: {origin_ref}: {j}')
# validate the code challenge # validate the code challenge
if payload['challenge'] != b64enc(sha256(j['code_verifier'].encode('utf-8')).digest()).rstrip(b'=').decode('utf-8'): challenge = b64enc(sha256(j.get('code_verifier').encode('utf-8')).digest()).rstrip(b'=').decode('utf-8')
raise HTTPException(status_code=401, detail='expected challenge did not match verifier') if payload.get('challenge') != challenge:
return JSONr(status_code=401, content={'status': 401, 'detail': 'expected challenge did not match verifier'})
access_expires_on = cur_time + TOKEN_EXPIRE_DELTA access_expires_on = cur_time + TOKEN_EXPIRE_DELTA
@ -342,36 +358,44 @@ async def auth_v1_token(request: Request):
"sync_timestamp": cur_time.isoformat(), "sync_timestamp": cur_time.isoformat(),
} }
return JSONResponse(response) return JSONr(response)
# {'fulfillment_context': {'fulfillment_class_ref_list': []}, 'lease_proposal_list': [{'license_type_qualifiers': {'count': 1}, 'product': {'name': 'NVIDIA RTX Virtual Workstation'}}], 'proposal_evaluation_mode': 'ALL_OF', 'scope_ref_list': ['00112233-4455-6677-8899-aabbccddeeff']} # venv/lib/python3.9/site-packages/nls_services_lease/test/test_lease_multi_controller.py
@app.post('/leasing/v1/lessor', description='request multiple leases (borrow) for current origin') @app.post('/leasing/v1/lessor', description='request multiple leases (borrow) for current origin')
async def leasing_v1_lessor(request: Request): async def leasing_v1_lessor(request: Request):
j, token, cur_time = json.loads((await request.body()).decode('utf-8')), __get_token(request), datetime.utcnow() j, token, cur_time = json_loads((await request.body()).decode('utf-8')), __get_token(request), datetime.utcnow()
try:
token = __get_token(request)
except JWTError:
return JSONr(status_code=401, content={'status': 401, 'detail': 'token is not valid'})
origin_ref = token.get('origin_ref') origin_ref = token.get('origin_ref')
scope_ref_list = j['scope_ref_list'] scope_ref_list = j.get('scope_ref_list')
logging.info(f'> [ create ]: {origin_ref}: create leases for scope_ref_list {scope_ref_list}') logging.info(f'> [ create ]: {origin_ref}: create leases for scope_ref_list {scope_ref_list}')
lease_result_list = [] lease_result_list = []
for scope_ref in scope_ref_list: for scope_ref in scope_ref_list:
# if scope_ref not in [ALLOTMENT_REF]:
# return JSONr(status_code=500, detail=f'no service instances found for scopes: ["{scope_ref}"]')
lease_ref = str(uuid4())
expires = cur_time + LEASE_EXPIRE_DELTA expires = cur_time + LEASE_EXPIRE_DELTA
lease_result_list.append({ lease_result_list.append({
"ordinal": 0, "ordinal": 0,
# https://docs.nvidia.com/license-system/latest/nvidia-license-system-user-guide/index.html # https://docs.nvidia.com/license-system/latest/nvidia-license-system-user-guide/index.html
"lease": { "lease": {
"ref": scope_ref, "ref": lease_ref,
"created": cur_time.isoformat(), "created": cur_time.isoformat(),
"expires": expires.isoformat(), "expires": expires.isoformat(),
# The percentage of the lease period that must elapse before a licensed client can renew a license "recommended_lease_renewal": LEASE_RENEWAL_PERIOD,
"recommended_lease_renewal": 0.15,
"offline_lease": "true", "offline_lease": "true",
"license_type": "CONCURRENT_COUNTED_SINGLE" "license_type": "CONCURRENT_COUNTED_SINGLE"
} }
}) })
data = Lease(origin_ref=origin_ref, lease_ref=scope_ref, lease_created=cur_time, lease_expires=expires) data = Lease(origin_ref=origin_ref, lease_ref=lease_ref, lease_created=cur_time, lease_expires=expires)
Lease.create_or_update(db, data) Lease.create_or_update(db, data)
response = { response = {
@ -381,7 +405,7 @@ async def leasing_v1_lessor(request: Request):
"prompts": None "prompts": None
} }
return JSONResponse(response) return JSONr(response)
# venv/lib/python3.9/site-packages/nls_services_lease/test/test_lease_multi_controller.py # venv/lib/python3.9/site-packages/nls_services_lease/test/test_lease_multi_controller.py
@ -401,9 +425,10 @@ async def leasing_v1_lessor_lease(request: Request):
"prompts": None "prompts": None
} }
return JSONResponse(response) return JSONr(response)
# venv/lib/python3.9/site-packages/nls_services_lease/test/test_lease_single_controller.py
# venv/lib/python3.9/site-packages/nls_core_lease/lease_single.py # venv/lib/python3.9/site-packages/nls_core_lease/lease_single.py
@app.put('/leasing/v1/lease/{lease_ref}', description='renew a lease') @app.put('/leasing/v1/lease/{lease_ref}', description='renew a lease')
async def leasing_v1_lease_renew(request: Request, lease_ref: str): async def leasing_v1_lease_renew(request: Request, lease_ref: str):
@ -414,13 +439,13 @@ async def leasing_v1_lease_renew(request: Request, lease_ref: str):
entity = Lease.find_by_origin_ref_and_lease_ref(db, origin_ref, lease_ref) entity = Lease.find_by_origin_ref_and_lease_ref(db, origin_ref, lease_ref)
if entity is None: if entity is None:
raise HTTPException(status_code=404, detail='requested lease not available') return JSONr(status_code=404, content={'status': 404, 'detail': 'requested lease not available'})
expires = cur_time + LEASE_EXPIRE_DELTA expires = cur_time + LEASE_EXPIRE_DELTA
response = { response = {
"lease_ref": lease_ref, "lease_ref": lease_ref,
"expires": expires.isoformat(), "expires": expires.isoformat(),
"recommended_lease_renewal": 0.16, "recommended_lease_renewal": LEASE_RENEWAL_PERIOD,
"offline_lease": True, "offline_lease": True,
"prompts": None, "prompts": None,
"sync_timestamp": cur_time.isoformat(), "sync_timestamp": cur_time.isoformat(),
@ -428,9 +453,10 @@ async def leasing_v1_lease_renew(request: Request, lease_ref: str):
Lease.renew(db, entity, expires, cur_time) Lease.renew(db, entity, expires, cur_time)
return JSONResponse(response) return JSONr(response)
# venv/lib/python3.9/site-packages/nls_services_lease/test/test_lease_single_controller.py
@app.delete('/leasing/v1/lease/{lease_ref}', description='release (return) a lease') @app.delete('/leasing/v1/lease/{lease_ref}', description='release (return) a lease')
async def leasing_v1_lease_delete(request: Request, lease_ref: str): async def leasing_v1_lease_delete(request: Request, lease_ref: str):
token, cur_time = __get_token(request), datetime.utcnow() token, cur_time = __get_token(request), datetime.utcnow()
@ -440,12 +466,12 @@ async def leasing_v1_lease_delete(request: Request, lease_ref: str):
entity = Lease.find_by_lease_ref(db, lease_ref) entity = Lease.find_by_lease_ref(db, lease_ref)
if entity.origin_ref != origin_ref: if entity.origin_ref != origin_ref:
raise HTTPException(status_code=403, detail='access or operation forbidden') return JSONr(status_code=403, content={'status': 403, 'detail': 'access or operation forbidden'})
if entity is None: if entity is None:
raise HTTPException(status_code=404, detail='requested lease not available') return JSONr(status_code=404, content={'status': 404, 'detail': 'requested lease not available'})
if Lease.delete(db, lease_ref) == 0: if Lease.delete(db, lease_ref) == 0:
raise HTTPException(status_code=404, detail='lease not found') return JSONr(status_code=404, content={'status': 404, 'detail': 'lease not found'})
response = { response = {
"lease_ref": lease_ref, "lease_ref": lease_ref,
@ -453,9 +479,10 @@ async def leasing_v1_lease_delete(request: Request, lease_ref: str):
"sync_timestamp": cur_time.isoformat(), "sync_timestamp": cur_time.isoformat(),
} }
return JSONResponse(response) return JSONr(response)
# venv/lib/python3.9/site-packages/nls_services_lease/test/test_lease_multi_controller.py
@app.delete('/leasing/v1/lessor/leases', description='release all leases') @app.delete('/leasing/v1/lessor/leases', description='release all leases')
async def leasing_v1_lessor_lease_remove(request: Request): async def leasing_v1_lessor_lease_remove(request: Request):
token, cur_time = __get_token(request), datetime.utcnow() token, cur_time = __get_token(request), datetime.utcnow()
@ -473,7 +500,29 @@ async def leasing_v1_lessor_lease_remove(request: Request):
"prompts": None "prompts": None
} }
return JSONResponse(response) return JSONr(response)
@app.post('/leasing/v1/lessor/shutdown', description='shutdown all leases')
async def leasing_v1_lessor_shutdown(request: Request):
j, cur_time = json_loads((await request.body()).decode('utf-8')), datetime.utcnow()
token = j.get('token')
token = jwt.decode(token=token, key=jwt_decode_key, algorithms=ALGORITHMS.RS256, options={'verify_aud': False})
origin_ref = token.get('origin_ref')
released_lease_list = list(map(lambda x: x.lease_ref, Lease.find_by_origin_ref(db, origin_ref)))
deletions = Lease.cleanup(db, origin_ref)
logging.info(f'> [ shutdown ]: {origin_ref}: removed {deletions} leases')
response = {
"released_lease_list": released_lease_list,
"release_failure_list": None,
"sync_timestamp": cur_time.isoformat(),
"prompts": None
}
return JSONr(response)
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -13,6 +13,7 @@ class Origin(Base):
origin_ref = Column(CHAR(length=36), primary_key=True, unique=True, index=True) # uuid4 origin_ref = Column(CHAR(length=36), primary_key=True, unique=True, index=True) # uuid4
# service_instance_xid = Column(CHAR(length=36), nullable=False, index=True) # uuid4 # not necessary, we only support one service_instance_xid ('INSTANCE_REF')
hostname = Column(VARCHAR(length=256), nullable=True) hostname = Column(VARCHAR(length=256), nullable=True)
guest_driver_version = Column(VARCHAR(length=10), nullable=True) guest_driver_version = Column(VARCHAR(length=10), nullable=True)
os_platform = Column(VARCHAR(length=256), nullable=True) os_platform = Column(VARCHAR(length=256), nullable=True)
@ -24,6 +25,7 @@ class Origin(Base):
def serialize(self) -> dict: def serialize(self) -> dict:
return { return {
'origin_ref': self.origin_ref, 'origin_ref': self.origin_ref,
# 'service_instance_xid': self.service_instance_xid,
'hostname': self.hostname, 'hostname': self.hostname,
'guest_driver_version': self.guest_driver_version, 'guest_driver_version': self.guest_driver_version,
'os_platform': self.os_platform, 'os_platform': self.os_platform,
@ -39,7 +41,6 @@ class Origin(Base):
def create_or_update(engine: Engine, origin: "Origin"): def create_or_update(engine: Engine, origin: "Origin"):
session = sessionmaker(bind=engine)() session = sessionmaker(bind=engine)()
entity = session.query(Origin).filter(Origin.origin_ref == origin.origin_ref).first() entity = session.query(Origin).filter(Origin.origin_ref == origin.origin_ref).first()
print(entity)
if entity is None: if entity is None:
session.add(origin) session.add(origin)
else: else:
@ -72,6 +73,7 @@ class Lease(Base):
lease_ref = Column(CHAR(length=36), primary_key=True, nullable=False, index=True) # uuid4 lease_ref = Column(CHAR(length=36), primary_key=True, nullable=False, index=True) # uuid4
origin_ref = Column(CHAR(length=36), ForeignKey(Origin.origin_ref, ondelete='CASCADE'), nullable=False, index=True) # uuid4 origin_ref = Column(CHAR(length=36), ForeignKey(Origin.origin_ref, ondelete='CASCADE'), nullable=False, index=True) # uuid4
# scope_ref = Column(CHAR(length=36), nullable=False, index=True) # uuid4 # not necessary, we only support one scope_ref ('ALLOTMENT_REF')
lease_created = Column(DATETIME(), nullable=False) lease_created = Column(DATETIME(), nullable=False)
lease_expires = Column(DATETIME(), nullable=False) lease_expires = Column(DATETIME(), nullable=False)
lease_updated = Column(DATETIME(), nullable=False) lease_updated = Column(DATETIME(), nullable=False)
@ -83,6 +85,7 @@ class Lease(Base):
return { return {
'lease_ref': self.lease_ref, 'lease_ref': self.lease_ref,
'origin_ref': self.origin_ref, 'origin_ref': self.origin_ref,
# 'scope_ref': self.scope_ref,
'lease_created': self.lease_created.isoformat(), 'lease_created': self.lease_created.isoformat(),
'lease_expires': self.lease_expires.isoformat(), 'lease_expires': self.lease_expires.isoformat(),
'lease_updated': self.lease_updated.isoformat(), 'lease_updated': self.lease_updated.isoformat(),
@ -178,4 +181,14 @@ def migrate(engine: Engine):
Lease.__table__.drop(bind=engine) Lease.__table__.drop(bind=engine)
init(engine) init(engine)
# def upgrade_1_2_to_1_3():
# x = db.dialect.get_columns(engine.connect(), Lease.__tablename__)
# x = next((_ for _ in x if _['name'] == 'scope_ref'), None)
# if x is None:
# Lease.scope_ref.compile()
# column_name = Lease.scope_ref.name
# column_type = Lease.scope_ref.type.compile(engine.dialect)
# engine.execute(f'ALTER TABLE "{Lease.__tablename__}" ADD COLUMN "{column_name}" {column_type}')
upgrade_1_0_to_1_1() upgrade_1_0_to_1_1()
# upgrade_1_2_to_1_3()

26
doc/Database.md Normal file
View File

@ -0,0 +1,26 @@
# Database structure
## `request_routing.service_instance`
| xid | org_name |
|----------------------------------------|--------------------------|
| `10000000-0000-0000-0000-000000000000` | `lic-000000000000000000` |
- `xid` is used as `SERVICE_INSTANCE_XID`
## `request_routing.license_allotment_service_instance`
| xid | service_instance_xid | license_allotment_xid |
|----------------------------------------|----------------------------------------|----------------------------------------|
| `90000000-0000-0000-0000-000000000001` | `10000000-0000-0000-0000-000000000000` | `80000000-0000-0000-0000-000000000001` |
- `xid` is only a primary-key and never used as foreign-key or reference
- `license_allotment_xid` must be used to fetch `xid`'s from `request_routing.license_allotment_reference`
## `request_routing.license_allotment_reference`
| xid | license_allotment_xid |
|----------------------------------------|----------------------------------------|
| `20000000-0000-0000-0000-000000000001` | `80000000-0000-0000-0000-000000000001` |
- `xid` is used as `scope_ref_list` on token request

View File

@ -33,6 +33,9 @@ nvidia-gridd[2986]: License acquired successfully. (Info: license.nvidia.space,
Most variables and configs are stored in `/var/lib/docker/volumes/configurations/_data`. Most variables and configs are stored in `/var/lib/docker/volumes/configurations/_data`.
Files can be modified with `docker cp <container-id>:/venv/... /opt/localfile/...` and back.
(May you need to fix permissions with `docker exec -u 0 <container-id> chown nonroot:nonroot /venv/...`)
## Dive / Docker image inspector ## Dive / Docker image inspector
- `dive dls:appliance` - `dive dls:appliance`

118
docker-compose.yml Normal file
View File

@ -0,0 +1,118 @@
version: '3.9'
x-dls-variables: &dls-variables
DLS_URL: localhost # REQUIRED, change to your ip or hostname
DLS_PORT: 443 # must match nginx listen & exposed port
LEASE_EXPIRE_DAYS: 90
DATABASE: sqlite:////app/database/db.sqlite
DEBUG: false
services:
dls:
image: collinwebdesigns/fastapi-dls:latest
restart: always
environment:
<<: *dls-variables
volumes:
- /opt/docker/fastapi-dls/cert:/app/cert # instance.private.pem, instance.public.pem
- db:/app/database
entrypoint: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app", "--proxy-headers"]
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8000/-/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
proxy:
image: nginx
ports:
# thees are ports where nginx (!) is listen to
- "80:80" # for "/leasing/v1/lessor/shutdown" used by windows guests, can't be changed!
- "443:443" # first part must match "DLS_PORT"
volumes:
- /opt/docker/fastapi-dls/cert:/opt/cert
healthcheck:
test: ["CMD", "curl", "--insecure", "--fail", "https://localhost/-/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
command: |
bash -c "bash -s <<\"EOF\"
cat > /etc/nginx/nginx.conf <<\"EON\"
daemon off;
user root;
worker_processes auto;
events {
worker_connections 1024;
}
http {
gzip on;
gzip_disable "msie6";
include /etc/nginx/mime.types;
upstream dls-backend {
server dls:8000; # must match dls listen port
}
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
root /var/www/html;
index index.html;
server_name _;
ssl_certificate "/opt/cert/webserver.crt";
ssl_certificate_key "/opt/cert/webserver.key";
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 10m;
ssl_protocols TLSv1.3 TLSv1.2;
# ssl_ciphers "ECDHE-ECDSA-CHACHA20-POLY1305";
# ssl_ciphers PROFILE=SYSTEM;
ssl_prefer_server_ciphers on;
location / {
proxy_set_header Host $$http_host;
proxy_set_header X-Real-IP $$remote_addr;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $$scheme;
proxy_pass http://dls-backend$$request_uri;
}
location = /-/health {
access_log off;
add_header 'Content-Type' 'application/json';
return 200 '{\"status\":\"up\",\"service\":\"nginx\"}';
}
}
server {
listen 80;
listen [::]:80;
root /var/www/html;
index index.html;
server_name _;
location /leasing/v1/lessor/shutdown {
proxy_set_header Host $$http_host;
proxy_set_header X-Real-IP $$remote_addr;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $$scheme;
proxy_pass http://dls-backend/leasing/v1/lessor/shutdown;
}
location / {
return 301 https://$$host$$request_uri;
}
}
}
EON
nginx
EOF"
volumes:
db:

View File

@ -3,6 +3,6 @@ uvicorn[standard]==0.20.0
python-jose==3.3.0 python-jose==3.3.0
pycryptodome==3.16.0 pycryptodome==3.16.0
python-dateutil==2.8.2 python-dateutil==2.8.2
sqlalchemy==1.4.45 sqlalchemy==1.4.46
markdown==3.4.1 markdown==3.4.1
python-dotenv==0.21.0 python-dotenv==0.21.0

View File

@ -3,7 +3,7 @@ from hashlib import sha256
from calendar import timegm from calendar import timegm
from datetime import datetime from datetime import datetime
from os.path import dirname, join from os.path import dirname, join
from uuid import uuid4 from uuid import uuid4, UUID
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from jose import jwt, jwk from jose import jwt, jwk
@ -20,8 +20,7 @@ from app.util import load_key
client = TestClient(main.app) client = TestClient(main.app)
ORIGIN_REF, LEASE_REF = str(uuid4()), str(uuid4()) ORIGIN_REF, ALLOTMENT_REF, SECRET = str(uuid4()), '20000000-0000-0000-0000-000000000001', 'HelloWorld'
SECRET = "HelloWorld"
# INSTANCE_KEY_RSA = generate_key() # INSTANCE_KEY_RSA = generate_key()
# INSTANCE_KEY_PUB = INSTANCE_KEY_RSA.public_key() # INSTANCE_KEY_PUB = INSTANCE_KEY_RSA.public_key()
@ -44,16 +43,15 @@ def test_index():
assert response.status_code == 200 assert response.status_code == 200
def test_status():
response = client.get('/status')
assert response.status_code == 200
assert response.json()['status'] == 'up'
def test_health(): def test_health():
response = client.get('/-/health') response = client.get('/-/health')
assert response.status_code == 200 assert response.status_code == 200
assert response.json()['status'] == 'up' assert response.json().get('status') == 'up'
def test_config():
response = client.get('/-/config')
assert response.status_code == 200
def test_readme(): def test_readme():
@ -71,11 +69,6 @@ def test_client_token():
assert response.status_code == 200 assert response.status_code == 200
def test_client_token_deprecated():
response = client.get('/client-token')
assert response.status_code == 200
def test_origins(): def test_origins():
pass pass
@ -110,7 +103,7 @@ def test_auth_v1_origin():
response = client.post('/auth/v1/origin', json=payload) response = client.post('/auth/v1/origin', json=payload)
assert response.status_code == 200 assert response.status_code == 200
assert response.json()['origin_ref'] == ORIGIN_REF assert response.json().get('origin_ref') == ORIGIN_REF
def auth_v1_origin_update(): def auth_v1_origin_update():
@ -131,7 +124,7 @@ def auth_v1_origin_update():
response = client.post('/auth/v1/origin/update', json=payload) response = client.post('/auth/v1/origin/update', json=payload)
assert response.status_code == 200 assert response.status_code == 200
assert response.json()['origin_ref'] == ORIGIN_REF assert response.json().get('origin_ref') == ORIGIN_REF
def test_auth_v1_code(): def test_auth_v1_code():
@ -143,8 +136,8 @@ def test_auth_v1_code():
response = client.post('/auth/v1/code', json=payload) response = client.post('/auth/v1/code', json=payload)
assert response.status_code == 200 assert response.status_code == 200
payload = jwt.get_unverified_claims(token=response.json()['auth_code']) payload = jwt.get_unverified_claims(token=response.json().get('auth_code'))
assert payload['origin_ref'] == ORIGIN_REF assert payload.get('origin_ref') == ORIGIN_REF
def test_auth_v1_token(): def test_auth_v1_token():
@ -168,9 +161,9 @@ def test_auth_v1_token():
response = client.post('/auth/v1/token', json=payload) response = client.post('/auth/v1/token', json=payload)
assert response.status_code == 200 assert response.status_code == 200
token = response.json()['auth_token'] token = response.json().get('auth_token')
payload = jwt.decode(token=token, key=jwt_decode_key, algorithms=ALGORITHMS.RS256, options={'verify_aud': False}) payload = jwt.decode(token=token, key=jwt_decode_key, algorithms=ALGORITHMS.RS256, options={'verify_aud': False})
assert payload['origin_ref'] == ORIGIN_REF assert payload.get('origin_ref') == ORIGIN_REF
def test_leasing_v1_lessor(): def test_leasing_v1_lessor():
@ -183,46 +176,67 @@ def test_leasing_v1_lessor():
'product': {'name': 'NVIDIA RTX Virtual Workstation'} 'product': {'name': 'NVIDIA RTX Virtual Workstation'}
}], }],
'proposal_evaluation_mode': 'ALL_OF', 'proposal_evaluation_mode': 'ALL_OF',
'scope_ref_list': [LEASE_REF] 'scope_ref_list': [ALLOTMENT_REF]
} }
response = client.post('/leasing/v1/lessor', json=payload, headers={'authorization': __bearer_token(ORIGIN_REF)}) response = client.post('/leasing/v1/lessor', json=payload, headers={'authorization': __bearer_token(ORIGIN_REF)})
assert response.status_code == 200 assert response.status_code == 200
lease_result_list = response.json()['lease_result_list'] lease_result_list = response.json().get('lease_result_list')
assert len(lease_result_list) == 1 assert len(lease_result_list) == 1
assert lease_result_list[0]['lease']['ref'] == LEASE_REF assert len(lease_result_list[0]['lease']['ref']) == 36
assert str(UUID(lease_result_list[0]['lease']['ref'])) == lease_result_list[0]['lease']['ref']
return lease_result_list[0]['lease']['ref']
def test_leasing_v1_lessor_lease(): def test_leasing_v1_lessor_lease():
response = client.get('/leasing/v1/lessor/leases', headers={'authorization': __bearer_token(ORIGIN_REF)}) response = client.get('/leasing/v1/lessor/leases', headers={'authorization': __bearer_token(ORIGIN_REF)})
assert response.status_code == 200 assert response.status_code == 200
active_lease_list = response.json()['active_lease_list'] active_lease_list = response.json().get('active_lease_list')
assert len(active_lease_list) == 1 assert len(active_lease_list) == 1
assert active_lease_list[0] == LEASE_REF assert len(active_lease_list[0]) == 36
assert str(UUID(active_lease_list[0])) == active_lease_list[0]
def test_leasing_v1_lease_renew(): def test_leasing_v1_lease_renew():
response = client.put(f'/leasing/v1/lease/{LEASE_REF}', headers={'authorization': __bearer_token(ORIGIN_REF)}) response = client.get('/leasing/v1/lessor/leases', headers={'authorization': __bearer_token(ORIGIN_REF)})
active_lease_list = response.json().get('active_lease_list')
active_lease_ref = active_lease_list[0]
###
response = client.put(f'/leasing/v1/lease/{active_lease_ref}', headers={'authorization': __bearer_token(ORIGIN_REF)})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()['lease_ref'] == LEASE_REF lease_ref = response.json().get('lease_ref')
assert len(lease_ref) == 36
assert lease_ref == active_lease_ref
def test_leasing_v1_lease_delete(): def test_leasing_v1_lease_delete():
response = client.delete(f'/leasing/v1/lease/{LEASE_REF}', headers={'authorization': __bearer_token(ORIGIN_REF)}) response = client.get('/leasing/v1/lessor/leases', headers={'authorization': __bearer_token(ORIGIN_REF)})
active_lease_list = response.json().get('active_lease_list')
active_lease_ref = active_lease_list[0]
###
response = client.delete(f'/leasing/v1/lease/{active_lease_ref}', headers={'authorization': __bearer_token(ORIGIN_REF)})
assert response.status_code == 200 assert response.status_code == 200
assert response.json()['lease_ref'] == LEASE_REF lease_ref = response.json().get('lease_ref')
assert len(lease_ref) == 36
assert lease_ref == active_lease_ref
def test_leasing_v1_lessor_lease_remove(): def test_leasing_v1_lessor_lease_remove():
test_leasing_v1_lessor() lease_ref = test_leasing_v1_lessor()
response = client.delete('/leasing/v1/lessor/leases', headers={'authorization': __bearer_token(ORIGIN_REF)}) response = client.delete('/leasing/v1/lessor/leases', headers={'authorization': __bearer_token(ORIGIN_REF)})
assert response.status_code == 200 assert response.status_code == 200
released_lease_list = response.json()['released_lease_list'] released_lease_list = response.json().get('released_lease_list')
assert len(released_lease_list) == 1 assert len(released_lease_list) == 1
assert released_lease_list[0] == LEASE_REF assert len(released_lease_list[0]) == 36
assert released_lease_list[0] == lease_ref

View File

@ -1 +1 @@
VERSION=1.2 VERSION=1.3