laniakea-api-server 0.2.2__tar.gz → 0.2.4__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.2 → laniakea_api_server-0.2.4}/PKG-INFO +1 -1
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/auth.py +30 -17
- laniakea_api_server-0.2.4/laniakea_api/database.py +296 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/main.py +14 -5
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/routers/deployments.py +100 -7
- laniakea_api_server-0.2.4/laniakea_api/routers/tfstate.py +111 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/pyproject.toml +1 -1
- laniakea_api_server-0.2.2/laniakea_api/database.py +0 -136
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/.github/workflows/publish.yml +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/.gitignore +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/LICENSE +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/README.md +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/__init__.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/cli.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/config.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/credential_parser.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/installer.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/models.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/queue.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/routers/.health.py.swp +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/routers/__init__.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/routers/agent.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/routers/agents.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/routers/credentials.py +0 -0
- {laniakea_api_server-0.2.2 → laniakea_api_server-0.2.4}/laniakea_api/routers/health.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: laniakea-api-server
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
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
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Authentication helpers
|
|
3
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:
|
|
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
6
|
human users (who pass through an OIDC Sign-in system) and worker agents
|
|
7
7
|
"""
|
|
8
8
|
import time
|
|
@@ -20,14 +20,14 @@ _bearer = HTTPBearer()
|
|
|
20
20
|
|
|
21
21
|
async def fetch_userinfo(oidc_token: str) -> dict:
|
|
22
22
|
"""
|
|
23
|
-
Takes an authentication token provided by a user and asks an external identity server
|
|
23
|
+
Takes an authentication token provided by a user and asks an external identity server
|
|
24
24
|
(the OIDC Provider) who that user actually is, retriving user info
|
|
25
25
|
"""
|
|
26
26
|
try:
|
|
27
27
|
# asynchronous HTTP to avoid waste of server resources
|
|
28
28
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
29
29
|
# OIDC discovery
|
|
30
|
-
discovery = await client.get(OIDC_DISCOVERY_URL)
|
|
30
|
+
discovery = await client.get(OIDC_DISCOVERY_URL)
|
|
31
31
|
discovery.raise_for_status() # status 200: OK
|
|
32
32
|
userinfo_url = discovery.json()["userinfo_endpoint"] # now final url is known
|
|
33
33
|
# USER info request
|
|
@@ -52,10 +52,10 @@ async def fetch_userinfo(oidc_token: str) -> dict:
|
|
|
52
52
|
|
|
53
53
|
def create_session_token(user_info: dict) -> tuple:
|
|
54
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
|
|
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
57
|
generates an internal JWT token:
|
|
58
|
-
|
|
58
|
+
|
|
59
59
|
It takes the user's data (sub, username, email, groups).
|
|
60
60
|
It adds an expiration date based on the minutes configured in SESSION_TTL_MINUTES.
|
|
61
61
|
It cryptographically signs everything with a secret key (SECRET_KEY)
|
|
@@ -64,7 +64,7 @@ def create_session_token(user_info: dict) -> tuple:
|
|
|
64
64
|
#NOTE: act here for expiration
|
|
65
65
|
expires_in = SESSION_TTL_MINUTES * 60
|
|
66
66
|
now = int(time.time())
|
|
67
|
-
#NOTE: PAYLOAD
|
|
67
|
+
#NOTE: PAYLOAD
|
|
68
68
|
payload = {
|
|
69
69
|
"sub": user_info.get("sub"),
|
|
70
70
|
"username": user_info.get("preferred_username") or user_info.get("sub"),
|
|
@@ -79,7 +79,7 @@ def create_session_token(user_info: dict) -> tuple:
|
|
|
79
79
|
async def verify_session_token(credentials: HTTPAuthorizationCredentials = Depends(_bearer),) -> dict:
|
|
80
80
|
"""
|
|
81
81
|
Check that the HTTP request contains an authorization header.
|
|
82
|
-
Takes the internal JWT token created in the previous step and checks
|
|
82
|
+
Takes the internal JWT token created in the previous step and checks
|
|
83
83
|
whether the signature is authentic (using the SECRET_KEY).
|
|
84
84
|
"""
|
|
85
85
|
try:
|
|
@@ -98,15 +98,15 @@ async def verify_session_token(credentials: HTTPAuthorizationCredentials = Depen
|
|
|
98
98
|
)
|
|
99
99
|
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
def decode_agent_token(token: str) -> str:
|
|
102
102
|
"""
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
103
|
+
Validate an agent pool JWT (HTCondor pool-password like) and return
|
|
104
|
+
the agent id. Raises HTTPException(401) if the token is invalid.
|
|
108
105
|
|
|
109
|
-
|
|
106
|
+
Shared by:
|
|
107
|
+
- verify_agent_token (Bearer header, /internal/* endpoints)
|
|
108
|
+
- the tfstate router (HTTP Basic, Terraform http backend only
|
|
109
|
+
supports username/password: the agent JWT travels as password)
|
|
110
110
|
"""
|
|
111
111
|
if not AGENT_MASTER_PASSWORD:
|
|
112
112
|
raise HTTPException(
|
|
@@ -115,7 +115,7 @@ async def verify_agent_token(credentials: HTTPAuthorizationCredentials = Depends
|
|
|
115
115
|
)
|
|
116
116
|
try:
|
|
117
117
|
payload = jwt.decode(
|
|
118
|
-
|
|
118
|
+
token,
|
|
119
119
|
AGENT_MASTER_PASSWORD,
|
|
120
120
|
algorithms=["HS256"],
|
|
121
121
|
)
|
|
@@ -132,3 +132,16 @@ async def verify_agent_token(credentials: HTTPAuthorizationCredentials = Depends
|
|
|
132
132
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
133
133
|
detail=f"Invalid agent token: {exc}",
|
|
134
134
|
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def verify_agent_token(credentials: HTTPAuthorizationCredentials = Depends(_bearer),) -> str:
|
|
138
|
+
"""
|
|
139
|
+
This function validates the tokens used by workers.
|
|
140
|
+
It doesn't use the user's key, but a dedicated secret key
|
|
141
|
+
called AGENT_MASTER_PASSWORD.
|
|
142
|
+
If the worker sends a valid token signed with this master password,
|
|
143
|
+
the API trusts the worker and allows it to fetch or update the status of deployment jobs.
|
|
144
|
+
|
|
145
|
+
HTCondor pool-password like.
|
|
146
|
+
"""
|
|
147
|
+
return decode_agent_token(credentials.credentials)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostgreSQL connection.
|
|
3
|
+
The agent has NO direct DB access:
|
|
4
|
+
**all writes go through the API**
|
|
5
|
+
The Dashboard is stateless.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Optional
|
|
12
|
+
import psycopg2
|
|
13
|
+
from psycopg2.extras import RealDictCursor
|
|
14
|
+
from laniakea_api.config import PG_HOST, PG_PORT, PG_DATABASE, PG_USER, PG_PASSWORD
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_conn():
|
|
18
|
+
"""
|
|
19
|
+
Open and return a new PostgreSQL connection.
|
|
20
|
+
"""
|
|
21
|
+
# act over config.py before changing here
|
|
22
|
+
return psycopg2.connect(
|
|
23
|
+
host=PG_HOST,
|
|
24
|
+
port=PG_PORT,
|
|
25
|
+
database=PG_DATABASE,
|
|
26
|
+
user=PG_USER,
|
|
27
|
+
password=PG_PASSWORD,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def create_deployment(
|
|
31
|
+
uuid: str, user_sub: str, username: str, description: str, provider: str, requested_at: datetime,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Insert a new deployment row with status QUEUED.
|
|
35
|
+
Called by the API the moment a job is accepted,
|
|
36
|
+
(db before redis)
|
|
37
|
+
"""
|
|
38
|
+
conn = get_conn()
|
|
39
|
+
try:
|
|
40
|
+
with conn.cursor() as cur:
|
|
41
|
+
# If the user making the request doesn't yet exist in the local database, it inserts them.
|
|
42
|
+
#if they already exist (subprimary key conflict), it does nothing.
|
|
43
|
+
cur.execute(
|
|
44
|
+
"""
|
|
45
|
+
INSERT INTO users (sub, username, email, role, active)
|
|
46
|
+
VALUES (%s, %s, %s, 'user', true)
|
|
47
|
+
ON CONFLICT (sub) DO NOTHING
|
|
48
|
+
""",
|
|
49
|
+
(user_sub, username, ""),
|
|
50
|
+
)
|
|
51
|
+
#Create a row in the deployments table with the initial state set to "QUEUED."
|
|
52
|
+
# if, by chance, that UUID already exists, simply update the state and date.
|
|
53
|
+
cur.execute(
|
|
54
|
+
"""
|
|
55
|
+
INSERT INTO deployments (
|
|
56
|
+
uuid, status, creation_time, update_time,
|
|
57
|
+
description, provider_name, sub
|
|
58
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
59
|
+
ON CONFLICT (uuid) DO UPDATE
|
|
60
|
+
SET status = EXCLUDED.status,
|
|
61
|
+
update_time = EXCLUDED.update_time
|
|
62
|
+
""",
|
|
63
|
+
(uuid, "QUEUED", requested_at, requested_at, description, provider, user_sub),
|
|
64
|
+
)
|
|
65
|
+
conn.commit()
|
|
66
|
+
finally:
|
|
67
|
+
conn.close()
|
|
68
|
+
|
|
69
|
+
def update_status(
|
|
70
|
+
uuid: str, new_status: str, status_reason: Optional[str] = None, outputs: Optional[str] = None,
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Update the status of an existing deployment.
|
|
74
|
+
Returns True if a row was updated, False if uuid not found.
|
|
75
|
+
"""
|
|
76
|
+
conn = get_conn()
|
|
77
|
+
try:
|
|
78
|
+
with conn.cursor() as cur:
|
|
79
|
+
# COALESCE causes the database to keep old value already present
|
|
80
|
+
# without overwriting them with an empty value.
|
|
81
|
+
cur.execute(
|
|
82
|
+
"""
|
|
83
|
+
UPDATE deployments
|
|
84
|
+
SET status = %s,
|
|
85
|
+
status_reason = COALESCE(%s, status_reason),
|
|
86
|
+
outputs = COALESCE(%s, outputs),
|
|
87
|
+
update_time = %s
|
|
88
|
+
WHERE uuid = %s
|
|
89
|
+
""",
|
|
90
|
+
(new_status, status_reason, outputs, datetime.utcnow(), uuid),
|
|
91
|
+
)
|
|
92
|
+
updated = cur.rowcount > 0
|
|
93
|
+
conn.commit()
|
|
94
|
+
return updated
|
|
95
|
+
finally:
|
|
96
|
+
conn.close()
|
|
97
|
+
|
|
98
|
+
def get_deployment(uuid: str) -> Optional[dict]:
|
|
99
|
+
"""
|
|
100
|
+
Fetch a single deployment row by uuid. Returns None if not found.
|
|
101
|
+
"""
|
|
102
|
+
conn = get_conn()
|
|
103
|
+
try:
|
|
104
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
105
|
+
cur.execute("SELECT * FROM deployments WHERE uuid = %s", (uuid,))
|
|
106
|
+
row = cur.fetchone()
|
|
107
|
+
return dict(row) if row else None
|
|
108
|
+
finally:
|
|
109
|
+
conn.close()
|
|
110
|
+
|
|
111
|
+
def list_deployments(user_sub: str) -> list:
|
|
112
|
+
"""
|
|
113
|
+
Fetch all deployments owned by a user, ordered by creation time desc.
|
|
114
|
+
"""
|
|
115
|
+
conn = get_conn()
|
|
116
|
+
try:
|
|
117
|
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
118
|
+
cur.execute(
|
|
119
|
+
"""
|
|
120
|
+
SELECT uuid, status, status_reason, description, provider_name,
|
|
121
|
+
creation_time, update_time, endpoint, outputs, sub
|
|
122
|
+
FROM deployments
|
|
123
|
+
WHERE sub = %s
|
|
124
|
+
ORDER BY creation_time DESC
|
|
125
|
+
""",
|
|
126
|
+
(user_sub,),
|
|
127
|
+
)
|
|
128
|
+
return [dict(r) for r in cur.fetchall()]
|
|
129
|
+
finally:
|
|
130
|
+
conn.close()
|
|
131
|
+
|
|
132
|
+
def delete_deployment(uuid: str) -> bool:
|
|
133
|
+
"""
|
|
134
|
+
Remove a deployment record. Returns True if a row was deleted.
|
|
135
|
+
Only the API decides WHEN this is allowed (terminal states):
|
|
136
|
+
this helper just executes the removal.
|
|
137
|
+
"""
|
|
138
|
+
conn = get_conn()
|
|
139
|
+
try:
|
|
140
|
+
with conn.cursor() as cur:
|
|
141
|
+
cur.execute("DELETE FROM deployments WHERE uuid = %s", (uuid,))
|
|
142
|
+
deleted = cur.rowcount > 0
|
|
143
|
+
conn.commit()
|
|
144
|
+
return deleted
|
|
145
|
+
finally:
|
|
146
|
+
conn.close()
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Deployment payload (needed to rebuild a destroy job)
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def save_payload(uuid: str, payload: dict) -> bool:
|
|
153
|
+
"""
|
|
154
|
+
Persist the job payload (deployment_info WITHOUT the auth block)
|
|
155
|
+
in the deployments.payload JSONB column. Used later to enqueue
|
|
156
|
+
a destroy job for this deployment.
|
|
157
|
+
"""
|
|
158
|
+
conn = get_conn()
|
|
159
|
+
try:
|
|
160
|
+
with conn.cursor() as cur:
|
|
161
|
+
cur.execute(
|
|
162
|
+
"UPDATE deployments SET payload = %s WHERE uuid = %s",
|
|
163
|
+
(json.dumps(payload), uuid),
|
|
164
|
+
)
|
|
165
|
+
updated = cur.rowcount > 0
|
|
166
|
+
conn.commit()
|
|
167
|
+
return updated
|
|
168
|
+
finally:
|
|
169
|
+
conn.close()
|
|
170
|
+
|
|
171
|
+
def get_payload(uuid: str) -> Optional[dict]:
|
|
172
|
+
"""
|
|
173
|
+
Fetch the stored job payload for a deployment. Returns None if missing.
|
|
174
|
+
psycopg2 deserializes JSONB to dict automatically.
|
|
175
|
+
"""
|
|
176
|
+
conn = get_conn()
|
|
177
|
+
try:
|
|
178
|
+
with conn.cursor() as cur:
|
|
179
|
+
cur.execute("SELECT payload FROM deployments WHERE uuid = %s", (uuid,))
|
|
180
|
+
row = cur.fetchone()
|
|
181
|
+
return row[0] if row and row[0] else None
|
|
182
|
+
finally:
|
|
183
|
+
conn.close()
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Terraform state (HTTP backend) — table: tf_states
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def tfstate_get(uuid: str) -> Optional[dict]:
|
|
190
|
+
"""
|
|
191
|
+
Fetch the Terraform state blob for a deployment.
|
|
192
|
+
Returns {"state": bytes} or None if no state exists (or state is empty).
|
|
193
|
+
"""
|
|
194
|
+
conn = get_conn()
|
|
195
|
+
try:
|
|
196
|
+
with conn.cursor() as cur:
|
|
197
|
+
cur.execute("SELECT state FROM tf_states WHERE uuid = %s", (uuid,))
|
|
198
|
+
row = cur.fetchone()
|
|
199
|
+
if row is None or row[0] is None or len(row[0]) == 0:
|
|
200
|
+
return None
|
|
201
|
+
return {"state": bytes(row[0])}
|
|
202
|
+
finally:
|
|
203
|
+
conn.close()
|
|
204
|
+
|
|
205
|
+
def tfstate_set(uuid: str, state: bytes) -> None:
|
|
206
|
+
"""
|
|
207
|
+
Insert or update the Terraform state blob for a deployment.
|
|
208
|
+
Called by Terraform (via the API) on every state push.
|
|
209
|
+
"""
|
|
210
|
+
conn = get_conn()
|
|
211
|
+
try:
|
|
212
|
+
with conn.cursor() as cur:
|
|
213
|
+
cur.execute(
|
|
214
|
+
"""
|
|
215
|
+
INSERT INTO tf_states (uuid, state, updated_at)
|
|
216
|
+
VALUES (%s, %s, now())
|
|
217
|
+
ON CONFLICT (uuid) DO UPDATE
|
|
218
|
+
SET state = EXCLUDED.state, updated_at = now()
|
|
219
|
+
""",
|
|
220
|
+
(uuid, state),
|
|
221
|
+
)
|
|
222
|
+
conn.commit()
|
|
223
|
+
finally:
|
|
224
|
+
conn.close()
|
|
225
|
+
|
|
226
|
+
def tfstate_delete(uuid: str) -> bool:
|
|
227
|
+
"""
|
|
228
|
+
Remove the Terraform state row (after a successful destroy).
|
|
229
|
+
"""
|
|
230
|
+
conn = get_conn()
|
|
231
|
+
try:
|
|
232
|
+
with conn.cursor() as cur:
|
|
233
|
+
cur.execute("DELETE FROM tf_states WHERE uuid = %s", (uuid,))
|
|
234
|
+
deleted = cur.rowcount > 0
|
|
235
|
+
conn.commit()
|
|
236
|
+
return deleted
|
|
237
|
+
finally:
|
|
238
|
+
conn.close()
|
|
239
|
+
|
|
240
|
+
def tfstate_get_lock(uuid: str) -> Optional[str]:
|
|
241
|
+
"""
|
|
242
|
+
Return the current lock info JSON (str) or None if unlocked/missing.
|
|
243
|
+
"""
|
|
244
|
+
conn = get_conn()
|
|
245
|
+
try:
|
|
246
|
+
with conn.cursor() as cur:
|
|
247
|
+
cur.execute("SELECT lock_info FROM tf_states WHERE uuid = %s", (uuid,))
|
|
248
|
+
row = cur.fetchone()
|
|
249
|
+
return row[0] if row and row[0] else None
|
|
250
|
+
finally:
|
|
251
|
+
conn.close()
|
|
252
|
+
|
|
253
|
+
def tfstate_set_lock(uuid: str, lock_info: str) -> None:
|
|
254
|
+
"""
|
|
255
|
+
Store lock info. The row may not exist yet on the very first lock
|
|
256
|
+
(before any state has been pushed): insert with an empty state.
|
|
257
|
+
"""
|
|
258
|
+
conn = get_conn()
|
|
259
|
+
try:
|
|
260
|
+
with conn.cursor() as cur:
|
|
261
|
+
cur.execute(
|
|
262
|
+
"""
|
|
263
|
+
INSERT INTO tf_states (uuid, state, lock_info)
|
|
264
|
+
VALUES (%s, ''::bytea, %s)
|
|
265
|
+
ON CONFLICT (uuid) DO UPDATE
|
|
266
|
+
SET lock_info = EXCLUDED.lock_info
|
|
267
|
+
""",
|
|
268
|
+
(uuid, lock_info),
|
|
269
|
+
)
|
|
270
|
+
conn.commit()
|
|
271
|
+
finally:
|
|
272
|
+
conn.close()
|
|
273
|
+
|
|
274
|
+
def tfstate_clear_lock(uuid: str) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Release the lock for a deployment state.
|
|
277
|
+
"""
|
|
278
|
+
conn = get_conn()
|
|
279
|
+
try:
|
|
280
|
+
with conn.cursor() as cur:
|
|
281
|
+
cur.execute("UPDATE tf_states SET lock_info = NULL WHERE uuid = %s", (uuid,))
|
|
282
|
+
conn.commit()
|
|
283
|
+
finally:
|
|
284
|
+
conn.close()
|
|
285
|
+
|
|
286
|
+
def check_connection() -> str:
|
|
287
|
+
"""
|
|
288
|
+
Returns 'connected' or an error string.
|
|
289
|
+
Used by /health.
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
conn = get_conn()
|
|
293
|
+
conn.close()
|
|
294
|
+
return "connected"
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
return f"error: {exc}"
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Registers all routers and starts uvicorn.
|
|
3
3
|
|
|
4
|
-
Create the FastAPI application instance and map the various groups of
|
|
4
|
+
Create the FastAPI application instance and map the various groups of
|
|
5
5
|
endpoints (the "routers") to their respective URL addresses (the "prefixes").
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import os
|
|
9
9
|
import uvicorn
|
|
10
10
|
from fastapi import FastAPI
|
|
11
|
-
from laniakea_api.routers import agent, agents,credentials, deployments, health
|
|
11
|
+
from laniakea_api.routers import agent, agents, credentials, deployments, health, tfstate
|
|
12
12
|
|
|
13
13
|
# FastAPI App
|
|
14
14
|
app = FastAPI(
|
|
15
15
|
title="Laniakea Queue API",
|
|
16
16
|
description="OIDC-authenticated gateway for enqueuing cloud deployment jobs.",
|
|
17
17
|
# FIXME: automatizza versione
|
|
18
|
-
version="0.
|
|
18
|
+
version="0.3.0",
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
# NOTE: CHANGE HERE for path
|
|
@@ -28,6 +28,8 @@ app.include_router(deployments.router, prefix=BASE)
|
|
|
28
28
|
app.include_router(agent.router, prefix=INTERNAL)
|
|
29
29
|
app.include_router(agents.router, prefix=INTERNAL) # POST /internal/agents/heartbeat
|
|
30
30
|
app.include_router(agents.router, prefix=BASE) # GET /api/agents/status
|
|
31
|
+
# NOTE: tfstate route paths already contain "/internal/...", so mount on BASE
|
|
32
|
+
app.include_router(tfstate.router, prefix=BASE) # Terraform http backend
|
|
31
33
|
app.include_router(health.router) # /health:no prefix
|
|
32
34
|
|
|
33
35
|
############ Routes registered ############################
|
|
@@ -40,16 +42,24 @@ app.include_router(health.router) # /health:no prefix
|
|
|
40
42
|
# POST /laniakea_core/v1.0/api/deployments
|
|
41
43
|
# GET /laniakea_core/v1.0/api/deployments
|
|
42
44
|
# GET /laniakea_core/v1.0/api/deployments/{uuid}
|
|
45
|
+
# DELETE /laniakea_core/v1.0/api/deployments/{uuid}
|
|
43
46
|
# GET /laniakea_core/v1.0/api/deployments/{uuid}/logs
|
|
44
47
|
#
|
|
45
48
|
# Internal (agent only):
|
|
46
49
|
# PATCH /laniakea_core/v1.0/internal/deployments/{uuid}/status
|
|
47
50
|
# POST /laniakea_core/v1.0/internal/deployments/{uuid}/logs
|
|
48
51
|
#
|
|
52
|
+
# Internal (terraform http backend, Basic auth with agent JWT as password):
|
|
53
|
+
# GET /laniakea_core/v1.0/internal/tfstate/{uuid}
|
|
54
|
+
# POST /laniakea_core/v1.0/internal/tfstate/{uuid}
|
|
55
|
+
# DELETE /laniakea_core/v1.0/internal/tfstate/{uuid}
|
|
56
|
+
# POST /laniakea_core/v1.0/internal/tfstate/{uuid}/lock
|
|
57
|
+
# DELETE /laniakea_core/v1.0/internal/tfstate/{uuid}/lock
|
|
58
|
+
#
|
|
49
59
|
# Monitoring:
|
|
50
60
|
# GET /health
|
|
51
61
|
|
|
52
|
-
# Entry point uvicorn
|
|
62
|
+
# Entry point uvicorn
|
|
53
63
|
# skipped
|
|
54
64
|
if __name__ == "__main__":
|
|
55
65
|
uvicorn.run(
|
|
@@ -61,4 +71,3 @@ if __name__ == "__main__":
|
|
|
61
71
|
log_level="info",
|
|
62
72
|
reload=False,
|
|
63
73
|
)
|
|
64
|
-
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""
|
|
2
2
|
user deployment endpoints:
|
|
3
3
|
|
|
4
|
-
GET
|
|
5
|
-
GET
|
|
6
|
-
POST
|
|
7
|
-
|
|
4
|
+
GET /api/deployments
|
|
5
|
+
GET /api/deployments/{uuid}
|
|
6
|
+
POST /api/deployments
|
|
7
|
+
DELETE /api/deployments/{uuid}
|
|
8
|
+
GET /api/deployments/{uuid}/logs
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
import copy
|
|
@@ -20,6 +21,13 @@ from laniakea_api.queue import get_queue
|
|
|
20
21
|
|
|
21
22
|
router = APIRouter()
|
|
22
23
|
|
|
24
|
+
# States whose resources are already gone (emergency destroy) or were never
|
|
25
|
+
# created: the record can be removed directly.
|
|
26
|
+
DELETABLE_STATES = {"CREATE_FAILED", "DELETE_COMPLETE", "QUEUED"}
|
|
27
|
+
# States with live resources: a destroy job must run first.
|
|
28
|
+
DESTROYABLE_STATES = {"CREATE_COMPLETE"}
|
|
29
|
+
|
|
30
|
+
|
|
23
31
|
def _strip_secrets(deployment: DeploymentRequest) -> dict:
|
|
24
32
|
"""
|
|
25
33
|
Return deployment dict without sensitive credential fields.
|
|
@@ -102,13 +110,23 @@ async def enqueue_deployment(
|
|
|
102
110
|
description=f"Deployment {deployment.deployment_uuid} by {caller['username']}",
|
|
103
111
|
)
|
|
104
112
|
except Exception as exc:
|
|
105
|
-
db.update_status(deployment.deployment_uuid, "CREATE_FAILED",
|
|
113
|
+
db.update_status(deployment.deployment_uuid, "CREATE_FAILED",
|
|
106
114
|
status_reason=f"Redis enqueue error: {exc}")
|
|
107
115
|
raise HTTPException(
|
|
108
116
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
109
117
|
detail=f"Failed to enqueue job: {exc}",
|
|
110
118
|
)
|
|
111
119
|
|
|
120
|
+
# Persist the job payload WITHOUT the auth block: it will be reused to
|
|
121
|
+
# rebuild a destroy job later (a fresh aai_token is injected at that time).
|
|
122
|
+
# Not fatal on failure: the deployment proceeds, only remote destroy
|
|
123
|
+
# becomes unavailable for this record.
|
|
124
|
+
try:
|
|
125
|
+
payload_to_store = {k: v for k, v in job_data.items() if k != "auth"}
|
|
126
|
+
db.save_payload(deployment.deployment_uuid, payload_to_store)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
112
130
|
return JobResponse(
|
|
113
131
|
job_id=job.id,
|
|
114
132
|
queue_name=queue_name,
|
|
@@ -117,6 +135,82 @@ async def enqueue_deployment(
|
|
|
117
135
|
message=f"Job enqueued on '{queue_name}' queue.",)
|
|
118
136
|
|
|
119
137
|
|
|
138
|
+
@router.delete("/api/deployments/{uuid}", status_code=200)
|
|
139
|
+
async def delete_deployment(
|
|
140
|
+
uuid: str, body: Optional[dict] = None, caller: dict = Depends(verify_session_token),):
|
|
141
|
+
"""
|
|
142
|
+
Remove or destroy a deployment.
|
|
143
|
+
|
|
144
|
+
- Terminal states (CREATE_FAILED, DELETE_COMPLETE, QUEUED): resources
|
|
145
|
+
are already gone -> remove the record (and any leftover tf state).
|
|
146
|
+
- CREATE_COMPLETE: enqueue a Terraform destroy job. Requires a fresh
|
|
147
|
+
'aai_token' in the request body (injected into the stored payload).
|
|
148
|
+
Status becomes DELETE_IN_PROGRESS; once the agent completes, the
|
|
149
|
+
record turns DELETE_COMPLETE and can be removed with a second call.
|
|
150
|
+
- Any other state (e.g. CREATE_IN_PROGRESS): 409, wait first.
|
|
151
|
+
"""
|
|
152
|
+
row = db.get_deployment(uuid)
|
|
153
|
+
if row is None:
|
|
154
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
|
155
|
+
detail=f"Deployment {uuid} not found.")
|
|
156
|
+
if row.get("sub") != caller["sub"]:
|
|
157
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")
|
|
158
|
+
|
|
159
|
+
status_now = row.get("status")
|
|
160
|
+
|
|
161
|
+
# dead record: just clean up
|
|
162
|
+
if status_now in DELETABLE_STATES:
|
|
163
|
+
db.delete_deployment(uuid)
|
|
164
|
+
db.tfstate_delete(uuid) # leftover state, if any
|
|
165
|
+
return {"uuid": uuid, "deleted": True}
|
|
166
|
+
|
|
167
|
+
# live resources: enqueue a destroy job
|
|
168
|
+
if status_now in DESTROYABLE_STATES:
|
|
169
|
+
payload = db.get_payload(uuid)
|
|
170
|
+
if not payload:
|
|
171
|
+
raise HTTPException(
|
|
172
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
173
|
+
detail="No stored payload for this deployment: remote destroy unavailable. "
|
|
174
|
+
"Remove resources manually, then delete the record.",
|
|
175
|
+
)
|
|
176
|
+
aai_token = (body or {}).get("aai_token")
|
|
177
|
+
if not aai_token:
|
|
178
|
+
raise HTTPException(
|
|
179
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
180
|
+
detail="aai_token required in request body to destroy resources.",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
payload["auth"] = {
|
|
184
|
+
"aai_token": aai_token,
|
|
185
|
+
"sub": caller["sub"],
|
|
186
|
+
"group": (body or {}).get("group", "default"),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
queue_name, q = get_queue(payload.get("selected_provider", "Openstack"))
|
|
190
|
+
try:
|
|
191
|
+
job = q.enqueue(
|
|
192
|
+
"laniakea_agent.worker_wrapper.destroy_from_dict",
|
|
193
|
+
payload,
|
|
194
|
+
job_timeout="1h",
|
|
195
|
+
description=f"Destroy {uuid} by {caller['username']}",
|
|
196
|
+
)
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
raise HTTPException(
|
|
199
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
200
|
+
detail=f"Failed to enqueue destroy job: {exc}",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
db.update_status(uuid, "DELETE_IN_PROGRESS",
|
|
204
|
+
status_reason=f"Destroy requested by {caller['username']}")
|
|
205
|
+
return {"uuid": uuid, "destroy_enqueued": True, "job_id": job.id,
|
|
206
|
+
"queue_name": queue_name}
|
|
207
|
+
|
|
208
|
+
raise HTTPException(
|
|
209
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
210
|
+
detail=f"Deployment is in state {status_now}: wait for the current operation to finish.",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
120
214
|
@router.get("/api/deployments/{uuid}/logs")
|
|
121
215
|
async def get_deployment_logs(
|
|
122
216
|
uuid: str, tail: Optional[int] = None, caller: dict = Depends(verify_session_token),):
|
|
@@ -126,7 +220,7 @@ async def get_deployment_logs(
|
|
|
126
220
|
"""
|
|
127
221
|
row = db.get_deployment(uuid)
|
|
128
222
|
if row is None:
|
|
129
|
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
|
223
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
|
130
224
|
detail=f"Deployment {uuid} not found.")
|
|
131
225
|
if row.get("sub") != caller["sub"]:
|
|
132
226
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
|
@@ -142,4 +236,3 @@ async def get_deployment_logs(
|
|
|
142
236
|
lines = lines[-tail:]
|
|
143
237
|
|
|
144
238
|
return {"uuid": uuid, "lines": lines, "total": len(lines)}
|
|
145
|
-
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terraform HTTP backend endpoints.
|
|
3
|
+
|
|
4
|
+
Terraform (backend "http") calls these endpoints on every operation:
|
|
5
|
+
|
|
6
|
+
GET /internal/tfstate/{uuid} -> fetch current state (404 if none)
|
|
7
|
+
POST /internal/tfstate/{uuid} -> push updated state
|
|
8
|
+
DELETE /internal/tfstate/{uuid} -> remove state (after destroy)
|
|
9
|
+
POST /internal/tfstate/{uuid}/lock -> acquire lock (423 if already locked)
|
|
10
|
+
DELETE /internal/tfstate/{uuid}/lock -> release lock
|
|
11
|
+
|
|
12
|
+
Auth: the Terraform http backend only supports HTTP Basic auth
|
|
13
|
+
(username/password). The agent passes username="agent" and
|
|
14
|
+
password=<agent JWT>; _verify_basic_agent() extracts the password and
|
|
15
|
+
validates it with the same logic used for the Bearer-based internal
|
|
16
|
+
endpoints.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import json
|
|
21
|
+
from fastapi import APIRouter, HTTPException, Request, Response, status
|
|
22
|
+
|
|
23
|
+
from laniakea_api import database as db
|
|
24
|
+
from laniakea_api.auth import decode_agent_token # see note in PR: thin wrapper around the JWT check used by verify_agent_token
|
|
25
|
+
|
|
26
|
+
router = APIRouter()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _verify_basic_agent(request: Request) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Validate HTTP Basic credentials where the password is the agent JWT.
|
|
32
|
+
Also accepts a standard Bearer token for convenience.
|
|
33
|
+
Returns the agent id.
|
|
34
|
+
"""
|
|
35
|
+
auth_header = request.headers.get("Authorization", "")
|
|
36
|
+
|
|
37
|
+
if auth_header.startswith("Basic "):
|
|
38
|
+
try:
|
|
39
|
+
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
|
|
40
|
+
_username, _, password = decoded.partition(":")
|
|
41
|
+
except Exception:
|
|
42
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
|
|
43
|
+
detail="Malformed Basic credentials.")
|
|
44
|
+
token = password
|
|
45
|
+
elif auth_header.startswith("Bearer "):
|
|
46
|
+
token = auth_header[7:]
|
|
47
|
+
else:
|
|
48
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
|
|
49
|
+
detail="Missing credentials.")
|
|
50
|
+
|
|
51
|
+
agent_id = decode_agent_token(token) # raises HTTPException(401) if invalid
|
|
52
|
+
return agent_id
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.get("/internal/tfstate/{uuid}")
|
|
56
|
+
async def get_state(uuid: str, request: Request):
|
|
57
|
+
_verify_basic_agent(request)
|
|
58
|
+
row = db.tfstate_get(uuid)
|
|
59
|
+
if row is None:
|
|
60
|
+
# Terraform expects 404 (or an empty body) when no state exists yet
|
|
61
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
|
62
|
+
detail="No state for this deployment.")
|
|
63
|
+
return Response(content=row["state"], media_type="application/json")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.post("/internal/tfstate/{uuid}")
|
|
67
|
+
async def push_state(uuid: str, request: Request):
|
|
68
|
+
_verify_basic_agent(request)
|
|
69
|
+
# Terraform may append ?ID=<lock-id> — verify it matches the current lock
|
|
70
|
+
lock_id = request.query_params.get("ID")
|
|
71
|
+
current_lock = db.tfstate_get_lock(uuid)
|
|
72
|
+
if current_lock and lock_id:
|
|
73
|
+
try:
|
|
74
|
+
if json.loads(current_lock).get("ID") != lock_id:
|
|
75
|
+
raise HTTPException(status_code=status.HTTP_423_LOCKED,
|
|
76
|
+
detail="State is locked by another operation.")
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
pass
|
|
79
|
+
body = await request.body()
|
|
80
|
+
if not body:
|
|
81
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
|
82
|
+
detail="Empty state payload.")
|
|
83
|
+
db.tfstate_set(uuid, body)
|
|
84
|
+
return {"uuid": uuid, "saved": True}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@router.delete("/internal/tfstate/{uuid}")
|
|
88
|
+
async def delete_state(uuid: str, request: Request):
|
|
89
|
+
_verify_basic_agent(request)
|
|
90
|
+
db.tfstate_delete(uuid)
|
|
91
|
+
return {"uuid": uuid, "deleted": True}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@router.post("/internal/tfstate/{uuid}/lock")
|
|
95
|
+
async def lock_state(uuid: str, request: Request):
|
|
96
|
+
_verify_basic_agent(request)
|
|
97
|
+
body = await request.body() # Terraform sends lock info JSON
|
|
98
|
+
current = db.tfstate_get_lock(uuid)
|
|
99
|
+
if current:
|
|
100
|
+
# already locked: return 423 with the existing lock info
|
|
101
|
+
return Response(content=current, media_type="application/json",
|
|
102
|
+
status_code=status.HTTP_423_LOCKED)
|
|
103
|
+
db.tfstate_set_lock(uuid, body.decode("utf-8") if body else "{}")
|
|
104
|
+
return {"uuid": uuid, "locked": True}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.delete("/internal/tfstate/{uuid}/lock")
|
|
108
|
+
async def unlock_state(uuid: str, request: Request):
|
|
109
|
+
_verify_basic_agent(request)
|
|
110
|
+
db.tfstate_clear_lock(uuid)
|
|
111
|
+
return {"uuid": uuid, "unlocked": True}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "laniakea-api-server"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "Laniakea Queue API — OIDC-authenticated gateway for cloud deployment orchestration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
PostgreSQL connection.
|
|
3
|
-
The agent has NO direct DB access:
|
|
4
|
-
**all writes go through the API**
|
|
5
|
-
The Dashboard is stateless.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import os
|
|
9
|
-
from datetime import datetime
|
|
10
|
-
from typing import Optional
|
|
11
|
-
import psycopg2
|
|
12
|
-
from psycopg2.extras import RealDictCursor
|
|
13
|
-
from laniakea_api.config import PG_HOST, PG_PORT, PG_DATABASE, PG_USER, PG_PASSWORD
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def get_conn():
|
|
17
|
-
"""
|
|
18
|
-
Open and return a new PostgreSQL connection.
|
|
19
|
-
"""
|
|
20
|
-
# act over config.py before changing here
|
|
21
|
-
return psycopg2.connect(
|
|
22
|
-
host=PG_HOST,
|
|
23
|
-
port=PG_PORT,
|
|
24
|
-
database=PG_DATABASE,
|
|
25
|
-
user=PG_USER,
|
|
26
|
-
password=PG_PASSWORD,
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
def create_deployment(
|
|
30
|
-
uuid: str, user_sub: str, username: str, description: str, provider: str, requested_at: datetime,
|
|
31
|
-
) -> None:
|
|
32
|
-
"""
|
|
33
|
-
Insert a new deployment row with status QUEUED.
|
|
34
|
-
Called by the API the moment a job is accepted,
|
|
35
|
-
(db before redis)
|
|
36
|
-
"""
|
|
37
|
-
conn = get_conn()
|
|
38
|
-
try:
|
|
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.
|
|
42
|
-
cur.execute(
|
|
43
|
-
"""
|
|
44
|
-
INSERT INTO users (sub, username, email, role, active)
|
|
45
|
-
VALUES (%s, %s, %s, 'user', true)
|
|
46
|
-
ON CONFLICT (sub) DO NOTHING
|
|
47
|
-
""",
|
|
48
|
-
(user_sub, username, ""),
|
|
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.
|
|
52
|
-
cur.execute(
|
|
53
|
-
"""
|
|
54
|
-
INSERT INTO deployments (
|
|
55
|
-
uuid, status, creation_time, update_time,
|
|
56
|
-
description, provider_name, sub
|
|
57
|
-
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
58
|
-
ON CONFLICT (uuid) DO UPDATE
|
|
59
|
-
SET status = EXCLUDED.status,
|
|
60
|
-
update_time = EXCLUDED.update_time
|
|
61
|
-
""",
|
|
62
|
-
(uuid, "QUEUED", requested_at, requested_at, description, provider, user_sub),
|
|
63
|
-
)
|
|
64
|
-
conn.commit()
|
|
65
|
-
finally:
|
|
66
|
-
conn.close()
|
|
67
|
-
|
|
68
|
-
def update_status(
|
|
69
|
-
uuid: str, new_status: str, status_reason: Optional[str] = None, outputs: Optional[str] = None,
|
|
70
|
-
) -> bool:
|
|
71
|
-
"""
|
|
72
|
-
Update the status of an existing deployment.
|
|
73
|
-
Returns True if a row was updated, False if uuid not found.
|
|
74
|
-
"""
|
|
75
|
-
conn = get_conn()
|
|
76
|
-
try:
|
|
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.
|
|
80
|
-
cur.execute(
|
|
81
|
-
"""
|
|
82
|
-
UPDATE deployments
|
|
83
|
-
SET status = %s,
|
|
84
|
-
status_reason = COALESCE(%s, status_reason),
|
|
85
|
-
outputs = COALESCE(%s, outputs),
|
|
86
|
-
update_time = %s
|
|
87
|
-
WHERE uuid = %s
|
|
88
|
-
""",
|
|
89
|
-
(new_status, status_reason, outputs, datetime.utcnow(), uuid),
|
|
90
|
-
)
|
|
91
|
-
updated = cur.rowcount > 0
|
|
92
|
-
conn.commit()
|
|
93
|
-
return updated
|
|
94
|
-
finally:
|
|
95
|
-
conn.close()
|
|
96
|
-
|
|
97
|
-
def get_deployment(uuid: str) -> Optional[dict]:
|
|
98
|
-
"""
|
|
99
|
-
Fetch a single deployment row by uuid. Returns None if not found.
|
|
100
|
-
"""
|
|
101
|
-
conn = get_conn()
|
|
102
|
-
try:
|
|
103
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
104
|
-
cur.execute("SELECT * FROM deployments WHERE uuid = %s", (uuid,))
|
|
105
|
-
row = cur.fetchone()
|
|
106
|
-
return dict(row) if row else None
|
|
107
|
-
finally:
|
|
108
|
-
conn.close()
|
|
109
|
-
|
|
110
|
-
def list_deployments(user_sub: str) -> list:
|
|
111
|
-
"""
|
|
112
|
-
Fetch all deployments owned by a user, ordered by creation time desc.
|
|
113
|
-
"""
|
|
114
|
-
conn = get_conn()
|
|
115
|
-
try:
|
|
116
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
117
|
-
cur.execute(
|
|
118
|
-
"SELECT * FROM deployments WHERE sub = %s ORDER BY creation_time DESC",
|
|
119
|
-
(user_sub,),
|
|
120
|
-
)
|
|
121
|
-
return [dict(r) for r in cur.fetchall()]
|
|
122
|
-
finally:
|
|
123
|
-
conn.close()
|
|
124
|
-
|
|
125
|
-
def check_connection() -> str:
|
|
126
|
-
"""
|
|
127
|
-
Returns 'connected' or an error string.
|
|
128
|
-
Used by /health.
|
|
129
|
-
"""
|
|
130
|
-
try:
|
|
131
|
-
conn = get_conn()
|
|
132
|
-
conn.close()
|
|
133
|
-
return "connected"
|
|
134
|
-
except Exception as exc:
|
|
135
|
-
return f"error: {exc}"
|
|
136
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|