Skip to content

Commit

Permalink
feat: railway integration (#297)
Browse files Browse the repository at this point in the history
* feat: railway integration

---------

Co-authored-by: rohan-chaturvedi <[email protected]>
  • Loading branch information
nimish-ks and rohan-chaturvedi authored Jul 18, 2024
1 parent 92254b8 commit 484f1a8
Show file tree
Hide file tree
Showing 22 changed files with 997 additions and 14 deletions.
4 changes: 1 addition & 3 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@

- [ ] Ensure linting passes (code style checks)?
- [ ] Update dependencies and lockfiles (if required)
- [ ] Regenerate graphql schema and types (if required)
- [ ] Verify the app builds locally?
- [ ] Manually test the changes on different browsers/devices?



3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true
},
"[graphql]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
} // Don't run prettier for files listed in .gitignore
}
18 changes: 18 additions & 0 deletions backend/api/migrations/0070_alter_providercredentials_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-07-16 07:05

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0069_activatedphaselicense'),
]

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')], max_length=50),
),
]
18 changes: 18 additions & 0 deletions backend/api/migrations/0071_alter_environmentsync_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-07-16 07:09

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0070_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')], 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 @@ -54,6 +54,14 @@ class Providers:
"auth_scheme": "token",
}

RAILWAY = {
"id": "railway",
"name": "Railway",
"expected_credentials": ["api_token"],
"optional_credentials": [],
"auth_scheme": "token",
}

@classmethod
def get_provider_choices(cls):
return [
Expand Down Expand Up @@ -119,6 +127,13 @@ class ServiceConfig:
"resource_type": "path",
}

RAILWAY = {
"id": "railway",
"name": "Railway",
"provider": Providers.RAILWAY,
"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 @@ -9,6 +9,7 @@
from api.utils.syncing.vault.main import sync_vault_secrets
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 .utils.syncing.cloudflare.pages import (
get_cf_pages_credentials,
sync_cloudflare_secrets,
Expand Down Expand Up @@ -83,6 +84,15 @@ def trigger_sync_tasks(env_sync):

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

elif env_sync.service == ServiceConfig.RAILWAY["id"]:
env_sync.status = EnvironmentSync.IN_PROGRESS
env_sync.save()

job = perform_railway_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
def cancel_sync_tasks(env_sync):
Expand Down Expand Up @@ -666,3 +676,87 @@ def perform_gitlab_sync(environment_sync):
environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.FAILED
environment_sync.save()


@job("default", timeout=3600)
def perform_railway_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")

railway_sync_options = environment_sync.options

railway_project = railway_sync_options.get("project")
railway_environment = railway_sync_options.get("environment")
railway_service = railway_sync_options.get("service")

success, sync_data = sync_railway_secrets(
secrets,
environment_sync.authentication.id,
railway_project["id"],
railway_environment["id"],
railway_service["id"] if railway_service is not None else None,
)

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()
179 changes: 179 additions & 0 deletions backend/api/utils/syncing/railway/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import requests
import graphene

from api.utils.syncing.auth import get_credentials


class RailwayServiceType(graphene.ObjectType):
id = graphene.ID(required=True)
name = graphene.String(required=True)


class RailwayEnvironmentType(graphene.ObjectType):
id = graphene.ID(required=True)
name = graphene.String(required=True)
project_id = graphene.ID(required=True)


class RailwayProjectType(graphene.ObjectType):
id = graphene.ID(required=True)
name = graphene.String(required=True)
environments = graphene.List(
graphene.NonNull(RailwayEnvironmentType), required=True
)
services = graphene.List(graphene.NonNull(RailwayServiceType), required=True)


RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2"


def get_headers(api_token):
return {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}


def fetch_railway_projects(credential_id):
credentials = get_credentials(credential_id)
api_token = credentials.get("api_token")

headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}

query = """
query {
projects {
edges {
node {
id
name
environments {
edges {
node {
id
name
}
}
}
services {
edges {
node {
id
name
}
}
}
}
}
}
}
"""

response = requests.post(RAILWAY_API_URL, json={"query": query}, headers=headers)
response.raise_for_status()
data = response.json()

if "errors" in data:
raise Exception(data["errors"])

projects = []
for edge in data["data"]["projects"]["edges"]:
project = edge["node"]
environments = [
env_edge["node"] for env_edge in project["environments"]["edges"]
]
services = [env_edge["node"] for env_edge in project["services"]["edges"]]
project["environments"] = environments
project["services"] = services
projects.append(project)

return projects


def create_environment(name, api_token):
headers = get_headers(api_token)

mutation = """
mutation($name: String!) {
createEnvironment(input: { name: $name }) {
environment {
id
name
}
}
}
"""
variables = {"name": name}
response = requests.post(
RAILWAY_API_URL,
json={"query": mutation, "variables": variables},
headers=headers,
)
response.raise_for_status()
data = response.json()

if "errors" in data:
raise Exception(data["errors"])

environment = data["data"]["createEnvironment"]["environment"]
return environment


def sync_railway_secrets(
secrets, credential_id, project_id, railway_environment_id, service_id=None
):

try:
credentials = get_credentials(credential_id)
api_token = credentials.get("api_token")

headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
"Accept-Encoding": "application/json",
}

# Prepare the secrets to the format expected by Railway
formatted_secrets = {k: v for k, v, _ in secrets}

# Build the mutation query
mutation = """
mutation UpsertVariables($input: VariableCollectionUpsertInput!) {
variableCollectionUpsert(input: $input)
}
"""
variables = {
"input": {
"projectId": project_id,
"environmentId": railway_environment_id,
"replace": True,
"variables": formatted_secrets,
}
}

# Optionally add serviceId if provided
if service_id:
variables["input"]["serviceId"] = service_id

# Make the request to Railway API
response = requests.post(
RAILWAY_API_URL,
json={"query": mutation, "variables": variables},
headers=headers,
)

data = response.json()

response.raise_for_status()

if "errors" in data:
raise Exception(data["errors"])

else:
return True, {
"response_code": response.status_code,
"message": "Successfully synced secrets.",
}

except Exception as e:
return False, {"message": f"Error syncing secrets: {str(e)}"}
Loading

0 comments on commit 484f1a8

Please sign in to comment.