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.
@@ -0,0 +1,8 @@
1
+ *.json
2
+ __pycache__/
3
+ .env
4
+ test_*
5
+ *.crt
6
+ *.key
7
+ test_enqueue.py
8
+
@@ -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,5 @@
1
+ """
2
+ OIDC-authenticated gateway for enqueuing cloud deployment jobs.
3
+ """
4
+
5
+ __version__ = "0.0.1"
@@ -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
+
@@ -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
+