Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add teams support for vercel syncs #401

Merged
merged 4 commits into from
Dec 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading