laniakea-api-server 0.0.1__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.0.1/.gitignore +8 -0
- laniakea_api_server-0.0.1/PKG-INFO +94 -0
- laniakea_api_server-0.0.1/README.md +61 -0
- laniakea_api_server-0.0.1/laniakea_api/__init__.py +5 -0
- laniakea_api_server-0.0.1/laniakea_api/auth.py +113 -0
- laniakea_api_server-0.0.1/laniakea_api/config.py +53 -0
- laniakea_api_server-0.0.1/laniakea_api/credential_parser.py +178 -0
- laniakea_api_server-0.0.1/laniakea_api/database.py +128 -0
- laniakea_api_server-0.0.1/laniakea_api/main.py +58 -0
- laniakea_api_server-0.0.1/laniakea_api/queue.py +97 -0
- laniakea_api_server-0.0.1/laniakea_api/routers/.health.py.swp +0 -0
- laniakea_api_server-0.0.1/laniakea_api/routers/__init__.py +0 -0
- laniakea_api_server-0.0.1/laniakea_api/routers/agent.py +98 -0
- laniakea_api_server-0.0.1/laniakea_api/routers/credentials.py +98 -0
- laniakea_api_server-0.0.1/laniakea_api/routers/deployments.py +145 -0
- laniakea_api_server-0.0.1/laniakea_api/routers/health.py +33 -0
- laniakea_api_server-0.0.1/pyproject.toml +55 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: laniakea-api-server
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Laniakea Queue API — OIDC-authenticated gateway for cloud deployment orchestration
|
|
5
|
+
Project-URL: Homepage, https://github.com/riccardocaccia/laniakea-api-redis
|
|
6
|
+
Project-URL: Issues, https://github.com/riccardocaccia/laniakea-api-redis/issues
|
|
7
|
+
Author: Laniakea Team
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: aws,cloud,deployment,fastapi,openstack,orchestration,redis
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: System :: Systems Administration
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: fastapi
|
|
21
|
+
Requires-Dist: httpx
|
|
22
|
+
Requires-Dist: hvac>=2.0.0
|
|
23
|
+
Requires-Dist: openstacksdk
|
|
24
|
+
Requires-Dist: psycopg2-binary
|
|
25
|
+
Requires-Dist: pydantic>=2.0
|
|
26
|
+
Requires-Dist: pyjwt
|
|
27
|
+
Requires-Dist: python-dotenv
|
|
28
|
+
Requires-Dist: pyyaml
|
|
29
|
+
Requires-Dist: redis
|
|
30
|
+
Requires-Dist: rq
|
|
31
|
+
Requires-Dist: uvicorn[standard]
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# laniakea-api-server
|
|
35
|
+
|
|
36
|
+
Laniakea Queue API — OIDC-authenticated gateway for cloud deployment orchestration.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install laniakea-api-server
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
Create a `.env` file:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Auth
|
|
50
|
+
SECRET_KEY=generate-with-python3-secrets-token-hex-32
|
|
51
|
+
SESSION_TTL_MINUTES=60
|
|
52
|
+
OIDC_DISCOVERY_URL=https://iam.your-provider.it/.well-known/openid-configuration
|
|
53
|
+
|
|
54
|
+
# Agent pool password
|
|
55
|
+
AGENT_MASTER_PASSWORD=generate-with-python3-secrets-token-hex-32
|
|
56
|
+
|
|
57
|
+
# Redis
|
|
58
|
+
REDIS_HOST=your-redis-host
|
|
59
|
+
REDIS_PORT=6379
|
|
60
|
+
REDIS_PASSWORD=your-redis-password
|
|
61
|
+
|
|
62
|
+
# PostgreSQL
|
|
63
|
+
PG_HOST=your-pg-host
|
|
64
|
+
PG_PORT=5432
|
|
65
|
+
PG_DATABASE=your-db-name
|
|
66
|
+
PG_USER=your-db-user
|
|
67
|
+
PG_PASSWORD=your-db-password
|
|
68
|
+
|
|
69
|
+
# Vault
|
|
70
|
+
VAULT_ADDR=https://your-vault:8200
|
|
71
|
+
VAULT_WRITER_TOKEN=hvs.xxxxxxxxxxxx
|
|
72
|
+
VAULT_TLS_VERIFY=false
|
|
73
|
+
|
|
74
|
+
# TLS (only needed with --ssl flag)
|
|
75
|
+
SSL_KEYFILE=certs/api.key
|
|
76
|
+
SSL_CERTFILE=certs/api.crt
|
|
77
|
+
|
|
78
|
+
# Logs
|
|
79
|
+
DEPLOYMENT_LOG_DIR=/var/log/laniakea-agent
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Usage
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# HTTP (testing)
|
|
86
|
+
laniakea-api --port 8000
|
|
87
|
+
|
|
88
|
+
# HTTPS (production)
|
|
89
|
+
laniakea-api --port 8443 --ssl
|
|
90
|
+
|
|
91
|
+
# Custom env file
|
|
92
|
+
laniakea-api --env /etc/laniakea/api.env --ssl
|
|
93
|
+
```
|
|
94
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# laniakea-api-server
|
|
2
|
+
|
|
3
|
+
Laniakea Queue API — OIDC-authenticated gateway for cloud deployment orchestration.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install laniakea-api-server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
Create a `.env` file:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Auth
|
|
17
|
+
SECRET_KEY=generate-with-python3-secrets-token-hex-32
|
|
18
|
+
SESSION_TTL_MINUTES=60
|
|
19
|
+
OIDC_DISCOVERY_URL=https://iam.your-provider.it/.well-known/openid-configuration
|
|
20
|
+
|
|
21
|
+
# Agent pool password
|
|
22
|
+
AGENT_MASTER_PASSWORD=generate-with-python3-secrets-token-hex-32
|
|
23
|
+
|
|
24
|
+
# Redis
|
|
25
|
+
REDIS_HOST=your-redis-host
|
|
26
|
+
REDIS_PORT=6379
|
|
27
|
+
REDIS_PASSWORD=your-redis-password
|
|
28
|
+
|
|
29
|
+
# PostgreSQL
|
|
30
|
+
PG_HOST=your-pg-host
|
|
31
|
+
PG_PORT=5432
|
|
32
|
+
PG_DATABASE=your-db-name
|
|
33
|
+
PG_USER=your-db-user
|
|
34
|
+
PG_PASSWORD=your-db-password
|
|
35
|
+
|
|
36
|
+
# Vault
|
|
37
|
+
VAULT_ADDR=https://your-vault:8200
|
|
38
|
+
VAULT_WRITER_TOKEN=hvs.xxxxxxxxxxxx
|
|
39
|
+
VAULT_TLS_VERIFY=false
|
|
40
|
+
|
|
41
|
+
# TLS (only needed with --ssl flag)
|
|
42
|
+
SSL_KEYFILE=certs/api.key
|
|
43
|
+
SSL_CERTFILE=certs/api.crt
|
|
44
|
+
|
|
45
|
+
# Logs
|
|
46
|
+
DEPLOYMENT_LOG_DIR=/var/log/laniakea-agent
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# HTTP (testing)
|
|
53
|
+
laniakea-api --port 8000
|
|
54
|
+
|
|
55
|
+
# HTTPS (production)
|
|
56
|
+
laniakea-api --port 8443 --ssl
|
|
57
|
+
|
|
58
|
+
# Custom env file
|
|
59
|
+
laniakea-api --env /etc/laniakea/api.env --ssl
|
|
60
|
+
```
|
|
61
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
all Pydantic models used across the api.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OIDCLoginRequest(BaseModel):
|
|
10
|
+
"""
|
|
11
|
+
Only the aai token is needed.
|
|
12
|
+
"""
|
|
13
|
+
oidc_token: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SessionTokenResponse(BaseModel):
|
|
17
|
+
"""
|
|
18
|
+
If the token check is OK returns:
|
|
19
|
+
"""
|
|
20
|
+
session_token: str
|
|
21
|
+
token_type: str = "bearer" # checks if the user has the correct permission
|
|
22
|
+
expires_in: int # sec.
|
|
23
|
+
user_info: dict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UserCredentials(BaseModel):
|
|
27
|
+
"""
|
|
28
|
+
Provider credentials associated with the user
|
|
29
|
+
Stored in Vault in secret/data/<sub>/credentials.
|
|
30
|
+
OpenStack: app_credentials or aai_token.
|
|
31
|
+
AWS: app_credentials
|
|
32
|
+
"""
|
|
33
|
+
# NOTE: now no credentials is essential. Consider changing this logic
|
|
34
|
+
# OpenStack
|
|
35
|
+
openstack_ssh_key: Optional[str] = None
|
|
36
|
+
openstack_app_credential_id: Optional[str] = None
|
|
37
|
+
openstack_app_credential_secret: Optional[str] = None
|
|
38
|
+
openstack_proxy_host: Optional[str] = None
|
|
39
|
+
openstack_auth_url: Optional[str] = None
|
|
40
|
+
openstack_region_name: Optional[str] = None
|
|
41
|
+
openstack_interface: Optional[str] = None
|
|
42
|
+
openstack_identity_api_version: Optional[str] = None
|
|
43
|
+
# AWS
|
|
44
|
+
aws_ssh_key: Optional[str] = None #NOTE:mmmm I already have one in openstack, change key name ecc
|
|
45
|
+
aws_access_key: Optional[str] = None
|
|
46
|
+
aws_secret_key: Optional[str] = None
|
|
47
|
+
aws_bastion_ip: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DeploymentRequest(BaseModel):
|
|
51
|
+
"""
|
|
52
|
+
Full deployment configuration (deployment_info.json), matching the structure used by workers.
|
|
53
|
+
The auth.aai_token field carries the OIDC token so that workers can exchange it for a keystone token
|
|
54
|
+
token when they process the job.
|
|
55
|
+
"""
|
|
56
|
+
deployment_uuid: str # NOTE: the dashboard needs to create a uuid for each job
|
|
57
|
+
timestamp: str
|
|
58
|
+
description: str # optional or mandatory? check teams
|
|
59
|
+
auth: dict # { aai_token, sub, group }
|
|
60
|
+
orchestrator: dict # target_provider, desired_orchestrator, endpoint
|
|
61
|
+
selected_provider: str # OpenStack | AWS
|
|
62
|
+
cloud_providers: dict
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class JobResponse(BaseModel):
|
|
66
|
+
job_id: str
|
|
67
|
+
queue_name: str
|
|
68
|
+
deployment_uuid: str
|
|
69
|
+
status: str
|
|
70
|
+
message: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class StatusUpdateRequest(BaseModel):
|
|
74
|
+
"""
|
|
75
|
+
Payload sent by the agent to update a deployment status.
|
|
76
|
+
Only the agent (authenticated via mTLS client cert) can call PATCH /internal/...
|
|
77
|
+
"""
|
|
78
|
+
status: str
|
|
79
|
+
status_reason: Optional[str] = None
|
|
80
|
+
outputs: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LogLineRequest(BaseModel):
|
|
84
|
+
"""
|
|
85
|
+
A single log line pushed by the agent.
|
|
86
|
+
The API appends it to logs/orchestrator-{uuid}.log on the API VM.
|
|
87
|
+
"""
|
|
88
|
+
level: str # INFO, ERROR, WARNING, ...
|
|
89
|
+
message: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CredentialTestRequest(BaseModel):
|
|
93
|
+
"""
|
|
94
|
+
Tests users app credential communicating with OpenStack.
|
|
95
|
+
Object used in credential.py
|
|
96
|
+
"""
|
|
97
|
+
os_auth_url: str
|
|
98
|
+
os_application_credential_id: str
|
|
99
|
+
os_application_credential_secret: str
|
|
100
|
+
os_region_name: str = "RegionOne"
|
|
101
|
+
os_interface: str = "public"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CredentialTestResponse(BaseModel):
|
|
105
|
+
"""
|
|
106
|
+
Give back the check over the app credential validity, after
|
|
107
|
+
the user requests the test on the Dashboard.
|
|
108
|
+
"""
|
|
109
|
+
success: bool
|
|
110
|
+
message: str
|
|
111
|
+
server_count: int = 0
|
|
112
|
+
detail: str = ""
|
|
113
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Import the .env
|
|
3
|
+
Inizialize all the service: Redis, Vault, Posgre
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
# Authentication
|
|
9
|
+
SECRET_KEY = os.getenv("SECRET_KEY", "")
|
|
10
|
+
ALGORITHM = "HS256"
|
|
11
|
+
SESSION_TTL_MINUTES = int(os.getenv("SESSION_TTL_MINUTES", "60")) # NOTE: maybe longer
|
|
12
|
+
OIDC_DISCOVERY_URL = os.getenv("OIDC_DISCOVERY_URL", "")
|
|
13
|
+
|
|
14
|
+
# Agent pool password
|
|
15
|
+
# to revoke ALL agents: change this value and restart API + all agents.
|
|
16
|
+
AGENT_MASTER_PASSWORD = os.getenv("AGENT_MASTER_PASSWORD", "")
|
|
17
|
+
|
|
18
|
+
# Redis
|
|
19
|
+
REDIS_HOST = os.getenv("REDIS_HOST", "")
|
|
20
|
+
# NOTE: Idk port
|
|
21
|
+
REDIS_PORT = int(os.getenv("REDIS_PORT", "1908"))
|
|
22
|
+
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
|
|
23
|
+
|
|
24
|
+
# vault
|
|
25
|
+
VAULT_ADDR = os.getenv("VAULT_ADDR", "")
|
|
26
|
+
VAULT_WRITER_TOKEN = os.getenv("VAULT_WRITER_TOKEN", "")
|
|
27
|
+
VAULT_TLS_VERIFY = os.getenv("VAULT_TLS_VERIFY", "false").lower() == "true"
|
|
28
|
+
VAULT_MOUNT = "secret"
|
|
29
|
+
|
|
30
|
+
# PostgreSQL
|
|
31
|
+
PG_HOST = os.getenv("PG_HOST", "localhost")
|
|
32
|
+
PG_PORT = int(os.getenv("PG_PORT", "5432"))
|
|
33
|
+
PG_DATABASE = os.getenv("PG_DATABASE", "")
|
|
34
|
+
PG_USER = os.getenv("PG_USER", "")
|
|
35
|
+
PG_PASSWORD = os.getenv("PG_PASSWORD", "")
|
|
36
|
+
|
|
37
|
+
# Deployment logs
|
|
38
|
+
# One file per deployment: terraform_{uuid}.log
|
|
39
|
+
# Agent pushes lines via POST /internal/deployments/{uuid}/logs.
|
|
40
|
+
# Dashboard reads via GET /api/deployments/{uuid}/logs.
|
|
41
|
+
LOG_DIR = os.getenv("DEPLOYMENT_LOG_DIR", "/var/log/laniakea-agent")
|
|
42
|
+
|
|
43
|
+
# NOTE: UPDATE.. still not used
|
|
44
|
+
# deployment status
|
|
45
|
+
VALID_STATUSES = {
|
|
46
|
+
"QUEUED",
|
|
47
|
+
"CREATE_IN_PROGRESS",
|
|
48
|
+
"CREATE_COMPLETE",
|
|
49
|
+
"CREATE_FAILED",
|
|
50
|
+
"UPDATE_IN_PROGRESS",
|
|
51
|
+
"UPDATE_FAILED",
|
|
52
|
+
}
|
|
53
|
+
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parses OpenStack credential files (.sh RC file or clouds.yaml) and returns
|
|
3
|
+
a structured dict ready to be sent to POST /profile/credentials.
|
|
4
|
+
|
|
5
|
+
Supports:
|
|
6
|
+
- RC file (export OS_APPLICATION_CREDENTIAL_ID=xxx ...)
|
|
7
|
+
- clouds.yaml (single or multi-cloud)
|
|
8
|
+
|
|
9
|
+
Standalone usage (TEST ONLY):
|
|
10
|
+
python3 credential_parser.py openrc.sh
|
|
11
|
+
python3 credential_parser.py clouds.yaml
|
|
12
|
+
python3 credential_parser.py clouds.yaml --cloud openstack
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import yaml
|
|
25
|
+
HAS_YAML = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
HAS_YAML = False
|
|
28
|
+
|
|
29
|
+
# RC file parser
|
|
30
|
+
# Maps RC env var names needed for internal credential field names
|
|
31
|
+
_RC_FIELD_MAP = {
|
|
32
|
+
"OS_AUTH_URL": "openstack_auth_url",
|
|
33
|
+
"OS_APPLICATION_CREDENTIAL_ID": "openstack_app_credential_id",
|
|
34
|
+
"OS_APPLICATION_CREDENTIAL_SECRET": "openstack_app_credential_secret",
|
|
35
|
+
"OS_REGION_NAME": "openstack_region_name",
|
|
36
|
+
"OS_INTERFACE": "openstack_interface",
|
|
37
|
+
"OS_IDENTITY_API_VERSION": "openstack_identity_api_version",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def _parse_rc_file(path: Path) -> dict:
|
|
41
|
+
"""
|
|
42
|
+
Parse a bash RC file like:
|
|
43
|
+
export OS_AUTH_URL=https://keystone...
|
|
44
|
+
export OS_APPLICATION_CREDENTIAL_ID=abc123
|
|
45
|
+
"""
|
|
46
|
+
result = {}
|
|
47
|
+
# matches: export KEY=value or KEY=value (with optional ecport and quotes)
|
|
48
|
+
pattern = re.compile(r"""^\s*(?:export\s+)?(\w+)=["\']?([^"\';\n]*)["\']?\s*$""")
|
|
49
|
+
|
|
50
|
+
for line in path.read_text().splitlines():
|
|
51
|
+
m = pattern.match(line)
|
|
52
|
+
# if the line matches the schema it continues putting it inside the result dict
|
|
53
|
+
if not m:
|
|
54
|
+
continue
|
|
55
|
+
env_key, value = m.group(1).strip(), m.group(2).strip()
|
|
56
|
+
field = _RC_FIELD_MAP.get(env_key)
|
|
57
|
+
if field and value:
|
|
58
|
+
result[field] = value
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
#clouds.yaml parser
|
|
62
|
+
|
|
63
|
+
def _parse_clouds_yaml(path: Path, cloud_name: Optional[str] = None) -> dict:
|
|
64
|
+
"""
|
|
65
|
+
Parse a clouds.yaml file.
|
|
66
|
+
If cloud_name is None and there is only one cloud entry, use that one.
|
|
67
|
+
"""
|
|
68
|
+
# NOTE: put it in the requirement
|
|
69
|
+
if not HAS_YAML:
|
|
70
|
+
raise ImportError("PyYAML is required to parse clouds.yaml. Run: pip install pyyaml")
|
|
71
|
+
|
|
72
|
+
raw = yaml.safe_load(path.read_text()) # from yaml to python dict
|
|
73
|
+
clouds = raw.get("clouds", {})
|
|
74
|
+
|
|
75
|
+
if not clouds:
|
|
76
|
+
raise ValueError("No 'clouds' section found in the file.")
|
|
77
|
+
|
|
78
|
+
# auto-select if only one cloud
|
|
79
|
+
# if user hasn't specifyed with parser
|
|
80
|
+
# NOTE: to be removed outside test
|
|
81
|
+
if cloud_name is None:
|
|
82
|
+
if len(clouds) == 1:
|
|
83
|
+
cloud_name = next(iter(clouds))
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Multiple clouds found: {list(clouds.keys())}. "
|
|
87
|
+
"Specify one with --cloud <name>."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if cloud_name not in clouds:
|
|
91
|
+
raise ValueError(f"Cloud '{cloud_name}' not found. Available: {list(clouds.keys())}")
|
|
92
|
+
|
|
93
|
+
entry = clouds[cloud_name]
|
|
94
|
+
auth = entry.get("auth", {})
|
|
95
|
+
|
|
96
|
+
result = {}
|
|
97
|
+
|
|
98
|
+
# auth block
|
|
99
|
+
# add fields to the result
|
|
100
|
+
if auth.get("auth_url"):
|
|
101
|
+
result["openstack_auth_url"] = auth["auth_url"]
|
|
102
|
+
if auth.get("application_credential_id"):
|
|
103
|
+
result["openstack_app_credential_id"] = auth["application_credential_id"]
|
|
104
|
+
if auth.get("application_credential_secret"):
|
|
105
|
+
result["openstack_app_credential_secret"] = auth["application_credential_secret"]
|
|
106
|
+
if entry.get("region_name"):
|
|
107
|
+
result["openstack_region_name"] = entry["region_name"]
|
|
108
|
+
if entry.get("interface"):
|
|
109
|
+
result["openstack_interface"] = entry["interface"]
|
|
110
|
+
if entry.get("identity_api_version"):
|
|
111
|
+
result["openstack_identity_api_version"] = str(entry["identity_api_version"])
|
|
112
|
+
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
# Public API
|
|
116
|
+
|
|
117
|
+
def parse_credential_file(path: str, cloud_name: Optional[str] = None) -> dict:
|
|
118
|
+
"""
|
|
119
|
+
Auto-detect file type (.sh / .yaml / .yml) and return a dict of credentials.
|
|
120
|
+
|
|
121
|
+
Returns dict with any of:
|
|
122
|
+
openstack_auth_url
|
|
123
|
+
openstack_app_credential_id
|
|
124
|
+
openstack_app_credential_secret
|
|
125
|
+
openstack_region_name
|
|
126
|
+
openstack_interface
|
|
127
|
+
openstack_identity_api_version
|
|
128
|
+
"""
|
|
129
|
+
p = Path(path)
|
|
130
|
+
if not p.exists():
|
|
131
|
+
raise FileNotFoundError(f"Credential file not found: {path}")
|
|
132
|
+
|
|
133
|
+
suffix = p.suffix.lower()
|
|
134
|
+
|
|
135
|
+
if suffix in (".yaml", ".yml"):
|
|
136
|
+
creds = _parse_clouds_yaml(p, cloud_name)
|
|
137
|
+
elif suffix in (".sh", ".env", ""):
|
|
138
|
+
creds = _parse_rc_file(p)
|
|
139
|
+
else:
|
|
140
|
+
# try RC first, fall back to YAML
|
|
141
|
+
try:
|
|
142
|
+
creds = _parse_rc_file(p)
|
|
143
|
+
if not creds:
|
|
144
|
+
raise ValueError("No RC fields found")
|
|
145
|
+
except Exception:
|
|
146
|
+
creds = _parse_clouds_yaml(p, cloud_name)
|
|
147
|
+
|
|
148
|
+
if not creds:
|
|
149
|
+
raise ValueError(f"No recognizable OpenStack credentials found in '{path}'.")
|
|
150
|
+
|
|
151
|
+
return creds
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _print_parsed(creds: dict) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Print the parsed credential masking ids and secrets.
|
|
157
|
+
"""
|
|
158
|
+
print("\nParsed credentials:")
|
|
159
|
+
for k, v in creds.items():
|
|
160
|
+
# mask the secret
|
|
161
|
+
display = v[:4] + "***" if ("secret" in k or "id" in k) in k and len(v) > 4 else v
|
|
162
|
+
print(f" {k:<45} {display}")
|
|
163
|
+
print()
|
|
164
|
+
|
|
165
|
+
# main block
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
parser = argparse.ArgumentParser(description="Parse OpenStack RC or clouds.yaml credential file")
|
|
168
|
+
parser.add_argument("file", help="Path to the RC file (.sh) or clouds.yaml")
|
|
169
|
+
parser.add_argument("--cloud", default=None, help="Cloud name to use (clouds.yaml only)")
|
|
170
|
+
args = parser.parse_args()
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
creds = parse_credential_file(args.file, cloud_name=args.cloud)
|
|
174
|
+
_print_parsed(creds)
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostgreSQL connection.
|
|
3
|
+
The agent has NO direct DB access, **all writes go through the API**
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional
|
|
9
|
+
import psycopg2
|
|
10
|
+
from psycopg2.extras import RealDictCursor
|
|
11
|
+
from laniakea_api.config import PG_HOST, PG_PORT, PG_DATABASE, PG_USER, PG_PASSWORD
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_conn():
|
|
15
|
+
"""
|
|
16
|
+
Open and return a new PostgreSQL connection.
|
|
17
|
+
"""
|
|
18
|
+
# act over config.py before changing here
|
|
19
|
+
return psycopg2.connect(
|
|
20
|
+
host=PG_HOST,
|
|
21
|
+
port=PG_PORT,
|
|
22
|
+
database=PG_DATABASE,
|
|
23
|
+
user=PG_USER,
|
|
24
|
+
password=PG_PASSWORD,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def create_deployment(
|
|
28
|
+
uuid: str, user_sub: str, username: str, description: str, provider: str, requested_at: datetime,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Insert a new deployment row with status QUEUED.
|
|
32
|
+
Called by the API the moment a job is accepted,
|
|
33
|
+
(db before redis)
|
|
34
|
+
"""
|
|
35
|
+
conn = get_conn()
|
|
36
|
+
try:
|
|
37
|
+
with conn.cursor() as cur:
|
|
38
|
+
cur.execute(
|
|
39
|
+
"""
|
|
40
|
+
INSERT INTO users (sub, username, email, role, active)
|
|
41
|
+
VALUES (%s, %s, %s, 'user', true)
|
|
42
|
+
ON CONFLICT (sub) DO NOTHING
|
|
43
|
+
""",
|
|
44
|
+
(user_sub, username, ""),
|
|
45
|
+
)
|
|
46
|
+
cur.execute(
|
|
47
|
+
"""
|
|
48
|
+
INSERT INTO deployments (
|
|
49
|
+
uuid, status, creation_time, update_time,
|
|
50
|
+
description, provider_name, sub
|
|
51
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
52
|
+
ON CONFLICT (uuid) DO UPDATE
|
|
53
|
+
SET status = EXCLUDED.status,
|
|
54
|
+
update_time = EXCLUDED.update_time
|
|
55
|
+
""",
|
|
56
|
+
(uuid, "QUEUED", requested_at, requested_at, description, provider, user_sub),
|
|
57
|
+
)
|
|
58
|
+
conn.commit()
|
|
59
|
+
finally:
|
|
60
|
+
conn.close()
|
|
61
|
+
|
|
62
|
+
def update_status(
|
|
63
|
+
uuid: str, new_status: str, status_reason: Optional[str] = None, outputs: Optional[str] = None,
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""
|
|
66
|
+
Update the status of an existing deployment.
|
|
67
|
+
Returns True if a row was updated, False if uuid not found.
|
|
68
|
+
"""
|
|
69
|
+
conn = get_conn()
|
|
70
|
+
try:
|
|
71
|
+
with conn.cursor() as cur:
|
|
72
|
+
cur.execute(
|
|
73
|
+
"""
|
|
74
|
+
UPDATE deployments
|
|
75
|
+
SET status = %s,
|
|
76
|
+
status_reason = COALESCE(%s, status_reason),
|
|
77
|
+
outputs = COALESCE(%s, outputs),
|
|
78
|
+
update_time = %s
|
|
79
|
+
WHERE uuid = %s
|
|
80
|
+
""",
|
|
81
|
+
(new_status, status_reason, outputs, datetime.utcnow(), uuid),
|
|
82
|
+
)
|
|
83
|
+
updated = cur.rowcount > 0
|
|
84
|
+
conn.commit()
|
|
85
|
+
return updated
|
|
86
|
+
finally:
|
|
87
|
+
conn.close()
|
|
88
|
+
|
|
89
|
+
def get_deployment(uuid: str) -> Optional[dict]:
|
|
90
|
+
"""
|
|
91
|
+
Fetch a single deployment row by uuid. Returns None if not found.
|
|
92
|
+
"""
|
|
93
|
+
conn = get_conn()
|
|
94
|
+
try:
|
|
95
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
96
|
+
cur.execute("SELECT * FROM deployments WHERE uuid = %s", (uuid,))
|
|
97
|
+
row = cur.fetchone()
|
|
98
|
+
return dict(row) if row else None
|
|
99
|
+
finally:
|
|
100
|
+
conn.close()
|
|
101
|
+
|
|
102
|
+
def list_deployments(user_sub: str) -> list:
|
|
103
|
+
"""
|
|
104
|
+
Fetch all deployments owned by a user, ordered by creation time desc.
|
|
105
|
+
"""
|
|
106
|
+
conn = get_conn()
|
|
107
|
+
try:
|
|
108
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
109
|
+
cur.execute(
|
|
110
|
+
"SELECT * FROM deployments WHERE sub = %s ORDER BY creation_time DESC",
|
|
111
|
+
(user_sub,),
|
|
112
|
+
)
|
|
113
|
+
return [dict(r) for r in cur.fetchall()]
|
|
114
|
+
finally:
|
|
115
|
+
conn.close()
|
|
116
|
+
|
|
117
|
+
def check_connection() -> str:
|
|
118
|
+
"""
|
|
119
|
+
Returns 'connected' or an error string.
|
|
120
|
+
Used by /health.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
conn = get_conn()
|
|
124
|
+
conn.close()
|
|
125
|
+
return "connected"
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
return f"error: {exc}"
|
|
128
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Registers all routers and starts uvicorn
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import uvicorn
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from laniakea_api.routers import agent, credentials, deployments, health
|
|
9
|
+
|
|
10
|
+
# FastAPI App
|
|
11
|
+
|
|
12
|
+
app = FastAPI(
|
|
13
|
+
title="Laniakea Queue API",
|
|
14
|
+
description="OIDC-authenticated gateway for enqueuing cloud deployment jobs.",
|
|
15
|
+
version="1.4.0",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# NOTE: CHANGE HERE for path
|
|
19
|
+
# Route prefixes
|
|
20
|
+
BASE = "/laniakea_core/v1.0"
|
|
21
|
+
INTERNAL = BASE + "/internal"
|
|
22
|
+
|
|
23
|
+
app.include_router(credentials.router, prefix=BASE)
|
|
24
|
+
app.include_router(deployments.router, prefix=BASE)
|
|
25
|
+
app.include_router(agent.router, prefix=INTERNAL)
|
|
26
|
+
app.include_router(health.router) # /health:no prefix
|
|
27
|
+
|
|
28
|
+
############ Routes registered ############################
|
|
29
|
+
#NOTE: Add or remove every new modification
|
|
30
|
+
#
|
|
31
|
+
# Public (dashboard):
|
|
32
|
+
# POST /laniakea_core/v1.0/auth/oidc
|
|
33
|
+
# POST /laniakea_core/v1.0/profile/credentials
|
|
34
|
+
# POST /laniakea_core/v1.0/profile/credentials/test
|
|
35
|
+
# POST /laniakea_core/v1.0/api/deployments
|
|
36
|
+
# GET /laniakea_core/v1.0/api/deployments
|
|
37
|
+
# GET /laniakea_core/v1.0/api/deployments/{uuid}
|
|
38
|
+
# GET /laniakea_core/v1.0/api/deployments/{uuid}/logs
|
|
39
|
+
#
|
|
40
|
+
# Internal (agent only):
|
|
41
|
+
# PATCH /laniakea_core/v1.0/internal/deployments/{uuid}/status
|
|
42
|
+
# POST /laniakea_core/v1.0/internal/deployments/{uuid}/logs
|
|
43
|
+
#
|
|
44
|
+
# Monitoring:
|
|
45
|
+
# GET /health
|
|
46
|
+
|
|
47
|
+
# Entry point uvicorn
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
uvicorn.run(
|
|
50
|
+
"main:app",
|
|
51
|
+
host="0.0.0.0",
|
|
52
|
+
port=8443,
|
|
53
|
+
ssl_keyfile=os.getenv("SSL_KEYFILE", "certs/api.key"),
|
|
54
|
+
ssl_certfile=os.getenv("SSL_CERTFILE", "certs/api.crt"),
|
|
55
|
+
log_level="info",
|
|
56
|
+
reload=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redis connection and RQ queue setup
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hvac
|
|
6
|
+
from fastapi import HTTPException, status
|
|
7
|
+
from redis import Redis
|
|
8
|
+
from rq import Queue
|
|
9
|
+
from config import (REDIS_HOST, REDIS_PORT, REDIS_PASSWORD,
|
|
10
|
+
VAULT_ADDR, VAULT_WRITER_TOKEN, VAULT_TLS_VERIFY, VAULT_MOUNT,)
|
|
11
|
+
|
|
12
|
+
# Redis
|
|
13
|
+
redis_conn = Redis(
|
|
14
|
+
host=REDIS_HOST,
|
|
15
|
+
port=REDIS_PORT,
|
|
16
|
+
password=REDIS_PASSWORD,
|
|
17
|
+
decode_responses=False, # RQ uses binary pickle
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# NOTE: need a final decision over the queues names
|
|
21
|
+
queues: dict = {
|
|
22
|
+
"openstack": Queue("openstack", connection=redis_conn),
|
|
23
|
+
"aws": Queue("aws", connection=redis_conn),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# NOTE: is this necessary? check
|
|
27
|
+
PROVIDER_TO_QUEUE: dict = {
|
|
28
|
+
"openstack": "openstack",
|
|
29
|
+
"Openstack": "openstack",
|
|
30
|
+
"OpenStack": "openstack",
|
|
31
|
+
"aws": "aws",
|
|
32
|
+
"AWS": "aws",
|
|
33
|
+
"Aws": "aws",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def get_queue(provider: str) -> tuple:
|
|
37
|
+
"""
|
|
38
|
+
Returns (queue_name, Queue) for the given provider string.
|
|
39
|
+
Raises HTTP 400 if the provider is not supported.
|
|
40
|
+
"""
|
|
41
|
+
queue_name = PROVIDER_TO_QUEUE.get(provider)
|
|
42
|
+
if not queue_name:
|
|
43
|
+
raise HTTPException(
|
|
44
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
45
|
+
detail=f"Unsupported provider '{provider}'. Supported: {list(PROVIDER_TO_QUEUE.keys())}",
|
|
46
|
+
)
|
|
47
|
+
return queue_name, queues[queue_name]
|
|
48
|
+
|
|
49
|
+
def check_redis() -> str:
|
|
50
|
+
"""
|
|
51
|
+
Returns 'connected' or an error string.
|
|
52
|
+
Used by /health.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
redis_conn.ping()
|
|
56
|
+
return "connected"
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
return f"error: {exc}"
|
|
59
|
+
|
|
60
|
+
# Vault
|
|
61
|
+
# see config.py
|
|
62
|
+
vault_client = hvac.Client(
|
|
63
|
+
url=VAULT_ADDR,
|
|
64
|
+
token=VAULT_WRITER_TOKEN,
|
|
65
|
+
verify=VAULT_TLS_VERIFY,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def vault_write_credentials(user_sub: str, creds: dict) -> str:
|
|
69
|
+
"""
|
|
70
|
+
Write user credentials to Vault under secret/data/<sub>/credentials.
|
|
71
|
+
Returns the vault path on success; raises HTTP 502 on failure.
|
|
72
|
+
"""
|
|
73
|
+
vault_path = f"{user_sub}/credentials"
|
|
74
|
+
try:
|
|
75
|
+
vault_client.secrets.kv.v2.create_or_update_secret(
|
|
76
|
+
path=vault_path,
|
|
77
|
+
secret=creds,
|
|
78
|
+
mount_point=VAULT_MOUNT,
|
|
79
|
+
)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
83
|
+
detail=f"Vault write failed: {exc}",
|
|
84
|
+
)
|
|
85
|
+
return vault_path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_vault() -> str:
|
|
89
|
+
"""
|
|
90
|
+
Returns 'authenticated' or an error string.
|
|
91
|
+
Used by /health.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
return "authenticated" if vault_client.is_authenticated() else "unauthenticated"
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
return f"error: {exc}"
|
|
97
|
+
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
internal endpoints called exclusively by laniakea-agent.
|
|
3
|
+
|
|
4
|
+
PATCH /internal/deployments/{uuid}/status
|
|
5
|
+
POST /internal/deployments/{uuid}/logs
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
11
|
+
import database as db
|
|
12
|
+
from laniakea_api.auth import verify_agent_token
|
|
13
|
+
from laniakea_api.config import VALID_STATUSES, LOG_DIR
|
|
14
|
+
from laniakea_api.models import StatusUpdateRequest, LogLineRequest
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
def _validate_transition(current: str, new_status: str, uuid: str) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Raise 409 Conflict if the requested state transition is not allowed
|
|
21
|
+
"""
|
|
22
|
+
allowed = {
|
|
23
|
+
"QUEUED": {"CREATE_IN_PROGRESS", "UPDATE_IN_PROGRESS"},
|
|
24
|
+
"CREATE_IN_PROGRESS": {"CREATE_COMPLETE", "CREATE_FAILED"},
|
|
25
|
+
"UPDATE_IN_PROGRESS": {"UPDATE_FAILED"},
|
|
26
|
+
"CREATE_COMPLETE": set(),
|
|
27
|
+
"CREATE_FAILED": set(),
|
|
28
|
+
"UPDATE_FAILED": set(),}
|
|
29
|
+
|
|
30
|
+
permitted = allowed.get(current, set())
|
|
31
|
+
if new_status not in permitted:
|
|
32
|
+
raise HTTPException(
|
|
33
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
34
|
+
detail=(
|
|
35
|
+
f"Deployment {uuid}: transition {current!r} -> {new_status!r} is not allowed. "
|
|
36
|
+
f"Permitted next states: {sorted(permitted) or 'none (terminal state)'}."
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.patch("/deployments/{uuid}/status")
|
|
42
|
+
async def agent_update_status(
|
|
43
|
+
uuid: str, body: StatusUpdateRequest, agent_id: str = Depends(verify_agent_token),):
|
|
44
|
+
"""
|
|
45
|
+
Called exclusively by laniakea-agent to transition a deployment status.
|
|
46
|
+
|
|
47
|
+
Auth: the agent sends a short-lived token signed with AGENT_MASTER_PASSWORD.
|
|
48
|
+
No client certificates needed.
|
|
49
|
+
|
|
50
|
+
If the token is invalid (wrong/rotated password) the API updates the
|
|
51
|
+
deployment to CREATE_FAILED before returning 401, so the dashboard
|
|
52
|
+
always shows a meaningful state instead of QUEUED forever.
|
|
53
|
+
"""
|
|
54
|
+
new_status = body.status.upper()
|
|
55
|
+
if new_status not in VALID_STATUSES:
|
|
56
|
+
raise HTTPException(
|
|
57
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
58
|
+
detail=f"Unknown status '{new_status}'. Valid: {sorted(VALID_STATUSES)}",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
row = db.get_deployment(uuid)
|
|
62
|
+
if row is None:
|
|
63
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Deployment {uuid} not found.")
|
|
64
|
+
|
|
65
|
+
current = row.get("status", "")
|
|
66
|
+
_validate_transition(current, new_status, uuid)
|
|
67
|
+
|
|
68
|
+
updated = db.update_status(
|
|
69
|
+
uuid=uuid,
|
|
70
|
+
new_status=new_status,
|
|
71
|
+
status_reason=body.status_reason,
|
|
72
|
+
outputs=body.outputs,
|
|
73
|
+
)
|
|
74
|
+
if not updated:
|
|
75
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="DB update failed.")
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"deployment_uuid": uuid,
|
|
79
|
+
"previous_status": current,
|
|
80
|
+
"new_status": new_status,
|
|
81
|
+
"updated_by": agent_id,
|
|
82
|
+
"updated_at": datetime.utcnow().isoformat(),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@router.post("/deployments/{uuid}/logs", status_code=204)
|
|
86
|
+
async def agent_push_log(
|
|
87
|
+
uuid: str, body: LogLineRequest,agent_id: str = Depends(verify_agent_token),):
|
|
88
|
+
"""
|
|
89
|
+
Receive a single log line from the agent and append it to
|
|
90
|
+
/var/log/laniakea-agent/terraform_{uuid}.log on the API VM.
|
|
91
|
+
The dashboard reads these logs via GET /api/deployments/{uuid}/logs.
|
|
92
|
+
"""
|
|
93
|
+
os.makedirs(LOG_DIR, exist_ok=True)
|
|
94
|
+
log_path = os.path.join(LOG_DIR, f"terraform_{uuid}.log")
|
|
95
|
+
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
|
96
|
+
line = f"{timestamp} [{body.level}] {body.message}\n"
|
|
97
|
+
with open(log_path, "a") as f:
|
|
98
|
+
f.write(line)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
User credential management.
|
|
3
|
+
|
|
4
|
+
POST /auth/oidc
|
|
5
|
+
POST /profile/credentials
|
|
6
|
+
POST /profile/credentials/test
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
10
|
+
from laniakea_api.auth import fetch_userinfo, create_session_token, verify_session_token
|
|
11
|
+
from laniakea_api.models import (OIDCLoginRequest, SessionTokenResponse,UserCredentials,
|
|
12
|
+
CredentialTestRequest, CredentialTestResponse,)
|
|
13
|
+
from laniakea_api.queue import vault_write_credentials, VAULT_MOUNT
|
|
14
|
+
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
@router.post("/auth/oidc", response_model=SessionTokenResponse)
|
|
18
|
+
async def login_oidc(req: OIDCLoginRequest):
|
|
19
|
+
"""
|
|
20
|
+
exchange a valid OIDC access token for a short-lived API session token.
|
|
21
|
+
|
|
22
|
+
Flow:
|
|
23
|
+
1. Caller authenticates with the OIDC provider and obtains an access token.
|
|
24
|
+
2. POST /auth/oidc { "oidc_token": "<access_token>" }
|
|
25
|
+
3. This endpoint verifies the token against the provider's userinfo endpoint.
|
|
26
|
+
4. On success, returns a signed JWT session token
|
|
27
|
+
5. Use the session token as Bearer on all subsequent requests.
|
|
28
|
+
"""
|
|
29
|
+
user_info = await fetch_userinfo(req.oidc_token)
|
|
30
|
+
session_token, expires_in = create_session_token(user_info)
|
|
31
|
+
return SessionTokenResponse(session_token=session_token, expires_in=expires_in,
|
|
32
|
+
# Add here additional information to include in the token
|
|
33
|
+
user_info={
|
|
34
|
+
"sub": user_info.get("sub"),
|
|
35
|
+
"username": user_info.get("preferred_username"),
|
|
36
|
+
"email": user_info.get("email"),
|
|
37
|
+
"groups": user_info.get("groups", []),
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@router.post("/profile/credentials", status_code=201)
|
|
42
|
+
async def save_credentials(creds: UserCredentials, caller: dict = Depends(verify_session_token),):
|
|
43
|
+
"""
|
|
44
|
+
Save or update provider credentials for the authenticated user in Vault
|
|
45
|
+
"""
|
|
46
|
+
secret_data = {k: v for k, v in creds.model_dump().items() if v is not None}
|
|
47
|
+
if not secret_data:
|
|
48
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No credentials provided.",)
|
|
49
|
+
vault_path = vault_write_credentials(caller["sub"], secret_data)
|
|
50
|
+
return {
|
|
51
|
+
"message": "Credentials saved to Vault.",
|
|
52
|
+
"vault_path": f"{VAULT_MOUNT}/data/{vault_path}",
|
|
53
|
+
"user": caller["username"],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# TEST CREDENTIALS
|
|
57
|
+
@router.post("/profile/credentials/test", response_model=CredentialTestResponse,
|
|
58
|
+
summary="Test OpenStack application credentials",
|
|
59
|
+
description=(
|
|
60
|
+
"Attempts to list servers on OpenStack using the provided app credentials. "
|
|
61
|
+
"Equivalent to running `openstack server list` on the CLI."),
|
|
62
|
+
)
|
|
63
|
+
async def test_openstack_credentials(body: CredentialTestRequest,
|
|
64
|
+
caller: dict = Depends(verify_session_token),):
|
|
65
|
+
"""
|
|
66
|
+
Test OpenStack application credentials without storing them
|
|
67
|
+
|
|
68
|
+
Flow:
|
|
69
|
+
1. Build an OpenStack connection with the provided credentials.
|
|
70
|
+
2. Call conn.compute.servers(), equivalent to *openstack server list*
|
|
71
|
+
3. If it returns (even empty) credentials are valid.
|
|
72
|
+
4. If it raises credentials are invalid or endpoint unreachable.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
import openstack
|
|
76
|
+
conn = openstack.connect(
|
|
77
|
+
auth_url=body.os_auth_url,
|
|
78
|
+
auth_type="v3applicationcredential",
|
|
79
|
+
application_credential_id=body.os_application_credential_id,
|
|
80
|
+
application_credential_secret=body.os_application_credential_secret,
|
|
81
|
+
region_name=body.os_region_name,
|
|
82
|
+
interface=body.os_interface,
|
|
83
|
+
identity_api_version=3,
|
|
84
|
+
)
|
|
85
|
+
servers = list(conn.compute.servers())
|
|
86
|
+
#NOTE: now returns a printed message
|
|
87
|
+
return CredentialTestResponse(
|
|
88
|
+
success=True,
|
|
89
|
+
message=f"Credentials are valid. {len(servers)} server(s) visible in region {body.os_region_name}.",
|
|
90
|
+
server_count=len(servers),)
|
|
91
|
+
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
return CredentialTestResponse(
|
|
94
|
+
success=False,
|
|
95
|
+
message="Could not authenticate to OpenStack, check credentials and auth_url.",
|
|
96
|
+
detail=str(exc),
|
|
97
|
+
)
|
|
98
|
+
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
user deployment endpoints:
|
|
3
|
+
|
|
4
|
+
GET /api/deployments
|
|
5
|
+
GET /api/deployments/{uuid}
|
|
6
|
+
POST /api/deployments
|
|
7
|
+
GET /api/deployments/{uuid}/logs
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import os
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Optional
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
15
|
+
import database as db
|
|
16
|
+
from laniakea_api.auth import verify_session_token
|
|
17
|
+
from laniakea_api.config import LOG_DIR
|
|
18
|
+
from laniakea_api.models import DeploymentRequest, JobResponse
|
|
19
|
+
from laniakea_api.queue import get_queue
|
|
20
|
+
|
|
21
|
+
router = APIRouter()
|
|
22
|
+
|
|
23
|
+
def _strip_secrets(deployment: DeploymentRequest) -> dict:
|
|
24
|
+
"""
|
|
25
|
+
Return deployment dict without sensitive credential fields.
|
|
26
|
+
"""
|
|
27
|
+
d = copy.deepcopy(deployment.model_dump())
|
|
28
|
+
provider_key = deployment.selected_provider.lower()
|
|
29
|
+
provider = d.get("cloud_providers", {}).get(provider_key, {})
|
|
30
|
+
for field in [
|
|
31
|
+
"ssh_key", "aws_access_key", "aws_secret_key", "bastion_ip",
|
|
32
|
+
"private_network_proxy_host", "os_application_credential_id",
|
|
33
|
+
"os_application_credential_secret",
|
|
34
|
+
]:
|
|
35
|
+
provider.pop(field, None)
|
|
36
|
+
return d
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/api/deployments")
|
|
40
|
+
async def list_deployments(caller: dict = Depends(verify_session_token)):
|
|
41
|
+
"""
|
|
42
|
+
Return all deployments belonging to the authenticated user.
|
|
43
|
+
"""
|
|
44
|
+
rows = db.list_deployments(caller["sub"])
|
|
45
|
+
return {"deployments": rows, "total": len(rows)}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# depends on session token: verify the sign on the token
|
|
49
|
+
@router.get("/api/deployments/{uuid}")
|
|
50
|
+
async def get_deployment(uuid: str, caller: dict = Depends(verify_session_token)):
|
|
51
|
+
"""
|
|
52
|
+
Return the full state of a single deployment.
|
|
53
|
+
"""
|
|
54
|
+
row = db.get_deployment(uuid)
|
|
55
|
+
if row is None:
|
|
56
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Deployment {uuid} not found.")
|
|
57
|
+
if row.get("sub") != caller["sub"]:
|
|
58
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")
|
|
59
|
+
return row
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.post("/api/deployments", response_model=JobResponse, status_code=202)
|
|
63
|
+
async def enqueue_deployment(
|
|
64
|
+
deployment: DeploymentRequest, caller: dict = Depends(verify_session_token),):
|
|
65
|
+
"""
|
|
66
|
+
Accept a deployment request:
|
|
67
|
+
1. Write QUEUED to PostgreSQL (dashboard can see it immediately)
|
|
68
|
+
2. eenqueue the job on the matching Redis queue.
|
|
69
|
+
"""
|
|
70
|
+
queue_name, q = get_queue(deployment.selected_provider)
|
|
71
|
+
requested_at = datetime.utcnow()
|
|
72
|
+
|
|
73
|
+
# persist QUEUED state before touching Redis
|
|
74
|
+
try:
|
|
75
|
+
db.create_deployment(
|
|
76
|
+
uuid=deployment.deployment_uuid,
|
|
77
|
+
user_sub=caller["sub"],
|
|
78
|
+
username=caller["username"] or caller["sub"],
|
|
79
|
+
description=deployment.description,
|
|
80
|
+
provider=deployment.selected_provider,
|
|
81
|
+
requested_at=requested_at,)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
85
|
+
detail=f"Failed to persist deployment to database: {exc}",)
|
|
86
|
+
|
|
87
|
+
# Enqueue on Redis
|
|
88
|
+
job_data = {
|
|
89
|
+
**_strip_secrets(deployment),
|
|
90
|
+
"user_sub": caller["sub"],
|
|
91
|
+
"user_email": caller.get("email"),
|
|
92
|
+
"requested_by": caller["username"],
|
|
93
|
+
"requested_at": requested_at.isoformat(),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
job = q.enqueue(
|
|
98
|
+
# NOTE: agent: worker_wrapper.py
|
|
99
|
+
"worker_wrapper.run_from_dict",
|
|
100
|
+
job_data,
|
|
101
|
+
job_timeout="10h",
|
|
102
|
+
description=f"Deployment {deployment.deployment_uuid} by {caller['username']}",
|
|
103
|
+
)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
db.update_status(deployment.deployment_uuid, "CREATE_FAILED",
|
|
106
|
+
status_reason=f"Redis enqueue error: {exc}")
|
|
107
|
+
raise HTTPException(
|
|
108
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
109
|
+
detail=f"Failed to enqueue job: {exc}",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return JobResponse(
|
|
113
|
+
job_id=job.id,
|
|
114
|
+
queue_name=queue_name,
|
|
115
|
+
deployment_uuid=deployment.deployment_uuid,
|
|
116
|
+
status="QUEUED",
|
|
117
|
+
message=f"Job enqueued on '{queue_name}' queue.",)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@router.get("/api/deployments/{uuid}/logs")
|
|
121
|
+
async def get_deployment_logs(
|
|
122
|
+
uuid: str, tail: Optional[int] = None, caller: dict = Depends(verify_session_token),):
|
|
123
|
+
"""
|
|
124
|
+
Return the log lines for a deployment.
|
|
125
|
+
Optional ?tail=N returns only the last N lines.
|
|
126
|
+
"""
|
|
127
|
+
row = db.get_deployment(uuid)
|
|
128
|
+
if row is None:
|
|
129
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
|
130
|
+
detail=f"Deployment {uuid} not found.")
|
|
131
|
+
if row.get("sub") != caller["sub"]:
|
|
132
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
|
133
|
+
|
|
134
|
+
log_path = os.path.join(LOG_DIR, f"terraform_{uuid}.log")
|
|
135
|
+
if not os.path.exists(log_path):
|
|
136
|
+
return {"uuid": uuid, "lines": [], "message": "No logs yet, deployment may still be queued."}
|
|
137
|
+
|
|
138
|
+
with open(log_path, "r") as f:
|
|
139
|
+
lines = f.read().splitlines()
|
|
140
|
+
|
|
141
|
+
if tail:
|
|
142
|
+
lines = lines[-tail:]
|
|
143
|
+
|
|
144
|
+
return {"uuid": uuid, "lines": lines, "total": len(lines)}
|
|
145
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
health check endpoint.
|
|
3
|
+
### no auth required,used by load balancers and monitoring.
|
|
4
|
+
|
|
5
|
+
GET /health
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from fastapi import APIRouter
|
|
10
|
+
from laniakea_api.database import check_connection
|
|
11
|
+
from laniakea_api.queue import check_redis, check_vault
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
@router.get("/health")
|
|
16
|
+
async def health():
|
|
17
|
+
"""
|
|
18
|
+
Verify Redis, Vault and PostgreSQL connectivity
|
|
19
|
+
"""
|
|
20
|
+
redis_status = check_redis()
|
|
21
|
+
vault_status = check_vault()
|
|
22
|
+
pg_status = check_connection()
|
|
23
|
+
|
|
24
|
+
healthy = all(
|
|
25
|
+
s in ("connected", "authenticated") for s in [redis_status, vault_status, pg_status])
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
"status": "healthy" if healthy else "unhealthy",
|
|
29
|
+
"redis": redis_status,
|
|
30
|
+
"vault": vault_status,
|
|
31
|
+
"postgres": pg_status,
|
|
32
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
33
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "laniakea-api-server"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Laniakea Queue API — OIDC-authenticated gateway for cloud deployment orchestration"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Laniakea Team" }
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
keywords = ["cloud", "deployment", "openstack", "aws", "fastapi", "redis", "orchestration"]
|
|
18
|
+
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: System Administrators",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Framework :: FastAPI",
|
|
28
|
+
"Topic :: System :: Systems Administration",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
dependencies = [
|
|
32
|
+
"fastapi",
|
|
33
|
+
"uvicorn[standard]",
|
|
34
|
+
"redis",
|
|
35
|
+
"rq",
|
|
36
|
+
"pyjwt",
|
|
37
|
+
"httpx",
|
|
38
|
+
"pydantic>=2.0",
|
|
39
|
+
"hvac>=2.0.0",
|
|
40
|
+
"openstacksdk",
|
|
41
|
+
"psycopg2-binary",
|
|
42
|
+
"python-dotenv",
|
|
43
|
+
"pyyaml",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
laniakea-api = "laniakea_api.cli:main"
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
Homepage = "https://github.com/riccardocaccia/laniakea-api-redis"
|
|
51
|
+
Issues = "https://github.com/riccardocaccia/laniakea-api-redis/issues"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["laniakea_api"]
|
|
55
|
+
|