|
1 | 1 | """Shared utilities for participant onboarding scripts.""" |
2 | 2 |
|
3 | 3 | import os |
| 4 | +import subprocess |
| 5 | +import time |
4 | 6 | from datetime import datetime, timezone |
5 | 7 | from pathlib import Path |
6 | 8 | from typing import Any |
7 | 9 |
|
| 10 | +import google.auth |
8 | 11 | import requests |
9 | | -from google.cloud import firestore, secretmanager # type: ignore[attr-defined] |
| 12 | +from google.auth import jwt as google_jwt |
| 13 | +from google.cloud import firestore |
10 | 14 | from google.oauth2 import credentials as oauth2_credentials |
11 | 15 | from rich.console import Console |
12 | 16 |
|
@@ -50,38 +54,111 @@ def get_github_user() -> str | None: |
50 | 54 | return github_user |
51 | 55 |
|
52 | 56 |
|
53 | | -def fetch_token_from_secret_manager( |
54 | | - github_handle: str, bootcamp_name: str, project_id: str |
| 57 | +def fetch_token_from_service( # noqa: PLR0911 |
| 58 | + github_handle: str, token_service_url: str | None = None |
55 | 59 | ) -> tuple[bool, str | None, str | None]: |
56 | 60 | """ |
57 | | - Fetch Firebase token from GCP Secret Manager. |
| 61 | + Fetch fresh Firebase token from Cloud Run token service. |
| 62 | +
|
| 63 | + This generates tokens on-demand using the workspace service account identity. |
| 64 | + Tokens are always fresh (< 1 hour old). |
58 | 65 |
|
59 | 66 | Parameters |
60 | 67 | ---------- |
61 | 68 | github_handle : str |
62 | 69 | GitHub handle of the participant. |
63 | | - bootcamp_name : str |
64 | | - Name of the bootcamp. |
65 | | - project_id : str |
66 | | - GCP project ID. |
| 70 | + token_service_url : str | None, optional |
| 71 | + Token service URL. If not provided, reads from TOKEN_SERVICE_URL env var |
| 72 | + or .token-service-url file. |
67 | 73 |
|
68 | 74 | Returns |
69 | 75 | ------- |
70 | 76 | tuple[bool, str | None, str | None] |
71 | 77 | Tuple of (success, token_value, error_message). |
72 | 78 | """ |
73 | 79 | try: |
74 | | - client = secretmanager.SecretManagerServiceClient() |
75 | | - secret_name = f"{bootcamp_name}-token-{github_handle}" |
76 | | - name = f"projects/{project_id}/secrets/{secret_name}/versions/latest" |
| 80 | + # Get token service URL |
| 81 | + if not token_service_url: |
| 82 | + token_service_url = os.environ.get("TOKEN_SERVICE_URL") |
| 83 | + |
| 84 | + if not token_service_url: |
| 85 | + # Try reading from config file |
| 86 | + config_file = Path.home() / ".token-service-url" |
| 87 | + if config_file.exists(): |
| 88 | + token_service_url = config_file.read_text().strip() |
| 89 | + |
| 90 | + if not token_service_url: |
| 91 | + return ( |
| 92 | + False, |
| 93 | + None, |
| 94 | + ( |
| 95 | + "Token service URL not found. Set TOKEN_SERVICE_URL environment " |
| 96 | + "variable or ensure .token-service-url file exists." |
| 97 | + ), |
| 98 | + ) |
| 99 | + |
| 100 | + # Get identity token for authentication |
| 101 | + # Try to get identity token |
| 102 | + # In workspaces, this will use the compute service account |
| 103 | + credentials, project_id = google.auth.default() # type: ignore[no-untyped-call] |
| 104 | + |
| 105 | + # Request an identity token instead of an access token |
| 106 | + # This is required for Cloud Run authentication |
| 107 | + if hasattr(credentials, "signer"): |
| 108 | + # Service account - can create identity tokens |
| 109 | + now = int(time.time()) |
| 110 | + payload = { |
| 111 | + "iss": credentials.service_account_email, |
| 112 | + "sub": credentials.service_account_email, |
| 113 | + "aud": token_service_url, |
| 114 | + "iat": now, |
| 115 | + "exp": now + 3600, |
| 116 | + } |
| 117 | + id_token = google_jwt.encode(credentials.signer, payload) # type: ignore[no-untyped-call] |
| 118 | + else: |
| 119 | + # User credentials or other - use gcloud |
| 120 | + try: |
| 121 | + result = subprocess.run( |
| 122 | + ["gcloud", "auth", "print-identity-token"], |
| 123 | + check=False, |
| 124 | + capture_output=True, |
| 125 | + text=True, |
| 126 | + timeout=10, |
| 127 | + ) |
| 128 | + if result.returncode != 0: |
| 129 | + return False, None, f"Failed to get identity token: {result.stderr}" |
| 130 | + id_token = result.stdout.strip() |
| 131 | + except Exception as e: |
| 132 | + return False, None, f"Failed to run gcloud: {str(e)}" |
| 133 | + |
| 134 | + if not id_token: |
| 135 | + return False, None, "Failed to get identity token for authentication" |
| 136 | + |
| 137 | + # Call token service |
| 138 | + url = f"{token_service_url.rstrip('/')}/generate-token" |
| 139 | + headers = { |
| 140 | + "Authorization": f"Bearer {id_token}", |
| 141 | + "Content-Type": "application/json", |
| 142 | + } |
| 143 | + payload = {"github_handle": github_handle} |
| 144 | + |
| 145 | + response = requests.post(url, json=payload, headers=headers, timeout=30) |
| 146 | + |
| 147 | + if response.status_code != 200: |
| 148 | + error_data = response.json() if response.content else {} |
| 149 | + error_msg = error_data.get("message", f"HTTP {response.status_code}") |
| 150 | + return False, None, f"Token service error: {error_msg}" |
| 151 | + |
| 152 | + data = response.json() |
| 153 | + token = data.get("token") |
77 | 154 |
|
78 | | - response = client.access_secret_version(request={"name": name}) |
79 | | - token = response.payload.data.decode("UTF-8") |
| 155 | + if not token: |
| 156 | + return False, None, "No token in service response" |
80 | 157 |
|
81 | 158 | return True, token, None |
82 | 159 |
|
83 | 160 | except Exception as e: |
84 | | - return False, None, str(e) |
| 161 | + return False, None, f"Failed to fetch token from service: {str(e)}" |
85 | 162 |
|
86 | 163 |
|
87 | 164 | def exchange_custom_token_for_id_token( |
@@ -359,6 +436,86 @@ def create_env_file( |
359 | 436 | return False |
360 | 437 |
|
361 | 438 |
|
| 439 | +def check_onboarded_status( |
| 440 | + db: firestore.Client, github_handle: str |
| 441 | +) -> tuple[bool, bool]: |
| 442 | + """ |
| 443 | + Check if participant is already onboarded. |
| 444 | +
|
| 445 | + Parameters |
| 446 | + ---------- |
| 447 | + db : firestore.Client |
| 448 | + Firestore client instance. |
| 449 | + github_handle : str |
| 450 | + GitHub handle of the participant. |
| 451 | +
|
| 452 | + Returns |
| 453 | + ------- |
| 454 | + tuple[bool, bool] |
| 455 | + Tuple of (success, is_onboarded). |
| 456 | + """ |
| 457 | + try: |
| 458 | + doc_ref = db.collection("participants").document(github_handle) |
| 459 | + doc = doc_ref.get() |
| 460 | + |
| 461 | + if not doc.exists: |
| 462 | + return True, False |
| 463 | + |
| 464 | + data = doc.to_dict() |
| 465 | + is_onboarded = data.get("onboarded", False) if data else False |
| 466 | + return True, is_onboarded |
| 467 | + |
| 468 | + except Exception as e: |
| 469 | + console.print(f"[yellow]⚠ Failed to check onboarded status:[/yellow] {e}") |
| 470 | + return False, False |
| 471 | + |
| 472 | + |
| 473 | +def validate_env_file(env_path: Path) -> tuple[bool, list[str]]: |
| 474 | + """ |
| 475 | + Validate if .env file exists and contains all required keys. |
| 476 | +
|
| 477 | + Parameters |
| 478 | + ---------- |
| 479 | + env_path : Path |
| 480 | + Path to the .env file. |
| 481 | +
|
| 482 | + Returns |
| 483 | + ------- |
| 484 | + tuple[bool, list[str]] |
| 485 | + Tuple of (is_complete, missing_keys). |
| 486 | + """ |
| 487 | + if not env_path.exists(): |
| 488 | + return False, ["File does not exist"] |
| 489 | + |
| 490 | + required_keys = [ |
| 491 | + "OPENAI_API_KEY", |
| 492 | + "EMBEDDING_BASE_URL", |
| 493 | + "EMBEDDING_API_KEY", |
| 494 | + "LANGFUSE_SECRET_KEY", |
| 495 | + "LANGFUSE_PUBLIC_KEY", |
| 496 | + "LANGFUSE_HOST", |
| 497 | + "WEAVIATE_HTTP_HOST", |
| 498 | + "WEAVIATE_GRPC_HOST", |
| 499 | + "WEAVIATE_API_KEY", |
| 500 | + ] |
| 501 | + |
| 502 | + try: |
| 503 | + with open(env_path) as f: |
| 504 | + content = f.read() |
| 505 | + |
| 506 | + missing_keys = [] |
| 507 | + for key in required_keys: |
| 508 | + # Check if key exists and has a non-empty value |
| 509 | + if f'{key}=""' in content or key not in content: |
| 510 | + missing_keys.append(key) |
| 511 | + |
| 512 | + return len(missing_keys) == 0, missing_keys |
| 513 | + |
| 514 | + except Exception as e: |
| 515 | + console.print(f"[yellow]⚠ Failed to validate .env file:[/yellow] {e}") |
| 516 | + return False, [str(e)] |
| 517 | + |
| 518 | + |
362 | 519 | def update_onboarded_status( |
363 | 520 | db: firestore.Client, github_handle: str |
364 | 521 | ) -> tuple[bool, str | None]: |
|
0 commit comments