diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index de5060f6f..a61e542b1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2279,6 +2279,9 @@ export const AppConnections = { ZABBIX: { apiToken: "The API Token used to access Zabbix.", instanceUrl: "The Zabbix instance URL to connect with." + }, + RAILWAY: { + apiToken: "The API token used to authenticate with Railway." } } }; @@ -2474,6 +2477,14 @@ export const SecretSyncs = { hostId: "The ID of the Zabbix host to sync secrets to.", hostName: "The name of the Zabbix host to sync secrets to.", macroType: "The type of macro to sync secrets to. (0: Text, 1: Secret)" + }, + RAILWAY: { + projectId: "The ID of the Railway project to sync secrets to.", + projectName: "The name of the Railway project to sync secrets to.", + environmentId: "The Railway environment to sync secrets to.", + environmentName: "The Railway environment to sync secrets to.", + serviceId: "The Railway service that secrets should be synced to.", + serviceName: "The Railway service that secrets should be synced to." } } }; @@ -2594,7 +2605,9 @@ export const SecretRotations = { export const SecretScanningDataSources = { LIST: (type?: SecretScanningDataSource) => ({ - projectId: `The ID of the project to list ${type ? SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type] : "Scanning"} Data Sources from.` + projectId: `The ID of the project to list ${ + type ? SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type] : "Scanning" + } Data Sources from.` }), GET_BY_ID: (type: SecretScanningDataSource) => ({ dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to retrieve.` diff --git a/backend/src/lib/config/request.ts b/backend/src/lib/config/request.ts index 8636b7476..aedf5cd44 100644 --- a/backend/src/lib/config/request.ts +++ b/backend/src/lib/config/request.ts @@ -1,11 +1,18 @@ -import axios from "axios"; -import axiosRetry from "axios-retry"; +import axios, { AxiosInstance, CreateAxiosDefaults } from "axios"; +import axiosRetry, { IAxiosRetryConfig } from "axios-retry"; -export const request = axios.create(); +export function createRequestClient(defaults: CreateAxiosDefaults = {}, retry: IAxiosRetryConfig = {}): AxiosInstance { + const client = axios.create(defaults); -axiosRetry(request, { - retries: 3, - // eslint-disable-next-line - retryDelay: axiosRetry.exponentialDelay, - retryCondition: (err) => axiosRetry.isNetworkError(err) || axiosRetry.isRetryableError(err) -}); + axiosRetry(client, { + retries: 3, + // eslint-disable-next-line + retryDelay: axiosRetry.exponentialDelay, + retryCondition: (err) => axiosRetry.isNetworkError(err) || axiosRetry.isRetryableError(err), + ...retry + }); + + return client; +} + +export const request = createRequestClient(); diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 35ec330e8..f692e700f 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -71,6 +71,10 @@ import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +import { + RailwayConnectionListItemSchema, + SanitizedRailwayConnectionSchema +} from "@app/services/app-connection/railway"; import { RenderConnectionListItemSchema, SanitizedRenderConnectionSchema @@ -123,7 +127,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedGitLabConnectionSchema.options, ...SanitizedCloudflareConnectionSchema.options, ...SanitizedBitbucketConnectionSchema.options, - ...SanitizedZabbixConnectionSchema.options + ...SanitizedZabbixConnectionSchema.options, + ...SanitizedRailwayConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -157,7 +162,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ GitLabConnectionListItemSchema, CloudflareConnectionListItemSchema, BitbucketConnectionListItemSchema, - ZabbixConnectionListItemSchema + ZabbixConnectionListItemSchema, + RailwayConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 56005beac..524abc18d 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -25,6 +25,7 @@ import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; +import { registerRailwayConnectionRouter } from "./railway-connection-router"; import { registerRenderConnectionRouter } from "./render-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; @@ -66,5 +67,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Railway, + server, + sanitizedResponseSchema: SanitizedRailwayConnectionSchema, + createSchema: CreateRailwayConnectionSchema, + updateSchema: UpdateRailwayConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + projects: z + .object({ + name: z.string(), + id: z.string(), + services: z.array( + z.object({ + name: z.string(), + id: z.string() + }) + ), + environments: z.array( + z.object({ + name: z.string(), + id: z.string() + }) + ) + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const projects = await server.services.appConnection.railway.listProjects(connectionId, req.permission); + + return { projects }; + } + }); +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 67e1ac720..038fce7aa 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -17,6 +17,7 @@ import { registerGitLabSyncRouter } from "./gitlab-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; @@ -49,5 +50,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Railway, + server, + responseSchema: RailwaySyncSchema, + createSchema: CreateRailwaySyncSchema, + updateSchema: UpdateRailwaySyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index d2cab7cd6..8c2862050 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -34,6 +34,7 @@ import { GitLabSyncListItemSchema, GitLabSyncSchema } from "@app/services/secret import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault"; import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku"; import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; +import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas"; import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; import { TeamCitySyncListItemSchema, TeamCitySyncSchema } from "@app/services/secret-sync/teamcity"; import { TerraformCloudSyncListItemSchema, TerraformCloudSyncSchema } from "@app/services/secret-sync/terraform-cloud"; @@ -64,7 +65,8 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ FlyioSyncSchema, GitLabSyncSchema, CloudflarePagesSyncSchema, - ZabbixSyncSchema + ZabbixSyncSchema, + RailwaySyncSchema ]); const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ @@ -90,7 +92,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ FlyioSyncListItemSchema, GitLabSyncListItemSchema, CloudflarePagesSyncListItemSchema, - ZabbixSyncListItemSchema + ZabbixSyncListItemSchema, + RailwaySyncListItemSchema ]); export const registerSecretSyncRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index a0f4c7aac..b9c405654 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -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 { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index c5c2b325f..df40a9eea 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -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 ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index cf5ed1a42..4f274516c 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -30,8 +30,9 @@ export const APP_CONNECTION_NAME_MAP: Record = { [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 = { @@ -64,6 +65,7 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -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; diff --git a/backend/src/services/app-connection/railway/index.ts b/backend/src/services/app-connection/railway/index.ts new file mode 100644 index 000000000..a282bd9dd --- /dev/null +++ b/backend/src/services/app-connection/railway/index.ts @@ -0,0 +1,4 @@ +export * from "./railway-connection-constants"; +export * from "./railway-connection-fns"; +export * from "./railway-connection-schemas"; +export * from "./railway-connection-types"; diff --git a/backend/src/services/app-connection/railway/railway-connection-constants.ts b/backend/src/services/app-connection/railway/railway-connection-constants.ts new file mode 100644 index 000000000..aabec1027 --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-constants.ts @@ -0,0 +1,5 @@ +export enum RailwayConnectionMethod { + AccountToken = "account-token", + ProjectToken = "project-token", + TeamToken = "team-token" +} diff --git a/backend/src/services/app-connection/railway/railway-connection-fns.ts b/backend/src/services/app-connection/railway/railway-connection-fns.ts new file mode 100644 index 000000000..7aa25b9f6 --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-fns.ts @@ -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 + }); + } +}; diff --git a/backend/src/services/app-connection/railway/railway-connection-public-client.ts b/backend/src/services/app-connection/railway/railway-connection-public-client.ts new file mode 100644 index 000000000..1c8bd9cc2 --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-public-client.ts @@ -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; + +export function getRailwayAuthHeaders(method: RailwayConnectionMethod, token: string): Record { + 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; +} { + 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((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( + query: string, + options: RailwaySendReqOptions, + variables: Record> = {}, + retryAttempt: number = 0 + ): Promise { + const body = { + query, + variables + }; + + const response = await this.client.request({ + 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 { + 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 }>>( + `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 }>>( + `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 }>>( + `mutation variableUpsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }`, + config, + variables + ); + } +} + +export const RailwayPublicAPI = new RailwayPublicClient(); diff --git a/backend/src/services/app-connection/railway/railway-connection-schemas.ts b/backend/src/services/app-connection/railway/railway-connection-schemas.ts new file mode 100644 index 000000000..066258f1e --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-schemas.ts @@ -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"]) + }) +}); diff --git a/backend/src/services/app-connection/railway/railway-connection-service.ts b/backend/src/services/app-connection/railway/railway-connection-service.ts new file mode 100644 index 000000000..379f36456 --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-service.ts @@ -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; + +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 + }; +}; diff --git a/backend/src/services/app-connection/railway/railway-connection-types.ts b/backend/src/services/app-connection/railway/railway-connection-types.ts new file mode 100644 index 000000000..66b6b549f --- /dev/null +++ b/backend/src/services/app-connection/railway/railway-connection-types.ts @@ -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; + +export type TRailwayConnectionInput = z.infer & { + app: AppConnection.Railway; +}; + +export type TValidateRailwayConnectionCredentialsSchema = typeof ValidateRailwayConnectionCredentialsSchema; + +export type TRailwayConnectionConfig = DiscriminativePick & { + 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 = { + 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; + }; +}; diff --git a/backend/src/services/secret-sync/railway/railway-sync-constants.ts b/backend/src/services/secret-sync/railway/railway-sync-constants.ts new file mode 100644 index 000000000..a77311bbf --- /dev/null +++ b/backend/src/services/secret-sync/railway/railway-sync-constants.ts @@ -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 +}; diff --git a/backend/src/services/secret-sync/railway/railway-sync-fns.ts b/backend/src/services/secret-sync/railway/railway-sync-fns.ts new file mode 100644 index 000000000..07862aeb5 --- /dev/null +++ b/backend/src/services/secret-sync/railway/railway-sync-fns.ts @@ -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 { + 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 + }); + } + } + } +}; diff --git a/backend/src/services/secret-sync/railway/railway-sync-schemas.ts b/backend/src/services/secret-sync/railway/railway-sync-schemas.ts new file mode 100644 index 000000000..56cea0408 --- /dev/null +++ b/backend/src/services/secret-sync/railway/railway-sync-schemas.ts @@ -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) +}); diff --git a/backend/src/services/secret-sync/railway/railway-sync-types.ts b/backend/src/services/secret-sync/railway/railway-sync-types.ts new file mode 100644 index 000000000..d2165072d --- /dev/null +++ b/backend/src/services/secret-sync/railway/railway-sync-types.ts @@ -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; + +export type TRailwaySync = z.infer; + +export type TRailwaySyncInput = z.infer; + +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; + }; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 62730c3da..c7dc0c9bb 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -21,7 +21,8 @@ export enum SecretSync { Flyio = "flyio", GitLab = "gitlab", CloudflarePages = "cloudflare-pages", - Zabbix = "zabbix" + Zabbix = "zabbix", + Railway = "railway" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index d058d355e..34b24eece 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -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.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}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 25df5d0b4..938679332 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -24,7 +24,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [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 = { @@ -50,7 +51,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [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 = { @@ -76,5 +78,6 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Flyio]: SecretSyncPlanType.Regular, [SecretSync.GitLab]: SecretSyncPlanType.Regular, [SecretSync.CloudflarePages]: SecretSyncPlanType.Regular, - [SecretSync.Zabbix]: SecretSyncPlanType.Regular + [SecretSync.Zabbix]: SecretSyncPlanType.Regular, + [SecretSync.Railway]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index b076ea9c4..7eaba35f2 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -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; diff --git a/docs/api-reference/endpoints/app-connections/railway/available.mdx b/docs/api-reference/endpoints/app-connections/railway/available.mdx new file mode 100644 index 000000000..83190c379 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/railway/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/railway/create.mdx b/docs/api-reference/endpoints/app-connections/railway/create.mdx new file mode 100644 index 000000000..96c1c9c53 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/railway" +--- + + + Check out the configuration docs for [Railway Connections](/integrations/app-connections/railway) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/railway/delete.mdx b/docs/api-reference/endpoints/app-connections/railway/delete.mdx new file mode 100644 index 000000000..4938f26e8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/railway/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/railway/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/railway/get-by-id.mdx new file mode 100644 index 000000000..844bd2376 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/railway/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/railway/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/railway/get-by-name.mdx new file mode 100644 index 000000000..4497cbfca --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/railway/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/railway/list.mdx b/docs/api-reference/endpoints/app-connections/railway/list.mdx new file mode 100644 index 000000000..16a6a087e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/railway" +--- diff --git a/docs/api-reference/endpoints/app-connections/railway/update.mdx b/docs/api-reference/endpoints/app-connections/railway/update.mdx new file mode 100644 index 000000000..66fa37a43 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/railway/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/railway/{connectionId}" +--- + + + Check out the configuration docs for [Railway Connections](/integrations/app-connections/railway) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/secret-syncs/railway/create.mdx b/docs/api-reference/endpoints/secret-syncs/railway/create.mdx new file mode 100644 index 000000000..51d23eaf2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/railway" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/delete.mdx b/docs/api-reference/endpoints/secret-syncs/railway/delete.mdx new file mode 100644 index 000000000..786ce05e6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/railway/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/railway/get-by-id.mdx new file mode 100644 index 000000000..dbeddee50 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/railway/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/railway/get-by-name.mdx new file mode 100644 index 000000000..4e4964adc --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/railway/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/railway/import-secrets.mdx new file mode 100644 index 000000000..2f9a9b017 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/railway/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/list.mdx b/docs/api-reference/endpoints/secret-syncs/railway/list.mdx new file mode 100644 index 000000000..f4dc62a45 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/railway" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/railway/remove-secrets.mdx new file mode 100644 index 000000000..f3e187a11 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/railway/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/railway/sync-secrets.mdx new file mode 100644 index 000000000..5bccb271a --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/railway/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/railway/update.mdx b/docs/api-reference/endpoints/secret-syncs/railway/update.mdx new file mode 100644 index 000000000..104194868 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/railway/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/railway/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index 94046ad02..f8e7aee4e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -78,10 +78,7 @@ }, { "group": "Infisical SSH", - "pages": [ - "documentation/platform/ssh/overview", - "documentation/platform/ssh/host-groups" - ] + "pages": ["documentation/platform/ssh/overview", "documentation/platform/ssh/host-groups"] }, { "group": "Key Management (KMS)", @@ -378,10 +375,7 @@ }, { "group": "Architecture", - "pages": [ - "internals/architecture/components", - "internals/architecture/cloud" - ] + "pages": ["internals/architecture/components", "internals/architecture/cloud"] }, "internals/security", "internals/service-tokens" @@ -488,6 +482,7 @@ "integrations/app-connections/oci", "integrations/app-connections/oracledb", "integrations/app-connections/postgres", + "integrations/app-connections/railway", "integrations/app-connections/render", "integrations/app-connections/teamcity", "integrations/app-connections/terraform-cloud", @@ -522,6 +517,7 @@ "integrations/secret-syncs/heroku", "integrations/secret-syncs/humanitec", "integrations/secret-syncs/oci-vault", + "integrations/secret-syncs/railway", "integrations/secret-syncs/render", "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", @@ -550,10 +546,7 @@ "integrations/cloud/gcp-secret-manager", { "group": "Cloudflare", - "pages": [ - "integrations/cloud/cloudflare-pages", - "integrations/cloud/cloudflare-workers" - ] + "pages": ["integrations/cloud/cloudflare-pages", "integrations/cloud/cloudflare-workers"] }, "integrations/cloud/terraform-cloud", "integrations/cloud/databricks", @@ -563,8 +556,8 @@ "integrations/cloud/digital-ocean-app-platform", "integrations/cloud/heroku", "integrations/cloud/netlify", - "integrations/cloud/railway", "integrations/cloud/flyio", + "integrations/cloud/railway", "integrations/cloud/render", "integrations/cloud/laravel-forge", "integrations/cloud/supabase", @@ -665,11 +658,7 @@ "cli/commands/reset", { "group": "infisical scan", - "pages": [ - "cli/commands/scan", - "cli/commands/scan-git-changes", - "cli/commands/scan-install" - ] + "pages": ["cli/commands/scan", "cli/commands/scan-git-changes", "cli/commands/scan-install"] } ] }, @@ -853,30 +842,30 @@ { "group": "Organizations", "pages": [ - { - "group": "OIDC SSO", - "pages": [ - "api-reference/endpoints/organizations/oidc-sso/get-oidc-config", - "api-reference/endpoints/organizations/oidc-sso/update-oidc-config", - "api-reference/endpoints/organizations/oidc-sso/create-oidc-config" - ] - }, - { - "group": "LDAP SSO", - "pages": [ - "api-reference/endpoints/organizations/ldap-sso/get-ldap-config", - "api-reference/endpoints/organizations/ldap-sso/update-ldap-config", - "api-reference/endpoints/organizations/ldap-sso/create-ldap-config" - ] - }, - { - "group": "SAML SSO", - "pages": [ - "api-reference/endpoints/organizations/saml-sso/get-saml-config", - "api-reference/endpoints/organizations/saml-sso/update-saml-config", - "api-reference/endpoints/organizations/saml-sso/create-saml-config" - ] - }, + { + "group": "OIDC SSO", + "pages": [ + "api-reference/endpoints/organizations/oidc-sso/get-oidc-config", + "api-reference/endpoints/organizations/oidc-sso/update-oidc-config", + "api-reference/endpoints/organizations/oidc-sso/create-oidc-config" + ] + }, + { + "group": "LDAP SSO", + "pages": [ + "api-reference/endpoints/organizations/ldap-sso/get-ldap-config", + "api-reference/endpoints/organizations/ldap-sso/update-ldap-config", + "api-reference/endpoints/organizations/ldap-sso/create-ldap-config" + ] + }, + { + "group": "SAML SSO", + "pages": [ + "api-reference/endpoints/organizations/saml-sso/get-saml-config", + "api-reference/endpoints/organizations/saml-sso/update-saml-config", + "api-reference/endpoints/organizations/saml-sso/create-saml-config" + ] + }, "api-reference/endpoints/organizations/memberships", "api-reference/endpoints/organizations/update-membership", "api-reference/endpoints/organizations/delete-membership", @@ -993,9 +982,7 @@ "pages": [ { "group": "Kubernetes", - "pages": [ - "api-reference/endpoints/dynamic-secrets/kubernetes/create-lease" - ] + "pages": ["api-reference/endpoints/dynamic-secrets/kubernetes/create-lease"] }, "api-reference/endpoints/dynamic-secrets/create", "api-reference/endpoints/dynamic-secrets/update", @@ -1517,6 +1504,18 @@ "api-reference/endpoints/app-connections/postgres/delete" ] }, + { + "group": "Railway", + "pages": [ + "api-reference/endpoints/app-connections/railway/list", + "api-reference/endpoints/app-connections/railway/available", + "api-reference/endpoints/app-connections/railway/get-by-id", + "api-reference/endpoints/app-connections/railway/get-by-name", + "api-reference/endpoints/app-connections/railway/create", + "api-reference/endpoints/app-connections/railway/update", + "api-reference/endpoints/app-connections/railway/delete" + ] + }, { "group": "Render", "pages": [ @@ -1826,6 +1825,20 @@ "api-reference/endpoints/secret-syncs/oci-vault/remove-secrets" ] }, + { + "group": "Railway", + "pages": [ + "api-reference/endpoints/secret-syncs/railway/list", + "api-reference/endpoints/secret-syncs/railway/get-by-id", + "api-reference/endpoints/secret-syncs/railway/get-by-name", + "api-reference/endpoints/secret-syncs/railway/create", + "api-reference/endpoints/secret-syncs/railway/update", + "api-reference/endpoints/secret-syncs/railway/delete", + "api-reference/endpoints/secret-syncs/railway/sync-secrets", + "api-reference/endpoints/secret-syncs/railway/import-secrets", + "api-reference/endpoints/secret-syncs/railway/remove-secrets" + ] + }, { "group": "Render", "pages": [ @@ -2172,7 +2185,7 @@ "api": { "openapi": "https://app.infisical.com/api/docs/json", "mdx": { - "server": ["https://app.infisical.com", "http://localhost:8080"] + "server": ["https://app.infisical.com"] } }, "appearance": { diff --git a/docs/images/app-connections/railway/railway-app-connection-account-settings-tokens.png b/docs/images/app-connections/railway/railway-app-connection-account-settings-tokens.png new file mode 100644 index 000000000..c3a6b014f Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-account-settings-tokens.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-account-settings.png b/docs/images/app-connections/railway/railway-app-connection-account-settings.png new file mode 100644 index 000000000..15f3d9af5 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-account-settings.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-account-token-create.png b/docs/images/app-connections/railway/railway-app-connection-account-token-create.png new file mode 100644 index 000000000..7c4ac2af7 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-account-token-create.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-account-token-created.png b/docs/images/app-connections/railway/railway-app-connection-account-token-created.png new file mode 100644 index 000000000..6ba7a83c7 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-account-token-created.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-account-token-form.png b/docs/images/app-connections/railway/railway-app-connection-account-token-form.png new file mode 100644 index 000000000..200fd1cdc Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-account-token-form.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-form.png b/docs/images/app-connections/railway/railway-app-connection-form.png new file mode 100644 index 000000000..122661426 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-form.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-generated.png b/docs/images/app-connections/railway/railway-app-connection-generated.png new file mode 100644 index 000000000..e3fc0c5cb Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-generated.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-option.png b/docs/images/app-connections/railway/railway-app-connection-option.png new file mode 100644 index 000000000..cffaa0e85 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-option.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-create.png b/docs/images/app-connections/railway/railway-app-connection-project-token-create.png new file mode 100644 index 000000000..839336cdd Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-create.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-created.png b/docs/images/app-connections/railway/railway-app-connection-project-token-created.png new file mode 100644 index 000000000..5588f89d5 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-created.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-dashboard.png b/docs/images/app-connections/railway/railway-app-connection-project-token-dashboard.png new file mode 100644 index 000000000..0ec110a91 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-dashboard.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-form.png b/docs/images/app-connections/railway/railway-app-connection-project-token-form.png new file mode 100644 index 000000000..a16c6a2e7 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-form.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-project.png b/docs/images/app-connections/railway/railway-app-connection-project-token-project.png new file mode 100644 index 000000000..b938cbe9e Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-project.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-project-token-settings.png b/docs/images/app-connections/railway/railway-app-connection-project-token-settings.png new file mode 100644 index 000000000..ae59c588a Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-project-token-settings.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-team-token-create.png b/docs/images/app-connections/railway/railway-app-connection-team-token-create.png new file mode 100644 index 000000000..d5c368e83 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-team-token-create.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-team-token-created.png b/docs/images/app-connections/railway/railway-app-connection-team-token-created.png new file mode 100644 index 000000000..4c3ef583d Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-team-token-created.png differ diff --git a/docs/images/app-connections/railway/railway-app-connection-team-token-form.png b/docs/images/app-connections/railway/railway-app-connection-team-token-form.png new file mode 100644 index 000000000..be7e10710 Binary files /dev/null and b/docs/images/app-connections/railway/railway-app-connection-team-token-form.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-created.png b/docs/images/secret-syncs/railway/railway-sync-created.png new file mode 100644 index 000000000..0b965ed5c Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-created.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-destination.png b/docs/images/secret-syncs/railway/railway-sync-destination.png new file mode 100644 index 000000000..2c401ac3d Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-destination.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-details.png b/docs/images/secret-syncs/railway/railway-sync-details.png new file mode 100644 index 000000000..9667ac8fb Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-details.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-options.png b/docs/images/secret-syncs/railway/railway-sync-options.png new file mode 100644 index 000000000..874b4c866 Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-options.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-review.png b/docs/images/secret-syncs/railway/railway-sync-review.png new file mode 100644 index 000000000..8f381d59e Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-review.png differ diff --git a/docs/images/secret-syncs/railway/railway-sync-source.png b/docs/images/secret-syncs/railway/railway-sync-source.png new file mode 100644 index 000000000..25cfe51d7 Binary files /dev/null and b/docs/images/secret-syncs/railway/railway-sync-source.png differ diff --git a/docs/images/secret-syncs/railway/select-option.png b/docs/images/secret-syncs/railway/select-option.png new file mode 100644 index 000000000..54f68a959 Binary files /dev/null and b/docs/images/secret-syncs/railway/select-option.png differ diff --git a/docs/integrations/app-connections/railway.mdx b/docs/integrations/app-connections/railway.mdx new file mode 100644 index 000000000..7b53d02ad --- /dev/null +++ b/docs/integrations/app-connections/railway.mdx @@ -0,0 +1,164 @@ +--- +title: "Railway Connection" +description: "Learn how to configure a Railway Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://docs.railway.com/guides/public-api#creating-a-token) to connect with Railway. + +## Create a Railway API Token + + + + A team token provides access to all resources within a team. It cannot be used to access personal resources in Railway. + + + + ![Dashboard Page](/images/app-connections/railway/railway-app-connection-account-settings.png) + + + ![Account Settings Page](/images/app-connections/railway/railway-app-connection-account-settings-tokens.png) + + + Make sure to provide a descriptive name and select the correct team. + + ![Enter Name and Select Team](/images/app-connections/railway/railway-app-connection-team-token-form.png) + + + ![Create Token](/images/app-connections/railway/railway-app-connection-team-token-create.png) + + + After clicking 'Create', your access token will be displayed. Save it securely for later use. + + ![Copy Token Modal](/images/app-connections/railway/railway-app-connection-team-token-created.png) + + + + + + If no team is selected, the token will be associated with your personal Railway account and will have access to all your individual and team resources. + + + + ![Dashboard Page](/images/app-connections/railway/railway-app-connection-account-settings.png) + + + ![Account Settings Page](/images/app-connections/railway/railway-app-connection-account-settings-tokens.png) + + + Provide a descriptive name and ensure no team is selected. This will create an account-level token. + + ![Enter Name](/images/app-connections/railway/railway-app-connection-account-token-form.png) + + + ![Create Token](/images/app-connections/railway/railway-app-connection-account-token-create.png) + + + After clicking 'Create', your access token will be shown. Save it for future use. + + ![Copy Token Modal](/images/app-connections/railway/railway-app-connection-account-token-created.png) + + + + + + Project tokens are limited to a specific environment within a project and can only be used to authenticate requests to that environment. + + + + ![Dashboard Page](/images/app-connections/railway/railway-app-connection-project-token-dashboard.png) + + + ![Project Settings Page](/images/app-connections/railway/railway-app-connection-project-token-project.png) + + + ![Project Token Settings Page](/images/app-connections/railway/railway-app-connection-project-token-settings.png) + + + Provide a descriptive name and select the appropriate environment for the token. + + ![Enter Name and Select environment](/images/app-connections/railway/railway-app-connection-project-token-form.png) + + + ![Create Token](/images/app-connections/railway/railway-app-connection-project-token-create.png) + + + After clicking 'Create', the access token will be displayed. Be sure to save it for later use. + + ![Copy Token Modal](/images/app-connections/railway/railway-app-connection-project-token-created.png) + + + + + +## Create a Railway Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Railway Connection** from the list of integrations. + + ![Select Railway Connection](/images/app-connections/railway/railway-app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The type of token you created earlier + - The token value from the previous step + + ![Railway Connection Modal](/images/app-connections/railway/railway-app-connection-form.png) + + + After submitting the form, your **Railway Connection** will be successfully created and ready to use with your Infisical projects. + + ![Railway Connection Created](/images/app-connections/railway/railway-app-connection-generated.png) + + + + + + To create a Railway Connection via API, send a request to the [Create Railway Connection](/api-reference/endpoints/app-connections/railway/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/railway \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-railway-connection", + "method": "team-token", + "credentials": { + "apiToken": "[TEAM TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-railway-connection", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "railway", + "method": "team-token", + "credentials": {} + } + } + ``` + + diff --git a/docs/integrations/secret-syncs/railway.mdx b/docs/integrations/secret-syncs/railway.mdx new file mode 100644 index 000000000..d0d546984 --- /dev/null +++ b/docs/integrations/secret-syncs/railway.mdx @@ -0,0 +1,171 @@ +--- +title: "Railway Sync" +description: "Learn how to configure a Railway Sync for Infisical." +--- + +**Prerequisites:** +- Create a [Railway Connection](/integrations/app-connections/railway) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Railway](/images/secret-syncs/railway/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/railway/railway-sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/railway/railway-sync-destination.png) + + - **Railway Connection**: The Railway Connection to authenticate with. + - **Project**: The Railway project to sync secrets to. + - **Environment**: The Railway environment to sync secrets to. + - **Service**: The Service to sync secrets to. + - **If not provided**: Secrets will be synced as [shared variables](https://docs.railway.com/guides/variables#shared-variables) on Railway. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/railway/railway-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Railway when keys conflict. + - **Import Secrets (Prioritize Railway)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Railway over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Railway Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/railway/railway-sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Railway Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/railway/railway-sync-review.png) + + + If enabled, your Railway Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/railway/railway-sync-created.png) + + + + + To create a **Railway Sync**, make an API request to the [Create Railway Sync](/api-reference/endpoints/secret-syncs/railway/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/railway \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-railway-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "destinationConfig": { + "projectId": "dev-project-id", + "projectName": "Development Project", + "environmentId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environmentName": "Development", + "serviceId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "serviceName": "my-railway-service", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-railway-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "railway", + "name": "my-railway-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "railway", + "destinationConfig": { + "projectId": "dev-project-id", + "projectName": "Development Project", + "environmentId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environmentName": "Development", + "serviceId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "serviceName": "my-railway-service", + } + } + } + ``` + + diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RailwaySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RailwaySyncFields.tsx new file mode 100644 index 000000000..6095f5763 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RailwaySyncFields.tsx @@ -0,0 +1,138 @@ +import { useMemo } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { + TRailwayProject, + useRailwayConnectionListProjects +} from "@app/hooks/api/appConnections/railway"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const RailwaySyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Railway } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const projectId = useWatch({ name: "destinationConfig.projectId", control }); + + const { data: projects = [], isPending: isProjectsLoading } = useRailwayConnectionListProjects( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const environments = useMemo(() => { + return projects.find((p) => p.id === projectId)?.environments ?? []; + }, [projects, projectId]); + + const services = useMemo(() => { + return projects.find((p) => p.id === projectId)?.services ?? []; + }, [projects, projectId]); + + return ( + <> + { + setValue("destinationConfig.environmentId", ""); + setValue("destinationConfig.projectId", ""); + setValue("destinationConfig.serviceId", ""); + setValue("destinationConfig.projectName", ""); + setValue("destinationConfig.environmentName", ""); + setValue("destinationConfig.serviceName", ""); + }} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.projectName", v?.name ?? ""); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.environmentName", v?.name ?? ""); + }} + options={environments} + placeholder="Select an environment..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.serviceName", v?.name ?? ""); + }} + options={services} + placeholder="Select a service..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index da8686cc1..f530e0a52 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -20,6 +20,7 @@ import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; +import { RailwaySyncFields } from "./RailwaySyncFields"; import { RenderSyncFields } from "./RenderSyncFields"; import { TeamCitySyncFields } from "./TeamCitySyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; @@ -79,6 +80,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Zabbix: return ; + case SecretSync.Railway: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index d91fc3771..57967f130 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -59,6 +59,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.GitLab: case SecretSync.CloudflarePages: case SecretSync.Zabbix: + case SecretSync.Railway: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RailwaySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RailwaySyncReviewFields.tsx new file mode 100644 index 000000000..717ad9ae7 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RailwaySyncReviewFields.tsx @@ -0,0 +1,29 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { useRailwayConnectionListProjects } from "@app/hooks/api/appConnections/railway"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const RailwaySyncReviewFields = () => { + const { watch } = useFormContext(); + const connectionId = watch("connection.id"); + const projectId = watch("destinationConfig.projectId"); + const environmentId = watch("destinationConfig.environmentId"); + + const { data: projects = [] } = useRailwayConnectionListProjects(connectionId, { + enabled: Boolean(connectionId) + }); + + const project = projects.find((p) => p.id === projectId); + const environment = project?.environments.find((e) => e.id === environmentId); + + return ( + <> + {project?.name ?? projectId} + + {environment?.name ?? environmentId} + + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index e2ffb9fa6..3b3a9cf74 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -30,6 +30,7 @@ import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; +import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields"; import { RenderSyncReviewFields } from "./RenderSyncReviewFields"; import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; @@ -128,6 +129,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Zabbix: DestinationFieldsComponent = ; break; + case SecretSync.Railway: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/railway-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/railway-sync-destination-schema.ts new file mode 100644 index 000000000..2870d6087 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/railway-sync-destination-schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const RailwaySyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Railway), + destinationConfig: z.object({ + projectId: z.string().min(1, "Project ID is required"), + projectName: z.string(), + environmentName: z.string(), + environmentId: z.string().min(1, "Environment is required"), + serviceId: z.string().optional(), + serviceName: z.string().optional() + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 331768e7f..425fd2414 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -17,6 +17,7 @@ import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; +import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; @@ -47,7 +48,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ FlyioSyncDestinationSchema, GitlabSyncDestinationSchema, CloudflarePagesSyncDestinationSchema, - ZabbixSyncDestinationSchema + ZabbixSyncDestinationSchema, + RailwaySyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index fa6f11bbb..900067180 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -43,6 +43,7 @@ import { import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/bitbucket-connection"; import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; +import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; export const APP_CONNECTION_MAP: Record< @@ -91,8 +92,9 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" }, [AppConnection.Gitlab]: { name: "GitLab", image: "GitLab.png" }, [AppConnection.Cloudflare]: { name: "Cloudflare", image: "Cloudflare.png" }, - [AppConnection.Bitbucket]: { name: "Bitbucket", image: "Bitbucket.png" }, - [AppConnection.Zabbix]: { name: "Zabbix", image: "Zabbix.png" } + [AppConnection.Zabbix]: { name: "Zabbix", image: "Zabbix.png" }, + [AppConnection.Railway]: { name: "Railway", image: "Railway.png" }, + [AppConnection.Bitbucket]: { name: "Bitbucket", image: "Bitbucket.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -146,6 +148,12 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "Simple Bind", icon: faLink }; case HerokuConnectionMethod.AuthToken: return { name: "Auth Token", icon: faKey }; + case RailwayConnectionMethod.AccountToken: + return { name: "Account Token", icon: faKey }; + case RailwayConnectionMethod.TeamToken: + return { name: "Team Token", icon: faKey }; + case RailwayConnectionMethod.ProjectToken: + return { name: "Project Token", icon: faKey }; case RenderConnectionMethod.ApiKey: return { name: "API Key", icon: faKey }; default: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index e42898036..5821ce959 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -85,6 +85,10 @@ export const SECRET_SYNC_MAP: Record = { [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_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 31443c67d..e6b623995 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -29,5 +29,6 @@ export enum AppConnection { Gitlab = "gitlab", Cloudflare = "cloudflare", Bitbucket = "bitbucket", - Zabbix = "zabbix" + Zabbix = "zabbix", + Railway = "railway" } diff --git a/frontend/src/hooks/api/appConnections/railway/index.ts b/frontend/src/hooks/api/appConnections/railway/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/railway/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/railway/queries.tsx b/frontend/src/hooks/api/appConnections/railway/queries.tsx new file mode 100644 index 000000000..0b1e0d51a --- /dev/null +++ b/frontend/src/hooks/api/appConnections/railway/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TRailwayProject } from "./types"; + +const railwayConnectionKeys = { + all: [...appConnectionKeys.all, "railway"] as const, + listSecretScopes: (connectionId: string) => + [...railwayConnectionKeys.all, "workspace-scopes", connectionId] as const +}; + +export const useRailwayConnectionListProjects = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TRailwayProject[], + unknown, + TRailwayProject[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: railwayConnectionKeys.listSecretScopes(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ projects: TRailwayProject[] }>( + `/api/v1/app-connections/railway/${connectionId}/projects` + ); + + return data.projects; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/railway/types.ts b/frontend/src/hooks/api/appConnections/railway/types.ts new file mode 100644 index 000000000..1c1279454 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/railway/types.ts @@ -0,0 +1,12 @@ +export type TRailwayProject = { + id: string; + name: string; + environments: Array<{ + id: string; + name: string; + }>; + services: Array<{ + id: string; + name: string; + }>; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index e7d672a34..c5e5fdda6 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -140,6 +140,10 @@ export type TZabbixConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Zabbix; }; +export type TRailwayConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Railway; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -169,7 +173,8 @@ export type TAppConnectionOption = | TGitlabConnectionOption | TCloudflareConnectionOption | TBitbucketConnectionOption - | TZabbixConnectionOption; + | TZabbixConnectionOption + | TRailwayConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -203,4 +208,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Cloudflare]: TCloudflareConnectionOption; [AppConnection.Bitbucket]: TBitbucketConnectionOption; [AppConnection.Zabbix]: TZabbixConnectionOption; + [AppConnection.Railway]: TRailwayConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 4aabde9ad..524b988d7 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -25,6 +25,7 @@ import { TMySqlConnection } from "./mysql-connection"; import { TOCIConnection } from "./oci-connection"; import { TOracleDBConnection } from "./oracledb-connection"; import { TPostgresConnection } from "./postgres-connection"; +import { TRailwayConnection } from "./railway-connection"; import { TRenderConnection } from "./render-connection"; import { TTeamCityConnection } from "./teamcity-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; @@ -95,7 +96,8 @@ export type TAppConnection = | TGitLabConnection | TCloudflareConnection | TBitbucketConnection - | TZabbixConnection; + | TZabbixConnection + | TRailwayConnection; export type TAvailableAppConnection = Pick; @@ -154,4 +156,5 @@ export type TAppConnectionMap = { [AppConnection.Cloudflare]: TCloudflareConnection; [AppConnection.Bitbucket]: TBitbucketConnection; [AppConnection.Zabbix]: TZabbixConnection; + [AppConnection.Railway]: TRailwayConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/railway-connection.ts b/frontend/src/hooks/api/appConnections/types/railway-connection.ts new file mode 100644 index 000000000..db961c7c3 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/railway-connection.ts @@ -0,0 +1,15 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum RailwayConnectionMethod { + AccountToken = "account-token", + ProjectToken = "project-token", + TeamToken = "team-token" +} + +export type TRailwayConnection = TRootAppConnection & { app: AppConnection.Railway } & { + method: RailwayConnectionMethod; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index ab79f73bb..a6ba1fd40 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -21,7 +21,8 @@ export enum SecretSync { Flyio = "flyio", GitLab = "gitlab", CloudflarePages = "cloudflare-pages", - Zabbix = "zabbix" + Zabbix = "zabbix", + Railway = "railway" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 33119b8a3..7071576bf 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -19,6 +19,7 @@ import { THCVaultSync } from "./hc-vault-sync"; import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; +import { TRailwaySync } from "./railway-sync"; import { TTeamCitySync } from "./teamcity-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; import { TVercelSync } from "./vercel-sync"; @@ -55,7 +56,8 @@ export type TSecretSync = | TFlyioSync | TGitLabSync | TCloudflarePagesSync - | TZabbixSync; + | TZabbixSync + | TRailwaySync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/railway-sync.ts b/frontend/src/hooks/api/secretSyncs/types/railway-sync.ts new file mode 100644 index 000000000..7a99bca7a --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/railway-sync.ts @@ -0,0 +1,22 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TRailwaySync = TRootSecretSync & { + destination: SecretSync.Railway; + destinationConfig: { + projectId: string; + projectName: string; + + environmentName: string; + environmentId: string; + + serviceId?: string; + serviceName?: string; + }; + connection: { + app: AppConnection.Railway; + name: string; + id: string; + }; +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 0e080d6dd..a84ad8aa0 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -34,6 +34,7 @@ import { MySqlConnectionForm } from "./MySqlConnectionForm"; import { OCIConnectionForm } from "./OCIConnectionForm"; import { OracleDBConnectionForm } from "./OracleDBConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; +import { RailwayConnectionForm } from "./RailwayConnectionForm"; import { RenderConnectionForm } from "./RenderConnectionForm"; import { TeamCityConnectionForm } from "./TeamCityConnectionForm"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; @@ -140,6 +141,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Zabbix: return ; + case AppConnection.Railway: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -238,6 +241,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Zabbix: return ; + case AppConnection.Railway: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RailwayConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RailwayConnectionForm.tsx new file mode 100644 index 000000000..e6403627f --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RailwayConnectionForm.tsx @@ -0,0 +1,135 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + RailwayConnectionMethod, + TRailwayConnection +} from "@app/hooks/api/appConnections/types/railway-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TRailwayConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Railway) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.nativeEnum(RailwayConnectionMethod), + credentials: z.object({ + apiToken: z.string().trim().min(1, "Service API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const RailwayConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Railway, + method: RailwayConnectionMethod.AccountToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RailwaySyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RailwaySyncDestinationCol.tsx new file mode 100644 index 000000000..05370793f --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RailwaySyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TRailwaySync } from "@app/hooks/api/secretSyncs/types/railway-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TRailwaySync; +}; + +export const RailwaySyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index c980df5bd..1b357cffb 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -17,6 +17,7 @@ import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol"; import { HerokuSyncDestinationCol } from "./HerokuSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; import { OCIVaultSyncDestinationCol } from "./OCIVaultSyncDestinationCol"; +import { RailwaySyncDestinationCol } from "./RailwaySyncDestinationCol"; import { RenderSyncDestinationCol } from "./RenderSyncDestinationCol"; import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; @@ -76,6 +77,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Zabbix: return ; + case SecretSync.Railway: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 1787162e3..a8bad462d 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -156,6 +156,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { throw new Error(`Unhandled Zabbix Scope Destination Col Values ${destination}`); } break; + case SecretSync.Railway: + primaryText = "Railway Project"; + secondaryText = destinationConfig.projectName; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RailwaySyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RailwaySyncDestinationSection.tsx new file mode 100644 index 000000000..03b650c5b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RailwaySyncDestinationSection.tsx @@ -0,0 +1,17 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TRailwaySync } from "@app/hooks/api/secretSyncs/types/railway-sync"; + +type Props = { + secretSync: TRailwaySync; +}; + +export const RailwaySyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + return ( + <> + {destinationConfig.projectName} + {destinationConfig.environmentName} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 15c1b61f1..568c3b3db 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -28,6 +28,7 @@ import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection"; import { HerokuSyncDestinationSection } from "./HerokuSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; import { OCIVaultSyncDestinationSection } from "./OCIVaultSyncDestinationSection"; +import { RailwaySyncDestinationSection } from "./RailwaySyncDestinationSection"; import { RenderSyncDestinationSection } from "./RenderSyncDestinationSection"; import { TeamCitySyncDestinationSection } from "./TeamCitySyncDestinationSection"; import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection"; @@ -118,6 +119,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Zabbix: DestinationComponents = ; break; + case SecretSync.Railway: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 225800783..debdfcbdf 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -61,6 +61,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.GitLab: case SecretSync.CloudflarePages: case SecretSync.Zabbix: + case SecretSync.Railway: AdditionalSyncOptionsComponent = null; break; default: