laniakea-api-server 0.2.1__tar.gz → 0.2.3__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.
Files changed (25) hide show
  1. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/PKG-INFO +1 -1
  2. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/__init__.py +1 -1
  3. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/auth.py +30 -17
  4. laniakea_api_server-0.2.3/laniakea_api/database.py +290 -0
  5. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/main.py +14 -5
  6. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/models.py +1 -0
  7. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/routers/deployments.py +100 -7
  8. laniakea_api_server-0.2.3/laniakea_api/routers/tfstate.py +111 -0
  9. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/pyproject.toml +1 -1
  10. laniakea_api_server-0.2.1/laniakea_api/database.py +0 -136
  11. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/.github/workflows/publish.yml +0 -0
  12. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/.gitignore +0 -0
  13. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/LICENSE +0 -0
  14. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/README.md +0 -0
  15. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/cli.py +0 -0
  16. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/config.py +0 -0
  17. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/credential_parser.py +0 -0
  18. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/installer.py +0 -0
  19. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/queue.py +0 -0
  20. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/routers/.health.py.swp +0 -0
  21. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/routers/__init__.py +0 -0
  22. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/routers/agent.py +0 -0
  23. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/routers/agents.py +0 -0
  24. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/laniakea_api/routers/credentials.py +0 -0
  25. {laniakea_api_server-0.2.1 → laniakea_api_server-0.2.3}/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.1
3
+ Version: 0.2.3
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
@@ -2,4 +2,4 @@
2
2
  OIDC-authenticated gateway for enqueuing cloud deployment jobs.
3
3
  """
4
4
 
5
- __version__ = "0.1.4"
5
+ __version__ = "0.2.2"
@@ -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
- async def verify_agent_token(credentials: HTTPAuthorizationCredentials = Depends(_bearer),) -> str:
101
+ def decode_agent_token(token: str) -> str:
102
102
  """
103
- This function validates the tokens used by workers.
104
- It doesn't use the user's key, but a dedicated secret key
105
- called AGENT_MASTER_PASSWORD.
106
- If the worker sends a valid token signed with this master password,
107
- the API trusts the worker and allows it to fetch or update the status of deployment jobs.
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
- HTCondor pool-password like.
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
- credentials.credentials,
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,290 @@
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
+ "SELECT * FROM deployments WHERE sub = %s ORDER BY creation_time DESC",
120
+ (user_sub,),
121
+ )
122
+ return [dict(r) for r in cur.fetchall()]
123
+ finally:
124
+ conn.close()
125
+
126
+ def delete_deployment(uuid: str) -> bool:
127
+ """
128
+ Remove a deployment record. Returns True if a row was deleted.
129
+ Only the API decides WHEN this is allowed (terminal states):
130
+ this helper just executes the removal.
131
+ """
132
+ conn = get_conn()
133
+ try:
134
+ with conn.cursor() as cur:
135
+ cur.execute("DELETE FROM deployments WHERE uuid = %s", (uuid,))
136
+ deleted = cur.rowcount > 0
137
+ conn.commit()
138
+ return deleted
139
+ finally:
140
+ conn.close()
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Deployment payload (needed to rebuild a destroy job)
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def save_payload(uuid: str, payload: dict) -> bool:
147
+ """
148
+ Persist the job payload (deployment_info WITHOUT the auth block)
149
+ in the deployments.payload JSONB column. Used later to enqueue
150
+ a destroy job for this deployment.
151
+ """
152
+ conn = get_conn()
153
+ try:
154
+ with conn.cursor() as cur:
155
+ cur.execute(
156
+ "UPDATE deployments SET payload = %s WHERE uuid = %s",
157
+ (json.dumps(payload), uuid),
158
+ )
159
+ updated = cur.rowcount > 0
160
+ conn.commit()
161
+ return updated
162
+ finally:
163
+ conn.close()
164
+
165
+ def get_payload(uuid: str) -> Optional[dict]:
166
+ """
167
+ Fetch the stored job payload for a deployment. Returns None if missing.
168
+ psycopg2 deserializes JSONB to dict automatically.
169
+ """
170
+ conn = get_conn()
171
+ try:
172
+ with conn.cursor() as cur:
173
+ cur.execute("SELECT payload FROM deployments WHERE uuid = %s", (uuid,))
174
+ row = cur.fetchone()
175
+ return row[0] if row and row[0] else None
176
+ finally:
177
+ conn.close()
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Terraform state (HTTP backend) — table: tf_states
181
+ # ---------------------------------------------------------------------------
182
+
183
+ def tfstate_get(uuid: str) -> Optional[dict]:
184
+ """
185
+ Fetch the Terraform state blob for a deployment.
186
+ Returns {"state": bytes} or None if no state exists (or state is empty).
187
+ """
188
+ conn = get_conn()
189
+ try:
190
+ with conn.cursor() as cur:
191
+ cur.execute("SELECT state FROM tf_states WHERE uuid = %s", (uuid,))
192
+ row = cur.fetchone()
193
+ if row is None or row[0] is None or len(row[0]) == 0:
194
+ return None
195
+ return {"state": bytes(row[0])}
196
+ finally:
197
+ conn.close()
198
+
199
+ def tfstate_set(uuid: str, state: bytes) -> None:
200
+ """
201
+ Insert or update the Terraform state blob for a deployment.
202
+ Called by Terraform (via the API) on every state push.
203
+ """
204
+ conn = get_conn()
205
+ try:
206
+ with conn.cursor() as cur:
207
+ cur.execute(
208
+ """
209
+ INSERT INTO tf_states (uuid, state, updated_at)
210
+ VALUES (%s, %s, now())
211
+ ON CONFLICT (uuid) DO UPDATE
212
+ SET state = EXCLUDED.state, updated_at = now()
213
+ """,
214
+ (uuid, state),
215
+ )
216
+ conn.commit()
217
+ finally:
218
+ conn.close()
219
+
220
+ def tfstate_delete(uuid: str) -> bool:
221
+ """
222
+ Remove the Terraform state row (after a successful destroy).
223
+ """
224
+ conn = get_conn()
225
+ try:
226
+ with conn.cursor() as cur:
227
+ cur.execute("DELETE FROM tf_states WHERE uuid = %s", (uuid,))
228
+ deleted = cur.rowcount > 0
229
+ conn.commit()
230
+ return deleted
231
+ finally:
232
+ conn.close()
233
+
234
+ def tfstate_get_lock(uuid: str) -> Optional[str]:
235
+ """
236
+ Return the current lock info JSON (str) or None if unlocked/missing.
237
+ """
238
+ conn = get_conn()
239
+ try:
240
+ with conn.cursor() as cur:
241
+ cur.execute("SELECT lock_info FROM tf_states WHERE uuid = %s", (uuid,))
242
+ row = cur.fetchone()
243
+ return row[0] if row and row[0] else None
244
+ finally:
245
+ conn.close()
246
+
247
+ def tfstate_set_lock(uuid: str, lock_info: str) -> None:
248
+ """
249
+ Store lock info. The row may not exist yet on the very first lock
250
+ (before any state has been pushed): insert with an empty state.
251
+ """
252
+ conn = get_conn()
253
+ try:
254
+ with conn.cursor() as cur:
255
+ cur.execute(
256
+ """
257
+ INSERT INTO tf_states (uuid, state, lock_info)
258
+ VALUES (%s, ''::bytea, %s)
259
+ ON CONFLICT (uuid) DO UPDATE
260
+ SET lock_info = EXCLUDED.lock_info
261
+ """,
262
+ (uuid, lock_info),
263
+ )
264
+ conn.commit()
265
+ finally:
266
+ conn.close()
267
+
268
+ def tfstate_clear_lock(uuid: str) -> None:
269
+ """
270
+ Release the lock for a deployment state.
271
+ """
272
+ conn = get_conn()
273
+ try:
274
+ with conn.cursor() as cur:
275
+ cur.execute("UPDATE tf_states SET lock_info = NULL WHERE uuid = %s", (uuid,))
276
+ conn.commit()
277
+ finally:
278
+ conn.close()
279
+
280
+ def check_connection() -> str:
281
+ """
282
+ Returns 'connected' or an error string.
283
+ Used by /health.
284
+ """
285
+ try:
286
+ conn = get_conn()
287
+ conn.close()
288
+ return "connected"
289
+ except Exception as exc:
290
+ 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.1.4",
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
-
@@ -63,6 +63,7 @@ class DeploymentRequest(BaseModel):
63
63
  auth: dict # { aai_token, sub, group }
64
64
  orchestrator: dict # target_provider, desired_orchestrator, endpoint
65
65
  selected_provider: str # OpenStack | AWS
66
+ service_type: Optional[str] = "galaxy"
66
67
  cloud_providers: dict
67
68
 
68
69
 
@@ -1,10 +1,11 @@
1
1
  """
2
2
  user deployment endpoints:
3
3
 
4
- GET /api/deployments
5
- GET /api/deployments/{uuid}
6
- POST /api/deployments
7
- GET /api/deployments/{uuid}/logs
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.1"
7
+ version = "0.2.3"
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
-