Skip to content

Commit 484f1a8

Browse files
feat: railway integration (#297)
* feat: railway integration --------- Co-authored-by: rohan-chaturvedi <[email protected]>
1 parent 92254b8 commit 484f1a8

File tree

22 files changed

+997
-14
lines changed

22 files changed

+997
-14
lines changed

.github/pull_request_template.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@
3838

3939
- [ ] Ensure linting passes (code style checks)?
4040
- [ ] Update dependencies and lockfiles (if required)
41+
- [ ] Regenerate graphql schema and types (if required)
4142
- [ ] Verify the app builds locally?
4243
- [ ] Manually test the changes on different browsers/devices?
43-
44-
45-

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,8 @@
2525
"[python]": {
2626
"editor.defaultFormatter": "ms-python.black-formatter",
2727
"editor.formatOnSave": true
28+
},
29+
"[graphql]": {
30+
"editor.defaultFormatter": "esbenp.prettier-vscode"
2831
} // Don't run prettier for files listed in .gitignore
2932
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.7 on 2024-07-16 07:05
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('api', '0069_activatedphaselicense'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='providercredentials',
15+
name='provider',
16+
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),
17+
),
18+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.7 on 2024-07-16 07:09
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('api', '0070_alter_providercredentials_provider'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='environmentsync',
15+
name='service',
16+
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),
17+
),
18+
]

backend/api/services.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ class Providers:
5454
"auth_scheme": "token",
5555
}
5656

57+
RAILWAY = {
58+
"id": "railway",
59+
"name": "Railway",
60+
"expected_credentials": ["api_token"],
61+
"optional_credentials": [],
62+
"auth_scheme": "token",
63+
}
64+
5765
@classmethod
5866
def get_provider_choices(cls):
5967
return [
@@ -119,6 +127,13 @@ class ServiceConfig:
119127
"resource_type": "path",
120128
}
121129

130+
RAILWAY = {
131+
"id": "railway",
132+
"name": "Railway",
133+
"provider": Providers.RAILWAY,
134+
"resource_type": "environment",
135+
}
136+
122137
@classmethod
123138
def get_service_choices(cls):
124139
return [

backend/api/tasks.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from api.utils.syncing.vault.main import sync_vault_secrets
1010
from api.utils.syncing.nomad.main import sync_nomad_secrets
1111
from api.utils.syncing.gitlab.main import sync_gitlab_secrets
12+
from api.utils.syncing.railway.main import sync_railway_secrets
1213
from .utils.syncing.cloudflare.pages import (
1314
get_cf_pages_credentials,
1415
sync_cloudflare_secrets,
@@ -83,6 +84,15 @@ def trigger_sync_tasks(env_sync):
8384

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

87+
elif env_sync.service == ServiceConfig.RAILWAY["id"]:
88+
env_sync.status = EnvironmentSync.IN_PROGRESS
89+
env_sync.save()
90+
91+
job = perform_railway_sync.delay(env_sync)
92+
job_id = job.get_id()
93+
94+
EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync)
95+
8696

8797
# try and cancel running or queued jobs for this sync
8898
def cancel_sync_tasks(env_sync):
@@ -666,3 +676,87 @@ def perform_gitlab_sync(environment_sync):
666676
environment_sync.last_sync = timezone.now()
667677
environment_sync.status = EnvironmentSync.FAILED
668678
environment_sync.save()
679+
680+
681+
@job("default", timeout=3600)
682+
def perform_railway_sync(environment_sync):
683+
try:
684+
EnvironmentSync = apps.get_model("api", "EnvironmentSync")
685+
EnvironmentSyncEvent = apps.get_model("api", "EnvironmentSyncEvent")
686+
687+
secrets = get_environment_secrets(
688+
environment_sync.environment, environment_sync.path
689+
)
690+
691+
if environment_sync.authentication is None:
692+
sync_data = (
693+
False,
694+
{"message": "No authentication credentials for this sync"},
695+
)
696+
raise Exception("No authentication credentials for this sync")
697+
698+
railway_sync_options = environment_sync.options
699+
700+
railway_project = railway_sync_options.get("project")
701+
railway_environment = railway_sync_options.get("environment")
702+
railway_service = railway_sync_options.get("service")
703+
704+
success, sync_data = sync_railway_secrets(
705+
secrets,
706+
environment_sync.authentication.id,
707+
railway_project["id"],
708+
railway_environment["id"],
709+
railway_service["id"] if railway_service is not None else None,
710+
)
711+
712+
sync_event = (
713+
EnvironmentSyncEvent.objects.filter(env_sync=environment_sync)
714+
.order_by("-created_at")
715+
.first()
716+
)
717+
718+
if success:
719+
sync_event.status = EnvironmentSync.COMPLETED
720+
sync_event.completed_at = timezone.now()
721+
sync_event.meta = sync_data
722+
sync_event.save()
723+
724+
environment_sync.last_sync = timezone.now()
725+
environment_sync.status = EnvironmentSync.COMPLETED
726+
environment_sync.save()
727+
728+
else:
729+
sync_event.status = EnvironmentSync.FAILED
730+
sync_event.completed_at = timezone.now()
731+
sync_event.meta = sync_data
732+
sync_event.save()
733+
734+
environment_sync.last_sync = timezone.now()
735+
environment_sync.status = EnvironmentSync.FAILED
736+
environment_sync.save()
737+
738+
except JobTimeoutException:
739+
# Handle timeout exception
740+
sync_event.status = EnvironmentSync.TIMED_OUT
741+
sync_event.completed_at = timezone.now()
742+
sync_event.save()
743+
744+
environment_sync.last_sync = timezone.now()
745+
environment_sync.status = EnvironmentSync.TIMED_OUT
746+
environment_sync.save()
747+
raise # Re-raise the JobTimeoutException
748+
749+
except Exception as ex:
750+
print(f"EXCEPTION {ex}")
751+
sync_event.status = EnvironmentSync.FAILED
752+
sync_event.completed_at = timezone.now()
753+
754+
try:
755+
sync_event.meta = sync_data
756+
except:
757+
pass
758+
sync_event.save()
759+
760+
environment_sync.last_sync = timezone.now()
761+
environment_sync.status = EnvironmentSync.FAILED
762+
environment_sync.save()
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import requests
2+
import graphene
3+
4+
from api.utils.syncing.auth import get_credentials
5+
6+
7+
class RailwayServiceType(graphene.ObjectType):
8+
id = graphene.ID(required=True)
9+
name = graphene.String(required=True)
10+
11+
12+
class RailwayEnvironmentType(graphene.ObjectType):
13+
id = graphene.ID(required=True)
14+
name = graphene.String(required=True)
15+
project_id = graphene.ID(required=True)
16+
17+
18+
class RailwayProjectType(graphene.ObjectType):
19+
id = graphene.ID(required=True)
20+
name = graphene.String(required=True)
21+
environments = graphene.List(
22+
graphene.NonNull(RailwayEnvironmentType), required=True
23+
)
24+
services = graphene.List(graphene.NonNull(RailwayServiceType), required=True)
25+
26+
27+
RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2"
28+
29+
30+
def get_headers(api_token):
31+
return {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
32+
33+
34+
def fetch_railway_projects(credential_id):
35+
credentials = get_credentials(credential_id)
36+
api_token = credentials.get("api_token")
37+
38+
headers = {
39+
"Authorization": f"Bearer {api_token}",
40+
"Content-Type": "application/json",
41+
}
42+
43+
query = """
44+
query {
45+
projects {
46+
edges {
47+
node {
48+
id
49+
name
50+
environments {
51+
edges {
52+
node {
53+
id
54+
name
55+
}
56+
}
57+
}
58+
services {
59+
edges {
60+
node {
61+
id
62+
name
63+
}
64+
}
65+
}
66+
}
67+
}
68+
}
69+
}
70+
"""
71+
72+
response = requests.post(RAILWAY_API_URL, json={"query": query}, headers=headers)
73+
response.raise_for_status()
74+
data = response.json()
75+
76+
if "errors" in data:
77+
raise Exception(data["errors"])
78+
79+
projects = []
80+
for edge in data["data"]["projects"]["edges"]:
81+
project = edge["node"]
82+
environments = [
83+
env_edge["node"] for env_edge in project["environments"]["edges"]
84+
]
85+
services = [env_edge["node"] for env_edge in project["services"]["edges"]]
86+
project["environments"] = environments
87+
project["services"] = services
88+
projects.append(project)
89+
90+
return projects
91+
92+
93+
def create_environment(name, api_token):
94+
headers = get_headers(api_token)
95+
96+
mutation = """
97+
mutation($name: String!) {
98+
createEnvironment(input: { name: $name }) {
99+
environment {
100+
id
101+
name
102+
}
103+
}
104+
}
105+
"""
106+
variables = {"name": name}
107+
response = requests.post(
108+
RAILWAY_API_URL,
109+
json={"query": mutation, "variables": variables},
110+
headers=headers,
111+
)
112+
response.raise_for_status()
113+
data = response.json()
114+
115+
if "errors" in data:
116+
raise Exception(data["errors"])
117+
118+
environment = data["data"]["createEnvironment"]["environment"]
119+
return environment
120+
121+
122+
def sync_railway_secrets(
123+
secrets, credential_id, project_id, railway_environment_id, service_id=None
124+
):
125+
126+
try:
127+
credentials = get_credentials(credential_id)
128+
api_token = credentials.get("api_token")
129+
130+
headers = {
131+
"Authorization": f"Bearer {api_token}",
132+
"Content-Type": "application/json",
133+
"Accept-Encoding": "application/json",
134+
}
135+
136+
# Prepare the secrets to the format expected by Railway
137+
formatted_secrets = {k: v for k, v, _ in secrets}
138+
139+
# Build the mutation query
140+
mutation = """
141+
mutation UpsertVariables($input: VariableCollectionUpsertInput!) {
142+
variableCollectionUpsert(input: $input)
143+
}
144+
"""
145+
variables = {
146+
"input": {
147+
"projectId": project_id,
148+
"environmentId": railway_environment_id,
149+
"replace": True,
150+
"variables": formatted_secrets,
151+
}
152+
}
153+
154+
# Optionally add serviceId if provided
155+
if service_id:
156+
variables["input"]["serviceId"] = service_id
157+
158+
# Make the request to Railway API
159+
response = requests.post(
160+
RAILWAY_API_URL,
161+
json={"query": mutation, "variables": variables},
162+
headers=headers,
163+
)
164+
165+
data = response.json()
166+
167+
response.raise_for_status()
168+
169+
if "errors" in data:
170+
raise Exception(data["errors"])
171+
172+
else:
173+
return True, {
174+
"response_code": response.status_code,
175+
"message": "Successfully synced secrets.",
176+
}
177+
178+
except Exception as e:
179+
return False, {"message": f"Error syncing secrets: {str(e)}"}

0 commit comments

Comments
 (0)