* feat: implement railway secret sync

* fix: railway sync config

* feat: add documentation on railway

* fix: undo mock on-prem change

* lint: fix

* fix: cleanup railway integration

* fix: retry and doc images

* fix: sync fields

* fix: query typo

* Update docs/docs.json

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Sid
2025-07-10 22:53:18 +05:30
committed by GitHub
co-authored by greptile-apps[bot]
parent d3d1fb7190
commit fedffea8d5
98 changed files with 1931 additions and 98 deletions
@@ -28,8 +28,9 @@ export enum AppConnection {
Flyio = "flyio",
GitLab = "gitlab",
Cloudflare = "cloudflare",
Bitbucket = "bitbucket",
Zabbix = "zabbix"
Zabbix = "zabbix",
Railway = "railway",
Bitbucket = "bitbucket"
}
export enum AWSRegion {
@@ -91,6 +91,7 @@ import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql";
import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums";
import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns";
import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres";
import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway";
import { RenderConnectionMethod } from "./render/render-connection-enums";
import { getRenderConnectionListItem, validateRenderConnectionCredentials } from "./render/render-connection-fns";
import {
@@ -143,8 +144,9 @@ export const listAppConnectionOptions = () => {
getFlyioConnectionListItem(),
getGitLabConnectionListItem(),
getCloudflareConnectionListItem(),
getBitbucketConnectionListItem(),
getZabbixConnectionListItem()
getZabbixConnectionListItem(),
getRailwayConnectionListItem(),
getBitbucketConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
};
@@ -225,8 +227,9 @@ export const validateAppConnectionCredentials = async (
[AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator
[AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Railway]: validateRailwayConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator
};
return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection);
@@ -345,8 +348,9 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.Flyio]: platformManagedCredentialsNotSupported,
[AppConnection.GitLab]: platformManagedCredentialsNotSupported,
[AppConnection.Cloudflare]: platformManagedCredentialsNotSupported,
[AppConnection.Bitbucket]: platformManagedCredentialsNotSupported,
[AppConnection.Zabbix]: platformManagedCredentialsNotSupported
[AppConnection.Zabbix]: platformManagedCredentialsNotSupported,
[AppConnection.Railway]: platformManagedCredentialsNotSupported,
[AppConnection.Bitbucket]: platformManagedCredentialsNotSupported
};
export const enterpriseAppCheck = async (
@@ -30,8 +30,9 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.Flyio]: "Fly.io",
[AppConnection.GitLab]: "GitLab",
[AppConnection.Cloudflare]: "Cloudflare",
[AppConnection.Bitbucket]: "Bitbucket",
[AppConnection.Zabbix]: "Zabbix"
[AppConnection.Zabbix]: "Zabbix",
[AppConnection.Railway]: "Railway",
[AppConnection.Bitbucket]: "Bitbucket"
};
export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanType> = {
@@ -64,6 +65,7 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.Flyio]: AppConnectionPlanType.Regular,
[AppConnection.GitLab]: AppConnectionPlanType.Regular,
[AppConnection.Cloudflare]: AppConnectionPlanType.Regular,
[AppConnection.Bitbucket]: AppConnectionPlanType.Regular,
[AppConnection.Zabbix]: AppConnectionPlanType.Regular
[AppConnection.Zabbix]: AppConnectionPlanType.Regular,
[AppConnection.Railway]: AppConnectionPlanType.Regular,
[AppConnection.Bitbucket]: AppConnectionPlanType.Regular
};
@@ -72,6 +72,8 @@ import { ValidateLdapConnectionCredentialsSchema } from "./ldap";
import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql";
import { ValidateMySqlConnectionCredentialsSchema } from "./mysql";
import { ValidatePostgresConnectionCredentialsSchema } from "./postgres";
import { ValidateRailwayConnectionCredentialsSchema } from "./railway";
import { railwayConnectionService } from "./railway/railway-connection-service";
import { ValidateRenderConnectionCredentialsSchema } from "./render/render-connection-schema";
import { renderConnectionService } from "./render/render-connection-service";
import { ValidateTeamCityConnectionCredentialsSchema } from "./teamcity";
@@ -124,8 +126,9 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.Flyio]: ValidateFlyioConnectionCredentialsSchema,
[AppConnection.GitLab]: ValidateGitLabConnectionCredentialsSchema,
[AppConnection.Cloudflare]: ValidateCloudflareConnectionCredentialsSchema,
[AppConnection.Bitbucket]: ValidateBitbucketConnectionCredentialsSchema,
[AppConnection.Zabbix]: ValidateZabbixConnectionCredentialsSchema
[AppConnection.Zabbix]: ValidateZabbixConnectionCredentialsSchema,
[AppConnection.Railway]: ValidateRailwayConnectionCredentialsSchema,
[AppConnection.Bitbucket]: ValidateBitbucketConnectionCredentialsSchema
};
export const appConnectionServiceFactory = ({
@@ -536,7 +539,8 @@ export const appConnectionServiceFactory = ({
flyio: flyioConnectionService(connectAppConnectionById),
gitlab: gitlabConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
cloudflare: cloudflareConnectionService(connectAppConnectionById),
bitbucket: bitbucketConnectionService(connectAppConnectionById),
zabbix: zabbixConnectionService(connectAppConnectionById)
zabbix: zabbixConnectionService(connectAppConnectionById),
railway: railwayConnectionService(connectAppConnectionById),
bitbucket: bitbucketConnectionService(connectAppConnectionById)
};
};
@@ -141,6 +141,12 @@ import {
TPostgresConnectionInput,
TValidatePostgresConnectionCredentialsSchema
} from "./postgres";
import {
TRailwayConnection,
TRailwayConnectionConfig,
TRailwayConnectionInput,
TValidateRailwayConnectionCredentialsSchema
} from "./railway";
import {
TRenderConnection,
TRenderConnectionConfig,
@@ -210,6 +216,7 @@ export type TAppConnection = { id: string } & (
| TCloudflareConnection
| TBitbucketConnection
| TZabbixConnection
| TRailwayConnection
);
export type TAppConnectionRaw = NonNullable<Awaited<ReturnType<TAppConnectionDALFactory["findById"]>>>;
@@ -248,6 +255,7 @@ export type TAppConnectionInput = { id: string } & (
| TCloudflareConnectionInput
| TBitbucketConnectionInput
| TZabbixConnectionInput
| TRailwayConnectionInput
);
export type TSqlConnectionInput =
@@ -293,7 +301,8 @@ export type TAppConnectionConfig =
| TGitLabConnectionConfig
| TCloudflareConnectionConfig
| TBitbucketConnectionConfig
| TZabbixConnectionConfig;
| TZabbixConnectionConfig
| TRailwayConnectionConfig;
export type TValidateAppConnectionCredentialsSchema =
| TValidateAwsConnectionCredentialsSchema
@@ -326,7 +335,8 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateGitLabConnectionCredentialsSchema
| TValidateCloudflareConnectionCredentialsSchema
| TValidateBitbucketConnectionCredentialsSchema
| TValidateZabbixConnectionCredentialsSchema;
| TValidateZabbixConnectionCredentialsSchema
| TValidateRailwayConnectionCredentialsSchema;
export type TListAwsConnectionKmsKeys = {
connectionId: string;
@@ -0,0 +1,4 @@
export * from "./railway-connection-constants";
export * from "./railway-connection-fns";
export * from "./railway-connection-schemas";
export * from "./railway-connection-types";
@@ -0,0 +1,5 @@
export enum RailwayConnectionMethod {
AccountToken = "account-token",
ProjectToken = "project-token",
TeamToken = "team-token"
}
@@ -0,0 +1,66 @@
/* eslint-disable no-await-in-loop */
import { AxiosError } from "axios";
import { BadRequestError } from "@app/lib/errors";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { RailwayConnectionMethod } from "./railway-connection-constants";
import { RailwayPublicAPI } from "./railway-connection-public-client";
import { TRailwayConnection, TRailwayConnectionConfig } from "./railway-connection-types";
export const getRailwayConnectionListItem = () => {
return {
name: "Railway" as const,
app: AppConnection.Railway as const,
methods: Object.values(RailwayConnectionMethod)
};
};
export const validateRailwayConnectionCredentials = async (config: TRailwayConnectionConfig) => {
const { credentials, method } = config;
try {
await RailwayPublicAPI.healthcheck({
method,
credentials
});
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to validate connection - verify credentials"
});
}
return credentials;
};
export const listProjects = async (appConnection: TRailwayConnection) => {
const { credentials, method } = appConnection;
try {
return await RailwayPublicAPI.listProjects({
method,
credentials
});
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to list projects: ${error.message || "Unknown error"}`
});
}
if (error instanceof BadRequestError) {
throw error;
}
throw new BadRequestError({
message: "Unable to list projects",
error
});
}
};
@@ -0,0 +1,237 @@
/* eslint-disable class-methods-use-this */
import { AxiosError, AxiosInstance, AxiosResponse } from "axios";
import { createRequestClient } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { RailwayConnectionMethod } from "./railway-connection-constants";
import {
RailwayAccountWorkspaceListSchema,
RailwayGetProjectsByProjectTokenSchema,
RailwayGetSubscriptionTypeSchema,
RailwayProjectsListSchema
} from "./railway-connection-schemas";
import { RailwayProject, TRailwayConnectionConfig, TRailwayResponse } from "./railway-connection-types";
type RailwaySendReqOptions = Pick<TRailwayConnectionConfig, "credentials" | "method">;
export function getRailwayAuthHeaders(method: RailwayConnectionMethod, token: string): Record<string, string> {
switch (method) {
case RailwayConnectionMethod.AccountToken:
case RailwayConnectionMethod.TeamToken:
return {
Authorization: token
};
case RailwayConnectionMethod.ProjectToken:
return {
"Project-Access-Token": token
};
default:
throw new Error(`Unsupported Railway connection method`);
}
}
export function getRailwayRatelimiter(headers: AxiosResponse["headers"]): {
isRatelimited: boolean;
maxAttempts: number;
wait: () => Promise<void>;
} {
const retryAfter: number | undefined = headers["Retry-After"] as number | undefined;
const requestsLeft = parseInt(headers["X-RateLimit-Remaining"] as string, 10);
const limitResetAt = headers["X-RateLimit-Reset"] as string;
const now = +new Date();
const nextReset = +new Date(limitResetAt);
const remaining = Math.min(0, nextReset - now);
const wait = () => {
return new Promise<void>((res) => {
setTimeout(res, remaining);
});
};
return {
isRatelimited: Boolean(retryAfter || requestsLeft === 0),
wait,
maxAttempts: 3
};
}
class RailwayPublicClient {
private client: AxiosInstance;
constructor() {
this.client = createRequestClient({
method: "POST",
baseURL: IntegrationUrls.RAILWAY_API_URL,
headers: {
"Content-Type": "application/json"
}
});
}
async send<T extends TRailwayResponse>(
query: string,
options: RailwaySendReqOptions,
variables: Record<string, string | Record<string, string>> = {},
retryAttempt: number = 0
): Promise<T["data"] | undefined> {
const body = {
query,
variables
};
const response = await this.client.request<T>({
data: body,
headers: getRailwayAuthHeaders(options.method, options.credentials.apiToken)
});
const { errors } = response.data;
if (Array.isArray(errors) && errors.length > 0) {
throw new AxiosError(errors[0].message);
}
const limiter = getRailwayRatelimiter(response.headers);
if (limiter.isRatelimited && retryAttempt <= limiter.maxAttempts) {
await limiter.wait();
return this.send(query, options, variables, retryAttempt + 1);
}
return response.data.data;
}
healthcheck(config: RailwaySendReqOptions) {
switch (config.method) {
case RailwayConnectionMethod.AccountToken:
return this.send(`{ me { teams { edges { node { id } } } } }`, config);
case RailwayConnectionMethod.ProjectToken:
return this.send(`{ projectToken { projectId environmentId project { id } } }`, config);
case RailwayConnectionMethod.TeamToken:
return this.send(`{ projects { edges { node { id name team { id } } } } }`, config);
default:
throw new Error(`Unsupported Railway connection method`);
}
}
async getSubscriptionType(config: RailwaySendReqOptions & { projectId: string }) {
const res = await this.send(
`query project($projectId: String!) { project(id: $projectId) { subscriptionType }}`,
config,
{
projectId: config.projectId
}
);
const data = await RailwayGetSubscriptionTypeSchema.parseAsync(res);
return data.project.subscriptionType;
}
async listProjects(config: RailwaySendReqOptions): Promise<RailwayProject[]> {
switch (config.method) {
case RailwayConnectionMethod.TeamToken: {
const res = await this.send(
`{ projects { edges { node { id, name, services{ edges{ node { id, name } } } environments { edges { node { name, id } } } } } } }`,
config
);
const data = await RailwayProjectsListSchema.parseAsync(res);
return data.projects.edges.map((p) => ({
id: p.node.id,
name: p.node.name,
environments: p.node.environments.edges.map((e) => e.node),
services: p.node.services.edges.map((s) => s.node)
}));
}
case RailwayConnectionMethod.AccountToken: {
const res = await this.send(
`{ me { workspaces { id, name, team{ projects{ edges{ node{ id, name, services{ edges { node { name, id } } } environments { edges { node { name, id } } } } } } } } } }`,
config
);
const data = await RailwayAccountWorkspaceListSchema.parseAsync(res);
return data.me.workspaces.flatMap((w) =>
w.team.projects.edges.map((p) => ({
id: p.node.id,
name: p.node.name,
environments: p.node.environments.edges.map((e) => e.node),
services: p.node.services.edges.map((s) => s.node)
}))
);
}
case RailwayConnectionMethod.ProjectToken: {
const res = await this.send(
`query { projectToken { project { id, name, services { edges { node { name, id } } } environments { edges { node { name, id } } } } } }`,
config
);
const data = await RailwayGetProjectsByProjectTokenSchema.parseAsync(res);
const p = data.projectToken.project;
return [
{
id: p.id,
name: p.name,
environments: p.environments.edges.map((e) => e.node),
services: p.services.edges.map((s) => s.node)
}
];
}
default:
throw new Error(`Unsupported Railway connection method`);
}
}
async getVariables(
config: RailwaySendReqOptions,
variables: { projectId: string; environmentId: string; serviceId?: string }
) {
const res = await this.send<TRailwayResponse<{ variables: Record<string, string> }>>(
`query variables($environmentId: String!, $projectId: String!, $serviceId: String) { variables( projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId ) }`,
config,
variables
);
if (!res?.variables) {
throw new BadRequestError({
message: "Failed to get railway variables - empty response"
});
}
return res.variables;
}
async deleteVariable(
config: RailwaySendReqOptions,
variables: { input: { projectId: string; environmentId: string; name: string; serviceId?: string } }
) {
await this.send<TRailwayResponse<{ variables: Record<string, string> }>>(
`mutation variableDelete($input: VariableDeleteInput!) { variableDelete(input: $input) }`,
config,
variables
);
}
async upsertVariable(
config: RailwaySendReqOptions,
variables: { input: { projectId: string; environmentId: string; name: string; value: string; serviceId?: string } }
) {
await this.send<TRailwayResponse<{ variables: Record<string, string> }>>(
`mutation variableUpsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }`,
config,
variables
);
}
}
export const RailwayPublicAPI = new RailwayPublicClient();
@@ -0,0 +1,117 @@
import z from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { RailwayConnectionMethod } from "./railway-connection-constants";
export const RailwayConnectionMethodSchema = z
.nativeEnum(RailwayConnectionMethod)
.describe(AppConnections.CREATE(AppConnection.Railway).method);
export const RailwayConnectionAccessTokenCredentialsSchema = z.object({
apiToken: z
.string()
.trim()
.min(1, "API Token required")
.max(255)
.describe(AppConnections.CREDENTIALS.RAILWAY.apiToken)
});
const BaseRailwayConnectionSchema = BaseAppConnectionSchema.extend({
app: z.literal(AppConnection.Railway)
});
export const RailwayConnectionSchema = BaseRailwayConnectionSchema.extend({
method: RailwayConnectionMethodSchema,
credentials: RailwayConnectionAccessTokenCredentialsSchema
});
export const SanitizedRailwayConnectionSchema = z.discriminatedUnion("method", [
BaseRailwayConnectionSchema.extend({
method: RailwayConnectionMethodSchema,
credentials: RailwayConnectionAccessTokenCredentialsSchema.pick({})
})
]);
export const ValidateRailwayConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: RailwayConnectionMethodSchema,
credentials: RailwayConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Railway).credentials
)
})
]);
export const CreateRailwayConnectionSchema = ValidateRailwayConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Railway)
);
export const UpdateRailwayConnectionSchema = z
.object({
credentials: RailwayConnectionAccessTokenCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Railway).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Railway));
export const RailwayConnectionListItemSchema = z.object({
name: z.literal("Railway"),
app: z.literal(AppConnection.Railway),
methods: z.nativeEnum(RailwayConnectionMethod).array()
});
export const RailwayResourceSchema = z.object({
node: z.object({
id: z.string(),
name: z.string()
})
});
export const RailwayProjectEdgeSchema = z.object({
node: z.object({
id: z.string(),
name: z.string(),
services: z.object({
edges: z.array(RailwayResourceSchema)
}),
environments: z.object({
edges: z.array(RailwayResourceSchema)
})
})
});
export const RailwayProjectsListSchema = z.object({
projects: z.object({
edges: z.array(RailwayProjectEdgeSchema)
})
});
export const RailwayAccountWorkspaceListSchema = z.object({
me: z.object({
workspaces: z.array(
z.object({
id: z.string(),
name: z.string(),
team: RailwayProjectsListSchema
})
)
})
});
export const RailwayGetProjectsByProjectTokenSchema = z.object({
projectToken: z.object({
project: RailwayProjectEdgeSchema.shape.node
})
});
export const RailwayGetSubscriptionTypeSchema = z.object({
project: z.object({
subscriptionType: z.enum(["free", "hobby", "pro", "trial"])
})
});
@@ -0,0 +1,30 @@
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { listProjects as getRailwayProjects } from "./railway-connection-fns";
import { TRailwayConnection } from "./railway-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TRailwayConnection>;
export const railwayConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listProjects = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Railway, connectionId, actor);
try {
const projects = await getRailwayProjects(appConnection);
return projects;
} catch (error) {
logger.error(error, "Failed to establish connection with Railway");
return [];
}
};
return {
listProjects
};
};
@@ -0,0 +1,79 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
CreateRailwayConnectionSchema,
RailwayConnectionSchema,
ValidateRailwayConnectionCredentialsSchema
} from "./railway-connection-schemas";
export type TRailwayConnection = z.infer<typeof RailwayConnectionSchema>;
export type TRailwayConnectionInput = z.infer<typeof CreateRailwayConnectionSchema> & {
app: AppConnection.Railway;
};
export type TValidateRailwayConnectionCredentialsSchema = typeof ValidateRailwayConnectionCredentialsSchema;
export type TRailwayConnectionConfig = DiscriminativePick<TRailwayConnection, "method" | "app" | "credentials"> & {
orgId: string;
};
export type TRailwayService = {
id: string;
name: string;
};
export type TRailwayEnvironment = {
id: string;
name: string;
};
export type RailwayProject = {
id: string;
name: string;
services: TRailwayService[];
environments: TRailwayEnvironment[];
};
export type TRailwayResponse<T = unknown> = {
data?: T;
errors?: {
message: string;
}[];
};
export type TAccountProjectListResponse = TRailwayResponse<{
projects: {
edges: TProjectEdge[];
};
}>;
export interface TProjectEdge {
node: {
id: string;
name: string;
services: {
edges: TServiceEdge[];
};
environments: {
edges: TEnvironmentEdge[];
};
};
}
type TServiceEdge = {
node: {
id: string;
name: string;
};
};
type TEnvironmentEdge = {
node: {
id: string;
name: string;
};
};
@@ -0,0 +1,10 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
export const RAILWAY_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Railway",
destination: SecretSync.Railway,
connection: AppConnection.Railway,
canImportSecrets: true
};
@@ -0,0 +1,124 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { RailwayPublicAPI } from "@app/services/app-connection/railway/railway-connection-public-client";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { SecretSyncError } from "../secret-sync-errors";
import { TSecretMap } from "../secret-sync-types";
import { TRailwaySyncWithCredentials } from "./railway-sync-types";
export const RailwaySyncFns = {
async getSecrets(secretSync: TRailwaySyncWithCredentials): Promise<TSecretMap> {
try {
const config = secretSync.destinationConfig;
const variables = await RailwayPublicAPI.getVariables(secretSync.connection, {
projectId: config.projectId,
environmentId: config.environmentId,
serviceId: config.serviceId || undefined
});
const entries = {} as TSecretMap;
for (const [key, value] of Object.entries(variables)) {
// Skip importing private railway variables
// eslint-disable-next-line no-continue
if (key.startsWith("RAILWAY_")) continue;
entries[key] = {
value
};
}
return entries;
} catch (error) {
throw new SecretSyncError({
error,
message: "Failed to import secrets from Railway"
});
}
},
async syncSecrets(secretSync: TRailwaySyncWithCredentials, secretMap: TSecretMap) {
const {
environment,
syncOptions: { disableSecretDeletion, keySchema }
} = secretSync;
const railwaySecrets = await this.getSecrets(secretSync);
const config = secretSync.destinationConfig;
for await (const key of Object.keys(secretMap)) {
try {
const existing = railwaySecrets[key];
if (existing === undefined || existing.value !== secretMap[key].value) {
await RailwayPublicAPI.upsertVariable(secretSync.connection, {
input: {
projectId: config.projectId,
environmentId: config.environmentId,
serviceId: config.serviceId || undefined,
name: key,
value: secretMap[key].value ?? ""
}
});
}
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
if (disableSecretDeletion) return;
for await (const key of Object.keys(railwaySecrets)) {
try {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue;
if (!secretMap[key]) {
await RailwayPublicAPI.deleteVariable(secretSync.connection, {
input: {
projectId: config.projectId,
environmentId: config.environmentId,
serviceId: config.serviceId || undefined,
name: key
}
});
}
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
},
async removeSecrets(secretSync: TRailwaySyncWithCredentials, secretMap: TSecretMap) {
const existing = await this.getSecrets(secretSync);
const config = secretSync.destinationConfig;
for await (const secret of Object.keys(existing)) {
try {
if (secret in secretMap) {
await RailwayPublicAPI.deleteVariable(secretSync.connection, {
input: {
projectId: config.projectId,
environmentId: config.environmentId,
serviceId: config.serviceId || undefined,
name: secret
}
});
}
} catch (error) {
throw new SecretSyncError({
error,
secretKey: secret
});
}
}
}
};
@@ -0,0 +1,56 @@
import { z } from "zod";
import { SecretSyncs } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
BaseSecretSyncSchema,
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const RailwaySyncDestinationConfigSchema = z.object({
projectId: z
.string()
.trim()
.min(1, "Railway project ID required")
.describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.projectId),
projectName: z.string().trim().describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.projectName),
environmentId: z
.string()
.trim()
.min(1, "Railway environment ID required")
.describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.environmentId),
environmentName: z.string().trim().describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.environmentName),
serviceId: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.serviceId),
serviceName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.RAILWAY.serviceName)
});
const RailwaySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const RailwaySyncSchema = BaseSecretSyncSchema(SecretSync.Railway, RailwaySyncOptionsConfig).extend({
destination: z.literal(SecretSync.Railway),
destinationConfig: RailwaySyncDestinationConfigSchema
});
export const CreateRailwaySyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.Railway,
RailwaySyncOptionsConfig
).extend({
destinationConfig: RailwaySyncDestinationConfigSchema
});
export const UpdateRailwaySyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.Railway,
RailwaySyncOptionsConfig
).extend({
destinationConfig: RailwaySyncDestinationConfigSchema.optional()
});
export const RailwaySyncListItemSchema = z.object({
name: z.literal("Railway"),
connection: z.literal(AppConnection.Railway),
destination: z.literal(SecretSync.Railway),
canImportSecrets: z.literal(true)
});
@@ -0,0 +1,31 @@
import z from "zod";
import { TRailwayConnection } from "@app/services/app-connection/railway";
import { CreateRailwaySyncSchema, RailwaySyncListItemSchema, RailwaySyncSchema } from "./railway-sync-schemas";
export type TRailwaySyncListItem = z.infer<typeof RailwaySyncListItemSchema>;
export type TRailwaySync = z.infer<typeof RailwaySyncSchema>;
export type TRailwaySyncInput = z.infer<typeof CreateRailwaySyncSchema>;
export type TRailwaySyncWithCredentials = TRailwaySync & {
connection: TRailwayConnection;
};
export type TRailwaySecret = {
createdAt: string;
environmentId?: string | null;
id: string;
isSealed: boolean;
name: string;
serviceId?: string | null;
updatedAt: string;
};
export type TRailwayVariablesGraphResponse = {
data: {
variables: Record<string, string>;
};
};
@@ -21,7 +21,8 @@ export enum SecretSync {
Flyio = "flyio",
GitLab = "gitlab",
CloudflarePages = "cloudflare-pages",
Zabbix = "zabbix"
Zabbix = "zabbix",
Railway = "railway"
}
export enum SecretSyncInitialSyncBehavior {
@@ -39,6 +39,8 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault";
import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku";
import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec";
import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns";
import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants";
import { RailwaySyncFns } from "./railway/railway-sync-fns";
import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render";
import { SECRET_SYNC_PLAN_MAP } from "./secret-sync-maps";
import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity";
@@ -70,7 +72,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION,
[SecretSync.GitLab]: GITLAB_SYNC_LIST_OPTION,
[SecretSync.CloudflarePages]: CLOUDFLARE_PAGES_SYNC_LIST_OPTION,
[SecretSync.Zabbix]: ZABBIX_SYNC_LIST_OPTION
[SecretSync.Zabbix]: ZABBIX_SYNC_LIST_OPTION,
[SecretSync.Railway]: RAILWAY_SYNC_LIST_OPTION
};
export const listSecretSyncOptions = () => {
@@ -240,6 +243,8 @@ export const SecretSyncFns = {
return CloudflarePagesSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.Zabbix:
return ZabbixSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.Railway:
return RailwaySyncFns.syncSecrets(secretSync, schemaSecretMap);
default:
throw new Error(
`Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -335,6 +340,9 @@ export const SecretSyncFns = {
case SecretSync.Zabbix:
secretMap = await ZabbixSyncFns.getSecrets(secretSync);
break;
case SecretSync.Railway:
secretMap = await RailwaySyncFns.getSecrets(secretSync);
break;
default:
throw new Error(
`Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -414,6 +422,8 @@ export const SecretSyncFns = {
return CloudflarePagesSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.Zabbix:
return ZabbixSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.Railway:
return RailwaySyncFns.removeSecrets(secretSync, schemaSecretMap);
default:
throw new Error(
`Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -24,7 +24,8 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.Flyio]: "Fly.io",
[SecretSync.GitLab]: "GitLab",
[SecretSync.CloudflarePages]: "Cloudflare Pages",
[SecretSync.Zabbix]: "Zabbix"
[SecretSync.Zabbix]: "Zabbix",
[SecretSync.Railway]: "Railway"
};
export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
@@ -50,7 +51,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.Flyio]: AppConnection.Flyio,
[SecretSync.GitLab]: AppConnection.GitLab,
[SecretSync.CloudflarePages]: AppConnection.Cloudflare,
[SecretSync.Zabbix]: AppConnection.Zabbix
[SecretSync.Zabbix]: AppConnection.Zabbix,
[SecretSync.Railway]: AppConnection.Railway
};
export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
@@ -76,5 +78,6 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
[SecretSync.Flyio]: SecretSyncPlanType.Regular,
[SecretSync.GitLab]: SecretSyncPlanType.Regular,
[SecretSync.CloudflarePages]: SecretSyncPlanType.Regular,
[SecretSync.Zabbix]: SecretSyncPlanType.Regular
[SecretSync.Zabbix]: SecretSyncPlanType.Regular,
[SecretSync.Railway]: SecretSyncPlanType.Regular
};
@@ -94,6 +94,12 @@ import {
THumanitecSyncListItem,
THumanitecSyncWithCredentials
} from "./humanitec";
import {
TRailwaySync,
TRailwaySyncInput,
TRailwaySyncListItem,
TRailwaySyncWithCredentials
} from "./railway/railway-sync-types";
import {
TRenderSync,
TRenderSyncInput,
@@ -138,7 +144,8 @@ export type TSecretSync =
| TFlyioSync
| TGitLabSync
| TCloudflarePagesSync
| TZabbixSync;
| TZabbixSync
| TRailwaySync;
export type TSecretSyncWithCredentials =
| TAwsParameterStoreSyncWithCredentials
@@ -163,7 +170,8 @@ export type TSecretSyncWithCredentials =
| TFlyioSyncWithCredentials
| TGitLabSyncWithCredentials
| TCloudflarePagesSyncWithCredentials
| TZabbixSyncWithCredentials;
| TZabbixSyncWithCredentials
| TRailwaySyncWithCredentials;
export type TSecretSyncInput =
| TAwsParameterStoreSyncInput
@@ -188,7 +196,8 @@ export type TSecretSyncInput =
| TFlyioSyncInput
| TGitLabSyncInput
| TCloudflarePagesSyncInput
| TZabbixSyncInput;
| TZabbixSyncInput
| TRailwaySyncInput;
export type TSecretSyncListItem =
| TAwsParameterStoreSyncListItem
@@ -213,7 +222,8 @@ export type TSecretSyncListItem =
| TFlyioSyncListItem
| TGitLabSyncListItem
| TCloudflarePagesSyncListItem
| TZabbixSyncListItem;
| TZabbixSyncListItem
| TRailwaySyncListItem;
export type TSyncOptionsConfig = {
canImportSecrets: boolean;