laniakea-api-server 0.1.2__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- laniakea_api_server-0.2.0/LICENSE +21 -0
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/PKG-INFO +3 -2
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/__init__.py +1 -1
- laniakea_api_server-0.2.0/laniakea_api/auth.py +134 -0
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/cli.py +16 -6
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/config.py +2 -2
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/credential_parser.py +9 -13
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/database.py +9 -1
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/installer.py +27 -5
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/main.py +9 -5
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/models.py +6 -3
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/routers/agent.py +1 -1
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/routers/agents.py +2 -1
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/pyproject.toml +2 -2
- laniakea_api_server-0.1.2/laniakea_api/auth.py +0 -103
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/.github/workflows/publish.yml +0 -0
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/.gitignore +0 -0
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/README.md +0 -0
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/queue.py +0 -0
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/routers/.health.py.swp +0 -0
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/routers/__init__.py +0 -0
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/routers/credentials.py +0 -0
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/routers/deployments.py +0 -0
- {laniakea_api_server-0.1.2 → laniakea_api_server-0.2.0}/laniakea_api/routers/health.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Riccardo Caccia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: laniakea-api-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Laniakea Queue API — OIDC-authenticated gateway for cloud deployment orchestration
|
|
5
5
|
Project-URL: Homepage, https://github.com/laniakea/laniakea-api
|
|
6
6
|
Project-URL: Issues, https://github.com/laniakea/laniakea-api/issues
|
|
7
|
-
Author:
|
|
7
|
+
Author: Riccardo Caccia
|
|
8
8
|
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
9
10
|
Keywords: aws,cloud,deployment,fastapi,openstack,orchestration,redis
|
|
10
11
|
Classifier: Development Status :: 3 - Alpha
|
|
11
12
|
Classifier: Framework :: FastAPI
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication helpers
|
|
3
|
+
|
|
4
|
+
It is responsible for verifying the identity of those who knock on
|
|
5
|
+
the server's door distinguishing between two types of users:
|
|
6
|
+
human users (who pass through an OIDC Sign-in system) and worker agents
|
|
7
|
+
"""
|
|
8
|
+
import time
|
|
9
|
+
import httpx
|
|
10
|
+
import jwt
|
|
11
|
+
from fastapi import Depends, HTTPException, status
|
|
12
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
13
|
+
from laniakea_api.config import (
|
|
14
|
+
SECRET_KEY, ALGORITHM, SESSION_TTL_MINUTES,
|
|
15
|
+
OIDC_DISCOVERY_URL, AGENT_MASTER_PASSWORD,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Initialize security OAuth2 Bearer Token
|
|
19
|
+
_bearer = HTTPBearer()
|
|
20
|
+
|
|
21
|
+
async def fetch_userinfo(oidc_token: str) -> dict:
|
|
22
|
+
"""
|
|
23
|
+
Takes an authentication token provided by a user and asks an external identity server
|
|
24
|
+
(the OIDC Provider) who that user actually is, retriving user info
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
# asynchronous HTTP to avoid waste of server resources
|
|
28
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
29
|
+
# OIDC discovery
|
|
30
|
+
discovery = await client.get(OIDC_DISCOVERY_URL)
|
|
31
|
+
discovery.raise_for_status() # status 200: OK
|
|
32
|
+
userinfo_url = discovery.json()["userinfo_endpoint"] # now final url is known
|
|
33
|
+
# USER info request
|
|
34
|
+
resp = await client.get(
|
|
35
|
+
userinfo_url,
|
|
36
|
+
headers={"Authorization": f"Bearer {oidc_token}"},
|
|
37
|
+
)
|
|
38
|
+
if resp.status_code != 200:
|
|
39
|
+
raise HTTPException(
|
|
40
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
41
|
+
detail=f"OIDC userinfo returned {resp.status_code}",
|
|
42
|
+
)
|
|
43
|
+
return resp.json()
|
|
44
|
+
except HTTPException:
|
|
45
|
+
raise
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
raise HTTPException(
|
|
48
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
49
|
+
detail=f"OIDC validation failed: {exc}",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_session_token(user_info: dict) -> tuple:
|
|
54
|
+
"""
|
|
55
|
+
Querying the external OIDC server for every single request would slow down the API.
|
|
56
|
+
To avoid this behaviour, once the user has been verified by fetch_userinfo, this function
|
|
57
|
+
generates an internal JWT token:
|
|
58
|
+
|
|
59
|
+
It takes the user's data (sub, username, email, groups).
|
|
60
|
+
It adds an expiration date based on the minutes configured in SESSION_TTL_MINUTES.
|
|
61
|
+
It cryptographically signs everything with a secret key (SECRET_KEY)
|
|
62
|
+
It returns a compact token that the user will use for subsequent requests.
|
|
63
|
+
"""
|
|
64
|
+
#NOTE: act here for expiration
|
|
65
|
+
expires_in = SESSION_TTL_MINUTES * 60
|
|
66
|
+
now = int(time.time())
|
|
67
|
+
#NOTE: PAYLOAD
|
|
68
|
+
payload = {
|
|
69
|
+
"sub": user_info.get("sub"),
|
|
70
|
+
"username": user_info.get("preferred_username") or user_info.get("sub"),
|
|
71
|
+
"email": user_info.get("email"),
|
|
72
|
+
"groups": user_info.get("groups", []),
|
|
73
|
+
"iat": now,
|
|
74
|
+
"exp": now + expires_in,
|
|
75
|
+
}
|
|
76
|
+
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM), expires_in
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def verify_session_token(credentials: HTTPAuthorizationCredentials = Depends(_bearer),) -> dict:
|
|
80
|
+
"""
|
|
81
|
+
Check that the HTTP request contains an authorization header.
|
|
82
|
+
Takes the internal JWT token created in the previous step and checks
|
|
83
|
+
whether the signature is authentic (using the SECRET_KEY).
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
return jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
|
|
87
|
+
|
|
88
|
+
except jwt.ExpiredSignatureError:
|
|
89
|
+
raise HTTPException(
|
|
90
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
91
|
+
detail="Session token expired",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
except jwt.InvalidTokenError as exc:
|
|
95
|
+
raise HTTPException(
|
|
96
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
97
|
+
detail=f"Invalid session token: {exc}",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def verify_agent_token(credentials: HTTPAuthorizationCredentials = Depends(_bearer),) -> str:
|
|
102
|
+
"""
|
|
103
|
+
This function validates the tokens used by workers.
|
|
104
|
+
It doesn't use the user's key, but a dedicated secret key
|
|
105
|
+
called AGENT_MASTER_PASSWORD.
|
|
106
|
+
If the worker sends a valid token signed with this master password,
|
|
107
|
+
the API trusts the worker and allows it to fetch or update the status of deployment jobs.
|
|
108
|
+
|
|
109
|
+
HTCondor pool-password like.
|
|
110
|
+
"""
|
|
111
|
+
if not AGENT_MASTER_PASSWORD:
|
|
112
|
+
raise HTTPException(
|
|
113
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
114
|
+
detail="AGENT_MASTER_PASSWORD not configured, set it as an ambient variable or in the .env",
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
payload = jwt.decode(
|
|
118
|
+
credentials.credentials,
|
|
119
|
+
AGENT_MASTER_PASSWORD,
|
|
120
|
+
algorithms=["HS256"],
|
|
121
|
+
)
|
|
122
|
+
# NOTE: unknown-agent default value
|
|
123
|
+
return payload.get("sub", "unknown-agent")
|
|
124
|
+
|
|
125
|
+
except jwt.ExpiredSignatureError:
|
|
126
|
+
raise HTTPException(
|
|
127
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
128
|
+
detail="Agent token expired.",
|
|
129
|
+
)
|
|
130
|
+
except jwt.InvalidTokenError as exc:
|
|
131
|
+
raise HTTPException(
|
|
132
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
133
|
+
detail=f"Invalid agent token: {exc}",
|
|
134
|
+
)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
after pip install laniakea-api-server, the user throw:
|
|
2
|
+
after pip install laniakea-api-server, the user can throw:
|
|
3
3
|
laniakea-api
|
|
4
4
|
laniakea-api --port 8000 # HTTP, no TLS (test)
|
|
5
5
|
laniakea-api --port 8443 --ssl # HTTPS con TLS
|
|
@@ -12,14 +12,19 @@ import sys
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def main():
|
|
15
|
+
"""
|
|
16
|
+
Which option is accepted in the cli
|
|
17
|
+
"""
|
|
15
18
|
parser = argparse.ArgumentParser(
|
|
16
19
|
prog="laniakea-api",
|
|
17
20
|
description="Laniakea Queue API server.",
|
|
18
21
|
)
|
|
22
|
+
|
|
19
23
|
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
|
|
20
24
|
parser.add_argument("--port", default=8443, type=int, help="Bind port (default: 8443)")
|
|
21
25
|
parser.add_argument("--env", default=".env", metavar="FILE", help="Path to .env file")
|
|
22
26
|
parser.add_argument("--ssl", action="store_true", help="Enable TLS (requires SSL_KEYFILE and SSL_CERTFILE in .env)")
|
|
27
|
+
#FIXME: reload to be mantained?
|
|
23
28
|
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development only)")
|
|
24
29
|
parser.add_argument("--version", action="store_true", help="Print version and exit.")
|
|
25
30
|
args = parser.parse_args()
|
|
@@ -30,15 +35,17 @@ def main():
|
|
|
30
35
|
sys.exit(0)
|
|
31
36
|
|
|
32
37
|
# load .env
|
|
38
|
+
# NOTE: if .env not specified local ambient variable are used
|
|
33
39
|
env_path = os.path.abspath(args.env)
|
|
34
40
|
if os.path.exists(env_path):
|
|
35
41
|
from dotenv import load_dotenv
|
|
36
42
|
load_dotenv(env_path)
|
|
37
43
|
print(f"[config] loaded {env_path}")
|
|
38
44
|
else:
|
|
39
|
-
print(f"[config] .env not found at {env_path}
|
|
45
|
+
print(f"[config] .env not found at {env_path}... using environment variables")
|
|
40
46
|
|
|
41
47
|
# validate required env vars
|
|
48
|
+
# NOTE: Check if all required vars
|
|
42
49
|
required = [
|
|
43
50
|
"SECRET_KEY", "AGENT_MASTER_PASSWORD",
|
|
44
51
|
"REDIS_HOST", "REDIS_PASSWORD",
|
|
@@ -48,24 +55,26 @@ def main():
|
|
|
48
55
|
]
|
|
49
56
|
missing = [v for v in required if not os.getenv(v)]
|
|
50
57
|
if missing:
|
|
51
|
-
print(f"[error] missing required
|
|
58
|
+
print(f"[error] missing required variables: {', '.join(missing)}")
|
|
52
59
|
sys.exit(1)
|
|
53
60
|
|
|
54
|
-
#
|
|
61
|
+
# FIXME: check if the destination for this dir is ok
|
|
62
|
+
# create a log dir
|
|
55
63
|
log_dir = os.getenv("DEPLOYMENT_LOG_DIR", "/var/log/laniakea-agent")
|
|
56
64
|
os.makedirs(log_dir, exist_ok=True)
|
|
57
65
|
|
|
58
66
|
import uvicorn
|
|
59
67
|
|
|
60
68
|
uvicorn_kwargs = dict(
|
|
61
|
-
app="laniakea_api.main:app",
|
|
69
|
+
app="laniakea_api.main:app", # App instance in the main file
|
|
62
70
|
host=args.host,
|
|
63
71
|
port=args.port,
|
|
64
72
|
log_level="info",
|
|
65
|
-
reload=args.reload,
|
|
73
|
+
reload=args.reload, # NOTE: To be removed if argparse removed
|
|
66
74
|
)
|
|
67
75
|
|
|
68
76
|
if args.ssl:
|
|
77
|
+
# if ssl option specified searches for SSL certificates and private key
|
|
69
78
|
ssl_key = os.getenv("SSL_KEYFILE", "certs/api.key")
|
|
70
79
|
ssl_cert = os.getenv("SSL_CERTFILE", "certs/api.crt")
|
|
71
80
|
if not os.path.exists(ssl_key) or not os.path.exists(ssl_cert):
|
|
@@ -77,6 +86,7 @@ def main():
|
|
|
77
86
|
else:
|
|
78
87
|
print(f"[api] HTTP on {args.host}:{args.port} (no TLS)")
|
|
79
88
|
|
|
89
|
+
# Starting the application
|
|
80
90
|
uvicorn.run(**uvicorn_kwargs)
|
|
81
91
|
|
|
82
92
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Import the .env
|
|
2
|
+
Import the .env vars
|
|
3
3
|
Inizialize all the service: Redis, Vault, Posgre
|
|
4
4
|
"""
|
|
5
5
|
|
|
@@ -17,7 +17,7 @@ AGENT_MASTER_PASSWORD = os.getenv("AGENT_MASTER_PASSWORD", "")
|
|
|
17
17
|
|
|
18
18
|
# Redis
|
|
19
19
|
REDIS_HOST = os.getenv("REDIS_HOST", "")
|
|
20
|
-
# NOTE:
|
|
20
|
+
# NOTE: mod. port
|
|
21
21
|
REDIS_PORT = int(os.getenv("REDIS_PORT", "1908"))
|
|
22
22
|
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
|
|
23
23
|
|
|
@@ -27,7 +27,8 @@ except ImportError:
|
|
|
27
27
|
HAS_YAML = False
|
|
28
28
|
|
|
29
29
|
# RC file parser
|
|
30
|
-
#
|
|
30
|
+
# This dictionary is used to translate the names of standard OpenStack variables (left)
|
|
31
|
+
# into the internal names used by the Laniakea database and APIs (right).
|
|
31
32
|
_RC_FIELD_MAP = {
|
|
32
33
|
"OS_AUTH_URL": "openstack_auth_url",
|
|
33
34
|
"OS_APPLICATION_CREDENTIAL_ID": "openstack_app_credential_id",
|
|
@@ -44,7 +45,9 @@ def _parse_rc_file(path: Path) -> dict:
|
|
|
44
45
|
export OS_APPLICATION_CREDENTIAL_ID=abc123
|
|
45
46
|
"""
|
|
46
47
|
result = {}
|
|
47
|
-
# matches: export KEY=value or KEY=value
|
|
48
|
+
# matches: export KEY=value or KEY=value
|
|
49
|
+
# This regex can clean up and capture lines whether they are written as export KEY="value",
|
|
50
|
+
# or as KEY='value' or simply KEY=value.
|
|
48
51
|
pattern = re.compile(r"""^\s*(?:export\s+)?(\w+)=["\']?([^"\';\n]*)["\']?\s*$""")
|
|
49
52
|
|
|
50
53
|
for line in path.read_text().splitlines():
|
|
@@ -58,14 +61,11 @@ def _parse_rc_file(path: Path) -> dict:
|
|
|
58
61
|
result[field] = value
|
|
59
62
|
return result
|
|
60
63
|
|
|
61
|
-
#clouds.yaml parser
|
|
62
|
-
|
|
63
64
|
def _parse_clouds_yaml(path: Path, cloud_name: Optional[str] = None) -> dict:
|
|
64
65
|
"""
|
|
65
66
|
Parse a clouds.yaml file.
|
|
66
67
|
If cloud_name is None and there is only one cloud entry, use that one.
|
|
67
|
-
"""
|
|
68
|
-
# NOTE: put it in the requirement
|
|
68
|
+
"""
|
|
69
69
|
if not HAS_YAML:
|
|
70
70
|
raise ImportError("PyYAML is required to parse clouds.yaml. Run: pip install pyyaml")
|
|
71
71
|
|
|
@@ -76,8 +76,7 @@ def _parse_clouds_yaml(path: Path, cloud_name: Optional[str] = None) -> dict:
|
|
|
76
76
|
raise ValueError("No 'clouds' section found in the file.")
|
|
77
77
|
|
|
78
78
|
# auto-select if only one cloud
|
|
79
|
-
# if user hasn't specifyed with parser
|
|
80
|
-
# NOTE: to be removed outside test
|
|
79
|
+
# if user hasn't specifyed with parser
|
|
81
80
|
if cloud_name is None:
|
|
82
81
|
if len(clouds) == 1:
|
|
83
82
|
cloud_name = next(iter(clouds))
|
|
@@ -96,7 +95,7 @@ def _parse_clouds_yaml(path: Path, cloud_name: Optional[str] = None) -> dict:
|
|
|
96
95
|
result = {}
|
|
97
96
|
|
|
98
97
|
# auth block
|
|
99
|
-
# add fields to the result
|
|
98
|
+
# NOTE: add fields to the result
|
|
100
99
|
if auth.get("auth_url"):
|
|
101
100
|
result["openstack_auth_url"] = auth["auth_url"]
|
|
102
101
|
if auth.get("application_credential_id"):
|
|
@@ -109,11 +108,8 @@ def _parse_clouds_yaml(path: Path, cloud_name: Optional[str] = None) -> dict:
|
|
|
109
108
|
result["openstack_interface"] = entry["interface"]
|
|
110
109
|
if entry.get("identity_api_version"):
|
|
111
110
|
result["openstack_identity_api_version"] = str(entry["identity_api_version"])
|
|
112
|
-
|
|
113
111
|
return result
|
|
114
112
|
|
|
115
|
-
# Public API
|
|
116
|
-
|
|
117
113
|
def parse_credential_file(path: str, cloud_name: Optional[str] = None) -> dict:
|
|
118
114
|
"""
|
|
119
115
|
Auto-detect file type (.sh / .yaml / .yml) and return a dict of credentials.
|
|
@@ -137,7 +133,7 @@ def parse_credential_file(path: str, cloud_name: Optional[str] = None) -> dict:
|
|
|
137
133
|
elif suffix in (".sh", ".env", ""):
|
|
138
134
|
creds = _parse_rc_file(p)
|
|
139
135
|
else:
|
|
140
|
-
# try RC first, fall back to YAML
|
|
136
|
+
# ambiguous suffix: try RC first, fall back to YAML
|
|
141
137
|
try:
|
|
142
138
|
creds = _parse_rc_file(p)
|
|
143
139
|
if not creds:
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
2
|
PostgreSQL connection.
|
|
3
|
-
The agent has NO direct DB access
|
|
3
|
+
The agent has NO direct DB access:
|
|
4
|
+
**all writes go through the API**
|
|
5
|
+
The Dashboard is stateless.
|
|
4
6
|
"""
|
|
5
7
|
|
|
6
8
|
import os
|
|
@@ -35,6 +37,8 @@ def create_deployment(
|
|
|
35
37
|
conn = get_conn()
|
|
36
38
|
try:
|
|
37
39
|
with conn.cursor() as cur:
|
|
40
|
+
# If the user making the request doesn't yet exist in the local database, it inserts them.
|
|
41
|
+
#if they already exist (subprimary key conflict), it does nothing.
|
|
38
42
|
cur.execute(
|
|
39
43
|
"""
|
|
40
44
|
INSERT INTO users (sub, username, email, role, active)
|
|
@@ -43,6 +47,8 @@ def create_deployment(
|
|
|
43
47
|
""",
|
|
44
48
|
(user_sub, username, ""),
|
|
45
49
|
)
|
|
50
|
+
#Create a row in the deployments table with the initial state set to "QUEUED."
|
|
51
|
+
# if, by chance, that UUID already exists, simply update the state and date.
|
|
46
52
|
cur.execute(
|
|
47
53
|
"""
|
|
48
54
|
INSERT INTO deployments (
|
|
@@ -69,6 +75,8 @@ def update_status(
|
|
|
69
75
|
conn = get_conn()
|
|
70
76
|
try:
|
|
71
77
|
with conn.cursor() as cur:
|
|
78
|
+
# COALESCE causes the database to keep old value already present
|
|
79
|
+
# without overwriting them with an empty value.
|
|
72
80
|
cur.execute(
|
|
73
81
|
"""
|
|
74
82
|
UPDATE deployments
|
|
@@ -16,6 +16,11 @@ import sys
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def _add_to_path():
|
|
19
|
+
"""
|
|
20
|
+
This function opens your Bash shell configuration file (~/.bashrc).
|
|
21
|
+
It checks whether the ~/.local/bin folder is already present. If it isn't,
|
|
22
|
+
it adds the following line to the bottom of the file: export PATH=$HOME/.local/bin:$PATH
|
|
23
|
+
"""
|
|
19
24
|
bashrc = os.path.expanduser("~/.bashrc")
|
|
20
25
|
line = 'export PATH=$HOME/.local/bin:$PATH'
|
|
21
26
|
|
|
@@ -33,26 +38,34 @@ def _add_to_path():
|
|
|
33
38
|
|
|
34
39
|
|
|
35
40
|
def _create_workdir(workdir: str):
|
|
41
|
+
"""
|
|
42
|
+
Create the main folder where the application will run (by default, ~/laniakea-api)
|
|
43
|
+
and create a certs subfolder within it to host future (optional) SSL certificates.
|
|
44
|
+
|
|
45
|
+
The most important part is generating the .env file. If the file doesn't already exist, it will
|
|
46
|
+
be created using the default values managed in the CONFIG.py
|
|
47
|
+
"""
|
|
36
48
|
os.makedirs(workdir, exist_ok=True)
|
|
37
49
|
os.makedirs(os.path.join(workdir, "certs"), exist_ok=True)
|
|
38
50
|
|
|
39
51
|
env_path = os.path.join(workdir, ".env")
|
|
40
52
|
if os.path.exists(env_path):
|
|
41
|
-
print(f"[workdir] .env already exists
|
|
53
|
+
print(f"[workdir] .env already exists... skipping the creation")
|
|
42
54
|
else:
|
|
55
|
+
# NOTE: MOD. HERE THE TEMPLATE
|
|
43
56
|
template = """\
|
|
44
57
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
|
45
58
|
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
|
46
59
|
SECRET_KEY=
|
|
47
60
|
SESSION_TTL_MINUTES=60
|
|
48
|
-
OIDC_DISCOVERY_URL=https://
|
|
61
|
+
OIDC_DISCOVERY_URL=https://example.it/.well-known/openid-configuration
|
|
49
62
|
|
|
50
63
|
# ── Agent pool password (must match the agent .env) ───────────────────────────
|
|
51
64
|
AGENT_MASTER_PASSWORD=
|
|
52
65
|
|
|
53
66
|
# ── Redis ─────────────────────────────────────────────────────────────────────
|
|
54
67
|
REDIS_HOST=127.0.0.1
|
|
55
|
-
REDIS_PORT=
|
|
68
|
+
REDIS_PORT=
|
|
56
69
|
REDIS_PASSWORD=
|
|
57
70
|
|
|
58
71
|
# ── PostgreSQL ────────────────────────────────────────────────────────────────
|
|
@@ -83,20 +96,28 @@ DEPLOYMENT_LOG_DIR=/var/log/laniakea-agent
|
|
|
83
96
|
|
|
84
97
|
|
|
85
98
|
def _create_log_dir():
|
|
99
|
+
"""
|
|
100
|
+
The API and agents need to write deployment logs to /var/log/laniakea-agent.
|
|
101
|
+
The script create this system folder if needed.
|
|
102
|
+
"""
|
|
86
103
|
log_dir = "/var/log/laniakea-agent"
|
|
87
104
|
if os.path.exists(log_dir):
|
|
88
|
-
print(f"[logs] {log_dir} already exists
|
|
105
|
+
print(f"[logs] {log_dir} already exists... skipping the creation")
|
|
89
106
|
return
|
|
90
107
|
try:
|
|
91
108
|
os.makedirs(log_dir, exist_ok=True)
|
|
92
109
|
os.chown(log_dir, os.getuid(), os.getgid())
|
|
93
110
|
print(f"[logs] log directory created: {log_dir}")
|
|
94
111
|
except PermissionError:
|
|
95
|
-
print(f"[logs] insufficient permissions
|
|
112
|
+
print(f"[logs] insufficient permissions! Please run:")
|
|
96
113
|
print(f" sudo mkdir -p {log_dir} && sudo chown $USER:$USER {log_dir}")
|
|
97
114
|
|
|
98
115
|
|
|
99
116
|
def main():
|
|
117
|
+
"""
|
|
118
|
+
Once the installation is completed this function print
|
|
119
|
+
a guide for the user.
|
|
120
|
+
"""
|
|
100
121
|
parser = argparse.ArgumentParser(
|
|
101
122
|
prog="laniakea-api-install",
|
|
102
123
|
description="Initial setup of the VM for laniakea-api-server.",
|
|
@@ -114,6 +135,7 @@ def main():
|
|
|
114
135
|
_create_log_dir()
|
|
115
136
|
_create_workdir(args.workdir)
|
|
116
137
|
|
|
138
|
+
# FIXME: implement a guide for certifacete (non-self-signed)
|
|
117
139
|
print(f"""
|
|
118
140
|
=== Setup completed ===
|
|
119
141
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Registers all routers and starts uvicorn
|
|
2
|
+
Registers all routers and starts uvicorn.
|
|
3
|
+
|
|
4
|
+
Create the FastAPI application instance and map the various groups of
|
|
5
|
+
endpoints (the "routers") to their respective URL addresses (the "prefixes").
|
|
3
6
|
"""
|
|
4
7
|
|
|
5
8
|
import os
|
|
@@ -8,11 +11,11 @@ from fastapi import FastAPI
|
|
|
8
11
|
from laniakea_api.routers import agent, agents,credentials, deployments, health
|
|
9
12
|
|
|
10
13
|
# FastAPI App
|
|
11
|
-
|
|
12
14
|
app = FastAPI(
|
|
13
15
|
title="Laniakea Queue API",
|
|
14
16
|
description="OIDC-authenticated gateway for enqueuing cloud deployment jobs.",
|
|
15
|
-
|
|
17
|
+
# FIXME: automatizza versione
|
|
18
|
+
version="0.1.4",
|
|
16
19
|
)
|
|
17
20
|
|
|
18
21
|
# NOTE: CHANGE HERE for path
|
|
@@ -24,8 +27,8 @@ app.include_router(credentials.router, prefix=BASE)
|
|
|
24
27
|
app.include_router(deployments.router, prefix=BASE)
|
|
25
28
|
app.include_router(agent.router, prefix=INTERNAL)
|
|
26
29
|
app.include_router(agents.router, prefix=INTERNAL) # POST /internal/agents/heartbeat
|
|
27
|
-
app.include_router(agents.router, prefix=BASE)
|
|
28
|
-
app.include_router(health.router)
|
|
30
|
+
app.include_router(agents.router, prefix=BASE) # GET /api/agents/status
|
|
31
|
+
app.include_router(health.router) # /health:no prefix
|
|
29
32
|
|
|
30
33
|
############ Routes registered ############################
|
|
31
34
|
#NOTE: Add or remove every new modification
|
|
@@ -47,6 +50,7 @@ app.include_router(health.router) # /health:no prefix
|
|
|
47
50
|
# GET /health
|
|
48
51
|
|
|
49
52
|
# Entry point uvicorn
|
|
53
|
+
# skipped
|
|
50
54
|
if __name__ == "__main__":
|
|
51
55
|
uvicorn.run(
|
|
52
56
|
"main:app",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
All Pydantic models used across the api.
|
|
3
|
+
|
|
4
|
+
Defines the data structure of everything that enters/exits from the API
|
|
3
5
|
"""
|
|
4
6
|
|
|
5
7
|
from typing import Optional
|
|
@@ -9,13 +11,14 @@ from pydantic import BaseModel
|
|
|
9
11
|
class OIDCLoginRequest(BaseModel):
|
|
10
12
|
"""
|
|
11
13
|
Only the aai token is needed.
|
|
14
|
+
From idp.
|
|
12
15
|
"""
|
|
13
16
|
oidc_token: str
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
class SessionTokenResponse(BaseModel):
|
|
17
20
|
"""
|
|
18
|
-
If the token check is OK returns:
|
|
21
|
+
If the token check is OK returns a JWT containing:
|
|
19
22
|
"""
|
|
20
23
|
session_token: str
|
|
21
24
|
token_type: str = "bearer" # checks if the user has the correct permission
|
|
@@ -55,7 +58,7 @@ class DeploymentRequest(BaseModel):
|
|
|
55
58
|
"""
|
|
56
59
|
deployment_uuid: str # NOTE: the dashboard needs to create a uuid for each job
|
|
57
60
|
timestamp: str
|
|
58
|
-
description: str # optional or mandatory? check teams
|
|
61
|
+
description: str # NOTE: optional or mandatory? check teams
|
|
59
62
|
auth: dict # { aai_token, sub, group }
|
|
60
63
|
orchestrator: dict # target_provider, desired_orchestrator, endpoint
|
|
61
64
|
selected_provider: str # OpenStack | AWS
|
|
@@ -21,7 +21,7 @@ def _validate_transition(current: str, new_status: str, uuid: str) -> None:
|
|
|
21
21
|
"""
|
|
22
22
|
allowed = {
|
|
23
23
|
"QUEUED": {"CREATE_IN_PROGRESS", "UPDATE_IN_PROGRESS"},
|
|
24
|
-
"CREATE_IN_PROGRESS": {"CREATE_COMPLETE", "CREATE_FAILED"},
|
|
24
|
+
"CREATE_IN_PROGRESS": {"CREATE_COMPLETE", "CREATE_FAILED", "QUEUED"},
|
|
25
25
|
"UPDATE_IN_PROGRESS": {"UPDATE_FAILED"},
|
|
26
26
|
"CREATE_COMPLETE": set(),
|
|
27
27
|
"CREATE_FAILED": set(),
|
|
@@ -26,6 +26,7 @@ class QuotaInfo(BaseModel):
|
|
|
26
26
|
floating_ips_available: Optional[int] = None
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
# NOTE: is it ok every 30 sec?
|
|
29
30
|
class HeartbeatRequest(BaseModel):
|
|
30
31
|
"""
|
|
31
32
|
Sent by the agent every 30 seconds
|
|
@@ -53,7 +54,7 @@ async def agent_heartbeat(
|
|
|
53
54
|
):
|
|
54
55
|
"""
|
|
55
56
|
Called by laniakea-agent every 30 seconds.
|
|
56
|
-
Stores the agent's quota info in Redis with a 60
|
|
57
|
+
Stores the agent's quota info in Redis with a 60 second TTL.
|
|
57
58
|
If the agent stops sending heartbeats, the key expires and the
|
|
58
59
|
dashboard shows the agent as offline.
|
|
59
60
|
"""
|
|
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "laniakea-api-server"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Laniakea Queue API — OIDC-authenticated gateway for cloud deployment orchestration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
12
|
|
|
13
13
|
authors = [
|
|
14
|
-
{ name = "
|
|
14
|
+
{ name = "Riccardo Caccia" }
|
|
15
15
|
]
|
|
16
16
|
|
|
17
17
|
keywords = ["cloud", "deployment", "openstack", "aws", "fastapi", "redis", "orchestration"]
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Authentication helpers.
|
|
3
|
-
"""
|
|
4
|
-
import time
|
|
5
|
-
import httpx
|
|
6
|
-
import jwt
|
|
7
|
-
from fastapi import Depends, HTTPException, status
|
|
8
|
-
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
9
|
-
from laniakea_api.config import (
|
|
10
|
-
SECRET_KEY, ALGORITHM, SESSION_TTL_MINUTES,
|
|
11
|
-
OIDC_DISCOVERY_URL, AGENT_MASTER_PASSWORD,
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
_bearer = HTTPBearer()
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
async def fetch_userinfo(oidc_token: str) -> dict:
|
|
18
|
-
try:
|
|
19
|
-
async with httpx.AsyncClient(timeout=10) as client:
|
|
20
|
-
discovery = await client.get(OIDC_DISCOVERY_URL)
|
|
21
|
-
discovery.raise_for_status()
|
|
22
|
-
userinfo_url = discovery.json()["userinfo_endpoint"]
|
|
23
|
-
resp = await client.get(
|
|
24
|
-
userinfo_url,
|
|
25
|
-
headers={"Authorization": f"Bearer {oidc_token}"},
|
|
26
|
-
)
|
|
27
|
-
if resp.status_code != 200:
|
|
28
|
-
raise HTTPException(
|
|
29
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
30
|
-
detail=f"OIDC userinfo returned {resp.status_code}",
|
|
31
|
-
)
|
|
32
|
-
return resp.json()
|
|
33
|
-
except HTTPException:
|
|
34
|
-
raise
|
|
35
|
-
except Exception as exc:
|
|
36
|
-
raise HTTPException(
|
|
37
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
38
|
-
detail=f"OIDC validation failed: {exc}",
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def create_session_token(user_info: dict) -> tuple:
|
|
43
|
-
expires_in = SESSION_TTL_MINUTES * 60
|
|
44
|
-
now = int(time.time())
|
|
45
|
-
payload = {
|
|
46
|
-
"sub": user_info.get("sub"),
|
|
47
|
-
"username": user_info.get("preferred_username") or user_info.get("sub"),
|
|
48
|
-
"email": user_info.get("email"),
|
|
49
|
-
"groups": user_info.get("groups", []),
|
|
50
|
-
"iat": now,
|
|
51
|
-
"exp": now + expires_in,
|
|
52
|
-
}
|
|
53
|
-
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM), expires_in
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
async def verify_session_token(
|
|
57
|
-
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
|
|
58
|
-
) -> dict:
|
|
59
|
-
try:
|
|
60
|
-
return jwt.decode(
|
|
61
|
-
credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]
|
|
62
|
-
)
|
|
63
|
-
except jwt.ExpiredSignatureError:
|
|
64
|
-
raise HTTPException(
|
|
65
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
66
|
-
detail="Session token expired.",
|
|
67
|
-
)
|
|
68
|
-
except jwt.InvalidTokenError as exc:
|
|
69
|
-
raise HTTPException(
|
|
70
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
71
|
-
detail=f"Invalid session token: {exc}",
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
async def verify_agent_token(
|
|
76
|
-
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
|
|
77
|
-
) -> str:
|
|
78
|
-
"""
|
|
79
|
-
Validates the agent JWT signed with AGENT_MASTER_PASSWORD.
|
|
80
|
-
HTCondor pool-password model — one shared secret for all agents.
|
|
81
|
-
"""
|
|
82
|
-
if not AGENT_MASTER_PASSWORD:
|
|
83
|
-
raise HTTPException(
|
|
84
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
85
|
-
detail="AGENT_MASTER_PASSWORD not configured.",
|
|
86
|
-
)
|
|
87
|
-
try:
|
|
88
|
-
payload = jwt.decode(
|
|
89
|
-
credentials.credentials,
|
|
90
|
-
AGENT_MASTER_PASSWORD,
|
|
91
|
-
algorithms=["HS256"],
|
|
92
|
-
)
|
|
93
|
-
return payload.get("sub", "unknown-agent")
|
|
94
|
-
except jwt.ExpiredSignatureError:
|
|
95
|
-
raise HTTPException(
|
|
96
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
97
|
-
detail="Agent token expired.",
|
|
98
|
-
)
|
|
99
|
-
except jwt.InvalidTokenError as exc:
|
|
100
|
-
raise HTTPException(
|
|
101
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
102
|
-
detail=f"Invalid agent token: {exc}",
|
|
103
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|