Skip to content

Commit 371d852

Browse files
authored
Merge pull request #1194 from transformerlab/add/team-secrets
Add access for team secrets
2 parents 78e07af + a78009c commit 371d852

8 files changed

Lines changed: 870 additions & 68 deletions

File tree

api/transformerlab/routers/compute_provider.py

Lines changed: 54 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import time
5+
import json
56
import configparser
67
from pathlib import Path
78
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Request
@@ -27,8 +28,7 @@
2728
ProviderTemplateFileUploadResponse,
2829
ResumeFromCheckpointRequest,
2930
)
30-
from transformerlab.shared.models.models import ProviderType, TeamComputeProvider
31-
from transformerlab.compute_providers.base import ComputeProvider
31+
from transformerlab.shared.models.models import ProviderType
3232
from transformerlab.compute_providers.models import (
3333
ClusterConfig,
3434
ClusterStatus,
@@ -45,6 +45,7 @@
4545
read_github_pat_from_workspace,
4646
generate_github_clone_setup,
4747
)
48+
from transformerlab.shared.secret_utils import load_team_secrets, replace_secrets_in_dict, replace_secret_placeholders
4849
from typing import Any
4950

5051
router = APIRouter(prefix="/compute_provider", tags=["compute_provider"])
@@ -59,17 +60,6 @@ def _sanitize_cluster_basename(base_name: Optional[str]) -> str:
5960
return normalized or "remote-template"
6061

6162

62-
def _get_provider_instances(providers: list[TeamComputeProvider]) -> Dict[str, ComputeProvider]:
63-
"""Instantiate providers safely."""
64-
instances: Dict[str, ComputeProvider] = {}
65-
for provider in providers:
66-
try:
67-
instances[provider.id] = get_provider_instance(provider)
68-
except Exception as exc:
69-
print(f"Failed to instantiate provider {provider.id}: {exc}")
70-
return instances
71-
72-
7363
@router.post("/{provider_id}/task/{task_id}/file-upload", response_model=ProviderTemplateFileUploadResponse)
7464
async def upload_task_file_for_provider(
7565
provider_id: str,
@@ -232,7 +222,6 @@ async def get_usage_report(
232222
Aggregates usage data by user, provider, and resources.
233223
Only accessible to team owners.
234224
"""
235-
import json
236225
from datetime import datetime
237226
from lab import Experiment
238227

@@ -562,46 +551,6 @@ async def check_provider(
562551
# ============================================================================
563552

564553

565-
@router.post("/{provider_id}/clusters/{cluster_name}/launch")
566-
async def launch_cluster(
567-
provider_id: str,
568-
cluster_name: str,
569-
config: ClusterConfig,
570-
user_and_team=Depends(get_user_and_team),
571-
session: AsyncSession = Depends(get_async_session),
572-
):
573-
"""
574-
Launch/provision a new cluster using the specified provider.
575-
Requires X-Team-Id header and team membership.
576-
"""
577-
team_id = user_and_team["team_id"]
578-
579-
provider = await get_team_provider(session, team_id, provider_id)
580-
if not provider:
581-
raise HTTPException(status_code=404, detail="Provider not found")
582-
583-
try:
584-
# Get provider instance
585-
provider_instance = get_provider_instance(provider)
586-
587-
if not config.cluster_name:
588-
config.cluster_name = cluster_name
589-
config.provider_name = provider.name
590-
config.provider_id = provider.id
591-
592-
# Launch cluster
593-
result = provider_instance.launch_cluster(cluster_name, config)
594-
595-
return {
596-
"status": "success",
597-
"message": f"Cluster '{cluster_name}' launch initiated",
598-
"cluster_name": cluster_name,
599-
"result": result,
600-
}
601-
except Exception:
602-
raise HTTPException(status_code=500, detail="Failed to launch cluster")
603-
604-
605554
def _get_aws_credentials_from_file(profile_name: str = "transformerlab-s3") -> Tuple[Optional[str], Optional[str]]:
606555
"""
607556
Read AWS credentials from ~/.aws/credentials file for the specified profile.
@@ -802,6 +751,9 @@ async def _launch_sweep_jobs(
802751

803752
provider_display_name = request.provider_name or provider.name
804753

754+
# Load team secrets for template replacement
755+
team_secrets = await load_team_secrets()
756+
805757
# Generate all parameter combinations
806758
param_names = list(sweep_config.keys())
807759
param_values = [sweep_config[name] for name in param_names]
@@ -832,6 +784,11 @@ async def _launch_sweep_jobs(
832784

833785
# Prepare environment variables
834786
env_vars = request.env_vars.copy() if request.env_vars else {}
787+
788+
# Replace {{secret.<name>}} patterns in env_vars
789+
if env_vars and team_secrets:
790+
env_vars = replace_secrets_in_dict(env_vars, team_secrets)
791+
835792
env_vars["_TFL_JOB_ID"] = str(child_job_id)
836793
env_vars["_TFL_EXPERIMENT_ID"] = request.experiment_id
837794

@@ -872,11 +829,25 @@ async def _launch_sweep_jobs(
872829
)
873830
setup_commands.append(github_setup)
874831

832+
# Add user-provided setup if any (replace secrets in setup)
875833
if request.setup:
876-
setup_commands.append(request.setup)
834+
setup_with_secrets = (
835+
replace_secret_placeholders(request.setup, team_secrets) if team_secrets else request.setup
836+
)
837+
setup_commands.append(setup_with_secrets)
877838

878839
final_setup = ";".join(setup_commands) if setup_commands else None
879840

841+
# Replace secrets in command
842+
command_with_secrets = (
843+
replace_secret_placeholders(request.command, team_secrets) if team_secrets else request.command
844+
)
845+
846+
# Replace secrets in parameters if present
847+
parameters_with_secrets = merged_params
848+
if merged_params and team_secrets:
849+
parameters_with_secrets = replace_secrets_in_dict(merged_params, team_secrets)
850+
880851
# Store child job data
881852
child_job_data = {
882853
"parent_sweep_job_id": str(parent_job_id),
@@ -886,7 +857,7 @@ async def _launch_sweep_jobs(
886857
"task_name": f"{request.task_name or 'Task'} (Sweep {i + 1}/{total_configs})"
887858
if request.task_name
888859
else None,
889-
"command": request.command,
860+
"command": command_with_secrets,
890861
"cluster_name": formatted_cluster_name,
891862
"subtype": request.subtype,
892863
"cpus": request.cpus,
@@ -897,7 +868,7 @@ async def _launch_sweep_jobs(
897868
"setup": final_setup,
898869
"env_vars": env_vars if env_vars else None,
899870
"file_mounts": request.file_mounts or None,
900-
"parameters": merged_params or None,
871+
"parameters": parameters_with_secrets or None,
901872
"provider_id": provider.id,
902873
"provider_type": provider.type,
903874
"provider_name": provider_display_name,
@@ -923,7 +894,7 @@ async def _launch_sweep_jobs(
923894
cluster_name=formatted_cluster_name,
924895
provider_name=provider_display_name,
925896
provider_id=provider.id,
926-
command=request.command,
897+
command=command_with_secrets,
927898
setup=final_setup,
928899
env_vars=env_vars,
929900
cpus=request.cpus,
@@ -1105,9 +1076,16 @@ async def launch_template_on_provider(
11051076

11061077
provider_display_name = request.provider_name or provider.name
11071078

1079+
# Load team secrets for template replacement
1080+
team_secrets = await load_team_secrets()
1081+
11081082
# Prepare environment variables - start with a copy of requested env_vars
11091083
env_vars = request.env_vars.copy() if request.env_vars else {}
11101084

1085+
# Replace {{secret.<name>}} patterns in env_vars
1086+
if env_vars and team_secrets:
1087+
env_vars = replace_secrets_in_dict(env_vars, team_secrets)
1088+
11111089
# Get AWS credentials from stored credentials file (transformerlab-s3 profile)
11121090
aws_profile = "transformerlab-s3"
11131091
if os.getenv("TFL_API_STORAGE_URI"):
@@ -1132,9 +1110,10 @@ async def launch_template_on_provider(
11321110
)
11331111
setup_commands.append(github_setup)
11341112

1135-
# Add user-provided setup if any
1113+
# Add user-provided setup if any (replace secrets in setup)
11361114
if request.setup:
1137-
setup_commands.append(request.setup)
1115+
setup_with_secrets = replace_secret_placeholders(request.setup, team_secrets) if team_secrets else request.setup
1116+
setup_commands.append(setup_with_secrets)
11381117

11391118
final_setup = ";".join(setup_commands) if setup_commands else None
11401119

@@ -1159,9 +1138,21 @@ async def launch_template_on_provider(
11591138
# env_vars["AWS_ACCESS_KEY_ID"] = aws_access_key_id
11601139
# env_vars["AWS_SECRET_ACCESS_KEY"] = aws_secret_access_key
11611140

1141+
# Replace secrets in command
1142+
command_with_secrets = (
1143+
replace_secret_placeholders(request.command, team_secrets) if team_secrets else request.command
1144+
)
1145+
1146+
# Replace secrets in parameters if present
1147+
parameters_with_secrets = None
1148+
if request.parameters and team_secrets:
1149+
parameters_with_secrets = replace_secrets_in_dict(request.parameters, team_secrets)
1150+
else:
1151+
parameters_with_secrets = request.parameters
1152+
11621153
job_data = {
11631154
"task_name": request.task_name,
1164-
"command": request.command,
1155+
"command": command_with_secrets,
11651156
"cluster_name": formatted_cluster_name,
11661157
"subtype": request.subtype,
11671158
"interactive_type": request.interactive_type,
@@ -1173,7 +1164,7 @@ async def launch_template_on_provider(
11731164
"setup": final_setup,
11741165
"env_vars": env_vars if env_vars else None,
11751166
"file_mounts": request.file_mounts or None,
1176-
"parameters": request.parameters or None,
1167+
"parameters": parameters_with_secrets or None,
11771168
"provider_id": provider.id,
11781169
"provider_type": provider.type,
11791170
"provider_name": provider_display_name,
@@ -1197,7 +1188,7 @@ async def launch_template_on_provider(
11971188
cluster_name=formatted_cluster_name,
11981189
provider_name=provider_display_name,
11991190
provider_id=provider.id,
1200-
command=request.command,
1191+
command=command_with_secrets,
12011192
setup=final_setup,
12021193
env_vars=env_vars,
12031194
cpus=request.cpus,

api/transformerlab/routers/teams.py

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
1+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
22
from fastapi.responses import FileResponse, Response
33
from sqlalchemy.ext.asyncio import AsyncSession
44
from transformerlab.utils.api_key_utils import mask_key
@@ -9,13 +9,14 @@
99
from transformerlab.utils.email import send_team_invitation_email
1010
from transformerlab.shared.remote_workspace import create_bucket_for_team
1111

12-
from pydantic import BaseModel, EmailStr
12+
from pydantic import BaseModel, EmailStr, Field
1313
from typing import Optional
1414
from sqlalchemy import select, delete, update, func, and_
1515
from datetime import datetime, timedelta
1616
from os import getenv
1717
from PIL import Image
1818
import io
19+
import json
1920
import logging
2021

2122
from lab import Experiment
@@ -71,6 +72,10 @@ class GitHubPATRequest(BaseModel):
7172
pat: Optional[str] = None
7273

7374

75+
class TeamSecretsRequest(BaseModel):
76+
secrets: dict[str, str] = Field(..., description="Team secrets as key-value pairs")
77+
78+
7479
router = APIRouter(tags=["teams"])
7580

7681

@@ -1019,3 +1024,99 @@ async def delete_team_logo(
10191024
except Exception as e:
10201025
print(f"Error deleting team logo: {e}")
10211026
raise HTTPException(status_code=500, detail="Failed to delete team logo")
1027+
1028+
1029+
@router.get("/teams/{team_id}/secrets")
1030+
async def get_team_secrets(
1031+
team_id: str,
1032+
user_and_team=Depends(get_user_and_team),
1033+
include_values: bool = Query(False, description="Include actual secret values (only for team owners)"),
1034+
):
1035+
"""
1036+
Get team secrets.
1037+
- Team members can view secret keys only (values are masked).
1038+
- Team owners can view actual values by setting include_values=true.
1039+
"""
1040+
# Verify team_id matches the one in header
1041+
if team_id != user_and_team["team_id"]:
1042+
raise HTTPException(status_code=400, detail="Team ID mismatch")
1043+
1044+
workspace_dir = await get_workspace_dir()
1045+
secrets_path = storage.join(workspace_dir, "team_secrets.json")
1046+
1047+
try:
1048+
if not await storage.exists(secrets_path):
1049+
return {"status": "success", "secrets": {}}
1050+
1051+
async with await storage.open(secrets_path, "r") as f:
1052+
secrets = json.loads(await f.read())
1053+
1054+
# Check if user is team owner and requested values
1055+
is_owner = user_and_team.get("role") == TeamRole.OWNER.value
1056+
if include_values and is_owner:
1057+
# Return actual values for team owners
1058+
return {
1059+
"status": "success",
1060+
"secrets": secrets,
1061+
"secret_keys": list(secrets.keys()),
1062+
}
1063+
else:
1064+
# Mask all secret values for security
1065+
masked_secrets = {key: "***" for key in secrets.keys()}
1066+
return {
1067+
"status": "success",
1068+
"secrets": masked_secrets,
1069+
"secret_keys": list(secrets.keys()),
1070+
}
1071+
except Exception as e:
1072+
print(f"Error reading team secrets: {e}")
1073+
raise HTTPException(status_code=500, detail="Failed to read team secrets")
1074+
1075+
1076+
@router.put("/teams/{team_id}/secrets")
1077+
async def set_team_secrets(
1078+
team_id: str,
1079+
secrets_data: TeamSecretsRequest,
1080+
owner_info=Depends(require_team_owner),
1081+
):
1082+
"""
1083+
Set team secrets. Only team owners can set/update secrets.
1084+
Stored in workspace/team_secrets.json file.
1085+
"""
1086+
# Verify team_id matches the one in header
1087+
if team_id != owner_info["team_id"]:
1088+
raise HTTPException(status_code=400, detail="Team ID mismatch")
1089+
1090+
workspace_dir = await get_workspace_dir()
1091+
secrets_path = storage.join(workspace_dir, "team_secrets.json")
1092+
1093+
try:
1094+
# Validate that all keys are valid environment variable names
1095+
# Environment variable names can contain letters, numbers, and underscores
1096+
import re
1097+
1098+
valid_key_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
1099+
for key in secrets_data.secrets.keys():
1100+
if not valid_key_pattern.match(key):
1101+
raise HTTPException(
1102+
status_code=400,
1103+
detail=f"Invalid secret key '{key}'. Secret keys must start with a letter or underscore and contain only letters, numbers, and underscores.",
1104+
)
1105+
1106+
# Ensure workspace directory exists
1107+
await storage.makedirs(workspace_dir, exist_ok=True)
1108+
1109+
# Write secrets to file
1110+
async with await storage.open(secrets_path, "w") as f:
1111+
await f.write(json.dumps(secrets_data.secrets, indent=2))
1112+
1113+
return {
1114+
"status": "success",
1115+
"message": "Team secrets saved successfully",
1116+
"secret_keys": list(secrets_data.secrets.keys()),
1117+
}
1118+
except HTTPException:
1119+
raise
1120+
except Exception as e:
1121+
print(f"Error saving team secrets: {e}")
1122+
raise HTTPException(status_code=500, detail="Failed to save team secrets")

0 commit comments

Comments
 (0)