Skip to content

Commit

Permalink
feat: add teams support for vercel syncs (#401)
Browse files Browse the repository at this point in the history
* feat: add teams support for vercel syncs

* fix: misc fixes for backward compatibility

* fix: source environment context when syncing secrets

---------

Co-authored-by: Nimish <[email protected]>
Co-authored-by: Nimish <[email protected]>
  • Loading branch information
3 people authored Dec 7, 2024
1 parent 22f4916 commit a893197
Show file tree
Hide file tree
Showing 11 changed files with 352 additions and 172 deletions.
6 changes: 4 additions & 2 deletions backend/api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def trigger_sync_tasks(env_sync):
job_id = job.get_id()

EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync)

elif env_sync.service == ServiceConfig.VERCEL["id"]:
env_sync.status = EnvironmentSync.IN_PROGRESS
env_sync.save()
Expand Down Expand Up @@ -792,13 +792,15 @@ def perform_vercel_sync(environment_sync):
vercel_sync_options = environment_sync.options

vercel_project = vercel_sync_options.get("project")
vercel_team = vercel_sync_options.get("team")
vercel_environment = vercel_sync_options.get("environment", "production")
vercel_secret_type = vercel_sync_options.get("secret_type", "encrypted")

success, sync_data = sync_vercel_secrets(
secrets,
environment_sync.authentication.id,
vercel_project["id"],
vercel_team["id"] if vercel_team is not None else None,
vercel_environment,
vercel_secret_type,
)
Expand Down Expand Up @@ -853,4 +855,4 @@ def perform_vercel_sync(environment_sync):

environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.FAILED
environment_sync.save()
environment_sync.save()
251 changes: 165 additions & 86 deletions backend/api/utils/syncing/vercel/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@

from api.utils.syncing.auth import get_credentials

VERCEL_API_BASE_URL = 'https://api.vercel.com'
VERCEL_API_BASE_URL = "https://api.vercel.com"


from graphene import ObjectType, List, ID, String


class VercelProjectType(ObjectType):
id = graphene.ID(required=True)
name = graphene.String(required=True)
environment = graphene.List(graphene.String)
id = ID(required=True)
name = String(required=True)
environment = List(String)


class VercelTeamProjectsType(ObjectType):
id = String(required=True)
team_name = String(required=True)
projects = List(VercelProjectType)


def get_vercel_credentials(credential_id):
Expand All @@ -21,14 +31,14 @@ def get_vercel_credentials(credential_id):

def get_vercel_headers(token):
"""Prepare headers for Vercel API requests."""
return {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}


def test_vercel_creds(credential_id):
"""Test if the Vercel credentials are valid."""
try:
token = get_vercel_credentials(credential_id)
url = f'{VERCEL_API_BASE_URL}/v2/user'
url = f"{VERCEL_API_BASE_URL}/v2/user"
response = requests.get(url, headers=get_vercel_headers(token))
return response.status_code == 200
except Exception:
Expand All @@ -38,53 +48,106 @@ def test_vercel_creds(credential_id):
def list_vercel_projects(credential_id):
"""
List all Vercel projects accessible with the provided credentials.
Returns a list of projects with their IDs, names, and available environments.
Includes personal projects as part of the "personal team" when teams are listed.
Returns a list of dictionaries with team names and their projects.
"""
try:
token = get_vercel_credentials(credential_id)
url = f'{VERCEL_API_BASE_URL}/v9/projects'
response = requests.get(url, headers=get_vercel_headers(token))

if response.status_code != 200:
raise Exception(f"Failed to list Vercel projects: {response.text}")

projects = response.json().get('projects', [])
return [
{
"id": project["id"],
"name": project["name"],
"environment": ["development", "preview", "production"]
}
for project in projects
]

# Fetch teams
teams_url = f"{VERCEL_API_BASE_URL}/v2/teams"
teams_response = requests.get(teams_url, headers=get_vercel_headers(token))
if teams_response.status_code != 200:
raise Exception(f"Failed to list Vercel teams: {teams_response.text}")

teams = teams_response.json().get("teams", [])

result = []

# Fetch projects for each team
for team in teams:
team_id = team["id"]
team_name = team["name"]

# Construct the URL based on whether it's a personal team
team_projects_url = (
f"{VERCEL_API_BASE_URL}/v9/projects"
if team_id is None
else f"{VERCEL_API_BASE_URL}/v9/projects?teamId={team_id}"
)
team_projects_response = requests.get(
team_projects_url, headers=get_vercel_headers(token)
)
if team_projects_response.status_code != 200:
print(
f"Failed to list projects for team {team_name}: {team_projects_response.text}"
)
continue

team_projects = team_projects_response.json().get("projects", [])
result.append(
{
"id": team_id,
"team_name": team_name,
"projects": [
{
"id": project["id"],
"name": project["name"],
"environment": ["development", "preview", "production"],
}
for project in team_projects
],
}
)

return result

except Exception as e:
raise Exception(f"Error listing Vercel projects: {str(e)}")


def get_existing_env_vars(token, project_id):
"""Retrieve all environment variables for a specific Vercel project."""
url = f'{VERCEL_API_BASE_URL}/v9/projects/{project_id}/env'
response = requests.get(url, headers=get_vercel_headers(token))
def get_existing_env_vars(token, project_id, team_id=None, target_environment=None):
"""
Retrieve environment variables for a specific Vercel project and environment.
Args:
token (str): Vercel API token
project_id (str): Project ID
team_id (str, optional): Team ID
target_environment (str, optional): Specific environment to filter by
"""
url = f"{VERCEL_API_BASE_URL}/v9/projects/{project_id}/env"
if team_id is not None:
url += f"?teamId={team_id}"
response = requests.get(url, headers=get_vercel_headers(token))

if response.status_code != 200:
raise Exception(f"Error retrieving environment variables: {response.text}")

envs = response.json().get("envs", [])

# Filter variables by target environment if specified
if target_environment:
envs = [env for env in envs if target_environment in env["target"]]

return {
env['key']: {
'id': env['id'],
'value': env['value'],
'target': env['target'],
'comment': env.get('comment'),
env["key"]: {
"id": env["id"],
"value": env["value"],
"target": env["target"],
"comment": env.get("comment"),
}
for env in response.json().get('envs', [])
for env in envs
}


def delete_env_var(token, project_id, env_var_id):
def delete_env_var(token, project_id, team_id, env_var_id):
"""Delete a Vercel environment variable using its ID."""
url = f'{VERCEL_API_BASE_URL}/v9/projects/{project_id}/env/{env_var_id}'
url = f"{VERCEL_API_BASE_URL}/v9/projects/{project_id}/env/{env_var_id}"
if team_id is not None:
url += f"?teamId={team_id}"
response = requests.delete(url, headers=get_vercel_headers(token))

if response.status_code != 200:
raise Exception(f"Error deleting environment variable: {response.text}")

Expand All @@ -93,76 +156,92 @@ def sync_vercel_secrets(
secrets,
credential_id,
project_id,
team_id,
environment="production",
secret_type="encrypted",
):
"""
Sync secrets to a Vercel project.
Sync secrets to a specific Vercel project environment.
Args:
secrets (list of tuple): List of (key, value, comment) tuples to sync
credential_id (str): The ID of the stored credentials
project_id (str): The Vercel project ID
team_id (str): The Vercel project team ID
environment (str): Target environment (development/preview/production/all)
secret_type (str): Type of secret (plain/encrypted/sensitive)
Returns:
tuple: (bool, dict) indicating success/failure and a message
"""
try:
token = get_vercel_credentials(credential_id)

# Determine target environments
target_environments = (
['production', 'preview', 'development']
if environment == 'all'
["production", "preview", "development"]
if environment == "all"
else [environment]
)

# Get existing environment variables
existing_env_vars = get_existing_env_vars(token, project_id)

# Prepare payload for bulk creation
payload = []
for key, value, comment in secrets:
# Check if the environment variable exists and needs updating
if key in existing_env_vars:
existing_var = existing_env_vars[key]
if (
value != existing_var['value']
or target_environments != existing_var['target']
or comment != existing_var.get('comment')
):
delete_env_var(token, project_id, existing_var['id'])

env_var = {
'key': key,
'value': value,
'type': secret_type,
'target': target_environments,
}
if comment:
env_var['comment'] = comment
payload.append(env_var)

# Delete environment variables not in the source
for key, env_var in existing_env_vars.items():
if not any(s[0] == key for s in secrets):
delete_env_var(token, project_id, env_var['id'])

# Bulk create environment variables
if payload:
url = f'{VERCEL_API_BASE_URL}/v10/projects/{project_id}/env?upsert=true'
response = requests.post(
url, headers=get_vercel_headers(token), json=payload

all_updates_successful = True
messages = []

# Process each target environment separately
for target_env in target_environments:
# Get existing environment variables for this specific environment
existing_env_vars = get_existing_env_vars(
token, project_id, team_id, target_environment=target_env
)

if response.status_code != 201:
raise Exception(f"Error creating environment variables: {response.text}")

return True, {
"message": f"Successfully synced secrets to Vercel project environments: {', '.join(target_environments)}"
}


# Prepare payload for bulk creation
payload = []
for key, value, comment in secrets:
# Check if the environment variable exists and needs updating
if key in existing_env_vars:
existing_var = existing_env_vars[key]
if (
value != existing_var["value"]
or comment != existing_var.get("comment")
):
# Only delete if we're updating this specific variable
delete_env_var(token, project_id, team_id, existing_var["id"])

env_var = {
"key": key,
"value": value,
"type": secret_type,
"target": [target_env], # Set target to specific environment
}
if comment:
env_var["comment"] = comment
payload.append(env_var)

# Delete environment variables not in the source (only for this environment)
for key, env_var in existing_env_vars.items():
if not any(s[0] == key for s in secrets):
delete_env_var(token, project_id, team_id, env_var["id"])

# Bulk create environment variables
if payload:
url = f"{VERCEL_API_BASE_URL}/v10/projects/{project_id}/env?upsert=true"
if team_id is not None:
url += f"&teamId={team_id}"
response = requests.post(
url, headers=get_vercel_headers(token), json=payload
)

if response.status_code != 201:
all_updates_successful = False
messages.append(
f"Failed to sync secrets for environment {target_env}: {response.text}"
)
else:
messages.append(
f"Successfully synced secrets to environment: {target_env}"
)

return all_updates_successful, {"message": "; ".join(messages)}

except Exception as e:
return False, {"message": f"Failed to sync secrets: {str(e)}"}
9 changes: 6 additions & 3 deletions backend/backend/graphene/mutations/syncing.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ class RailwayResourceInput(graphene.InputObjectType):
id = graphene.ID(required=True)
name = graphene.String(required=True)

class VercelResourceInput(graphene.InputObjectType):
id = graphene.ID(required=True)
name = graphene.String(required=True)

class InitEnvSync(graphene.Mutation):
class Arguments:
Expand Down Expand Up @@ -546,6 +543,8 @@ class Arguments:
env_id = graphene.ID()
path = graphene.String()
credential_id = graphene.ID()
team_id = graphene.String()
team_name = graphene.String()
project_id = graphene.String()
project_name = graphene.String()
environment = graphene.String()
Expand All @@ -561,6 +560,8 @@ def mutate(
env_id,
path,
credential_id,
team_id,
team_name,
project_id,
project_name,
environment="production",
Expand All @@ -578,6 +579,7 @@ def mutate(

sync_options = {
"project": {"id": project_id, "name": project_name},
"team": {"id": team_id, "name": team_name},
"environment": environment,
"secret_type": secret_type,
}
Expand All @@ -602,6 +604,7 @@ def mutate(

return CreateVercelSync(sync=sync)


class DeleteSync(graphene.Mutation):
class Arguments:
sync_id = graphene.ID()
Expand Down
Loading

0 comments on commit a893197

Please sign in to comment.