Skip to content

Commit

Permalink
Merge branch 'main' into feat--service-accounts
Browse files Browse the repository at this point in the history
  • Loading branch information
rohan-chaturvedi authored Nov 15, 2024
2 parents 121334a + 58a44ba commit 3fe52c5
Show file tree
Hide file tree
Showing 20 changed files with 901 additions and 4 deletions.
18 changes: 18 additions & 0 deletions backend/api/migrations/0085_alter_providercredentials_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.15 on 2024-11-11 14:31

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0084_auto_20241008_0708'),
]

operations = [
migrations.AlterField(
model_name='providercredentials',
name='provider',
field=models.CharField(choices=[('cloudflare', 'Cloudflare'), ('aws', 'AWS'), ('github', 'GitHub'), ('gitlab', 'GitLab'), ('hashicorp_vault', 'Hashicorp Vault'), ('hashicorp_nomad', 'Hashicorp Nomad'), ('railway', 'Railway'), ('vercel', 'Vercel')], max_length=50),
),
]
18 changes: 18 additions & 0 deletions backend/api/migrations/0086_alter_environmentsync_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.15 on 2024-11-11 14:36

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0085_alter_providercredentials_provider'),
]

operations = [
migrations.AlterField(
model_name='environmentsync',
name='service',
field=models.CharField(choices=[('cloudflare_pages', 'Cloudflare Pages'), ('aws_secrets_manager', 'AWS Secrets Manager'), ('github_actions', 'GitHub Actions'), ('gitlab_ci', 'GitLab CI'), ('hashicorp_vault', 'Hashicorp Vault'), ('hashicorp_nomad', 'Hashicorp Nomad'), ('railway', 'Railway'), ('vercel', 'Vercel')], max_length=50),
),
]
15 changes: 15 additions & 0 deletions backend/api/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ class Providers:
"auth_scheme": "token",
}

VERCEL = {
"id": "vercel",
"name": "Vercel",
"expected_credentials": ["api_token"],
"optional_credentials": [],
"auth_scheme": "token",
}

@classmethod
def get_provider_choices(cls):
return [
Expand Down Expand Up @@ -134,6 +142,13 @@ class ServiceConfig:
"resource_type": "environment",
}

VERCEL = {
"id": "vercel",
"name": "Vercel",
"provider": Providers.VERCEL,
"resource_type": "environment",
}

@classmethod
def get_service_choices(cls):
return [
Expand Down
94 changes: 94 additions & 0 deletions backend/api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from api.utils.syncing.nomad.main import sync_nomad_secrets
from api.utils.syncing.gitlab.main import sync_gitlab_secrets
from api.utils.syncing.railway.main import sync_railway_secrets
from api.utils.syncing.vercel.main import sync_vercel_secrets
from .utils.syncing.cloudflare.pages import (
get_cf_pages_credentials,
sync_cloudflare_secrets,
Expand Down Expand Up @@ -92,6 +93,15 @@ 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()

job = perform_vercel_sync.delay(env_sync)
job_id = job.get_id()

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


# try and cancel running or queued jobs for this sync
Expand Down Expand Up @@ -760,3 +770,87 @@ def perform_railway_sync(environment_sync):
environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.FAILED
environment_sync.save()


@job("default", timeout=3600)
def perform_vercel_sync(environment_sync):
try:
EnvironmentSync = apps.get_model("api", "EnvironmentSync")
EnvironmentSyncEvent = apps.get_model("api", "EnvironmentSyncEvent")

secrets = get_environment_secrets(
environment_sync.environment, environment_sync.path
)

if environment_sync.authentication is None:
sync_data = (
False,
{"message": "No authentication credentials for this sync"},
)
raise Exception("No authentication credentials for this sync")

vercel_sync_options = environment_sync.options

vercel_project = vercel_sync_options.get("project")
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_environment,
vercel_secret_type,
)

sync_event = (
EnvironmentSyncEvent.objects.filter(env_sync=environment_sync)
.order_by("-created_at")
.first()
)

if success:
sync_event.status = EnvironmentSync.COMPLETED
sync_event.completed_at = timezone.now()
sync_event.meta = sync_data
sync_event.save()

environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.COMPLETED
environment_sync.save()

else:
sync_event.status = EnvironmentSync.FAILED
sync_event.completed_at = timezone.now()
sync_event.meta = sync_data
sync_event.save()

environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.FAILED
environment_sync.save()

except JobTimeoutException:
# Handle timeout exception
sync_event.status = EnvironmentSync.TIMED_OUT
sync_event.completed_at = timezone.now()
sync_event.save()

environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.TIMED_OUT
environment_sync.save()
raise # Re-raise the JobTimeoutException

except Exception as ex:
print(f"EXCEPTION {ex}")
sync_event.status = EnvironmentSync.FAILED
sync_event.completed_at = timezone.now()

try:
sync_event.meta = sync_data
except:
pass
sync_event.save()

environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.FAILED
environment_sync.save()
168 changes: 168 additions & 0 deletions backend/api/utils/syncing/vercel/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import requests
import graphene
from graphene import ObjectType

from api.utils.syncing.auth import get_credentials

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

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


def get_vercel_credentials(credential_id):
"""Get Vercel credentials from the encrypted storage."""
credentials = get_credentials(credential_id)
token = credentials.get("api_token")
return token


def get_vercel_headers(token):
"""Prepare headers for Vercel API requests."""
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'
response = requests.get(url, headers=get_vercel_headers(token))
return response.status_code == 200
except Exception:
return False


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.
"""
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
]
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))

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

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


def delete_env_var(token, project_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}'
response = requests.delete(url, headers=get_vercel_headers(token))

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


def sync_vercel_secrets(
secrets,
credential_id,
project_id,
environment="production",
secret_type="encrypted",
):
"""
Sync secrets to a Vercel project.
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
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'
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
)

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)}"
}

except Exception as e:
return False, {"message": f"Failed to sync secrets: {str(e)}"}
Loading

0 comments on commit 3fe52c5

Please sign in to comment.