From fdfd4a582566a214d1bcc7b4baaea07efac4bab8 Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Wed, 17 Sep 2025 21:57:16 -0300 Subject: [PATCH 1/5] use upsert collection instead of upserting every variable --- .../railway-connection-public-client.ts | 31 ++++++- .../secret-sync/railway/railway-sync-fns.ts | 88 ++++++++++--------- 2 files changed, 74 insertions(+), 45 deletions(-) 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 index 1c8bd9cc2..85cc309da 100644 --- a/backend/src/services/app-connection/railway/railway-connection-public-client.ts +++ b/backend/src/services/app-connection/railway/railway-connection-public-client.ts @@ -75,7 +75,7 @@ class RailwayPublicClient { async send( query: string, options: RailwaySendReqOptions, - variables: Record> = {}, + variables: Record = {}, retryAttempt: number = 0 ): Promise { const body = { @@ -117,6 +117,22 @@ class RailwayPublicClient { } } + async getDeployments(config: RailwaySendReqOptions, variables: { input: { serviceId: string; environmentId: string }, first?: number }) { + return await this.send>( + `query deployments($input: DeploymentListInput!, $first: Int) { deployments(first: $first, input: $input) { edges { node { id } } } }`, + config, + variables + ); + } + + async redeployDeployment(config: RailwaySendReqOptions, variables: { input: { deploymentId: string } }) { + return await this.send>( + `mutation deploymentRedeploy($deploymentId: String!) { deploymentRedeploy(id: $deploymentId) { id } }`, + config, + { deploymentId: variables.input.deploymentId } + ); + } + async getSubscriptionType(config: RailwaySendReqOptions & { projectId: string }) { const res = await this.send( `query project($projectId: String!) { project(id: $projectId) { subscriptionType }}`, @@ -213,7 +229,7 @@ class RailwayPublicClient { async deleteVariable( config: RailwaySendReqOptions, - variables: { input: { projectId: string; environmentId: string; name: string; serviceId?: string } } + variables: { input: { projectId: string; environmentId: string; name: string; skipDeploys?: boolean; serviceId?: string } } ) { await this.send }>>( `mutation variableDelete($input: VariableDeleteInput!) { variableDelete(input: $input) }`, @@ -222,6 +238,17 @@ class RailwayPublicClient { ); } + async upsertCollection( + config: RailwaySendReqOptions, + variables: { input: { projectId: string; environmentId: string; variables: Record; skipDeploys?: boolean; serviceId?: string, replace?: boolean } } + ) { + return await this.send>( + `mutation variableCollectionUpsert($input: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $input) }`, + config, + variables, + ); + } + async upsertVariable( config: RailwaySendReqOptions, variables: { input: { projectId: string; environmentId: string; name: string; value: string; serviceId?: string } } diff --git a/backend/src/services/secret-sync/railway/railway-sync-fns.ts b/backend/src/services/secret-sync/railway/railway-sync-fns.ts index 07862aeb5..ce18e526f 100644 --- a/backend/src/services/secret-sync/railway/railway-sync-fns.ts +++ b/backend/src/services/secret-sync/railway/railway-sync-fns.ts @@ -40,61 +40,63 @@ export const RailwaySyncFns = { } }, + /** + * Syncs secrets to Railway and redeploys the service if needed. + * + * Gets existing Railway vars, merges with new secrets (keeping Railway vars if deletion is disabled), + * then replaces every variable with the new values, if variable is not in the secretMap, it is deleted. + * If there's a service, triggers a redeploy to pick up the changes. + */ async syncSecrets(secretSync: TRailwaySyncWithCredentials, secretMap: TSecretMap) { const { - environment, - syncOptions: { disableSecretDeletion, keySchema } + syncOptions: { disableSecretDeletion } } = secretSync; const railwaySecrets = await this.getSecrets(secretSync); const config = secretSync.destinationConfig; - for await (const key of Object.keys(secretMap)) { - try { - const existing = railwaySecrets[key]; + const railwaySecretsMap = Object.fromEntries(Object.entries(railwaySecrets).map(([key, secret]) => [key, secret.value])); + const secretMapMap = Object.fromEntries(Object.entries(secretMap).map(([key, secret]) => [key, secret.value])); - 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 - }); + const toReplace = disableSecretDeletion + ? { ...railwaySecretsMap, ...secretMapMap } + : secretMapMap; + + const upserted = await RailwayPublicAPI.upsertCollection(secretSync.connection, { + input: { + projectId: config.projectId, + environmentId: config.environmentId, + serviceId: config.serviceId || undefined, + skipDeploys: true, + variables: toReplace, + replace: true, } - } + }); - if (disableSecretDeletion) return; + if (!upserted) throw new SecretSyncError({ + message: "Failed to upsert secrets to Railway", + }) - for await (const key of Object.keys(railwaySecrets)) { - try { - // eslint-disable-next-line no-continue - if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + if (!config.serviceId) return; - 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 - }); + const latestDeployment = await RailwayPublicAPI.getDeployments(secretSync.connection, { + input: { + serviceId: config.serviceId, + environmentId: config.environmentId + }, + first: 1, + }); + + const latestDeploymentId = latestDeployment?.deployments.edges[0].node.id; + + if (!latestDeploymentId) throw new SecretSyncError({ + message: "Failed to get latest deployment from Railway", + }) + + await RailwayPublicAPI.redeployDeployment(secretSync.connection, { + input: { + deploymentId: latestDeploymentId } - } + }); }, async removeSecrets(secretSync: TRailwaySyncWithCredentials, secretMap: TSecretMap) { From 051853786821e16fc8f4c9ebed56a456aeb8a85c Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Thu, 18 Sep 2025 10:07:44 -0300 Subject: [PATCH 2/5] add try catch --- .../secret-sync/railway/railway-sync-fns.ts | 136 ++++++++++-------- 1 file changed, 80 insertions(+), 56 deletions(-) diff --git a/backend/src/services/secret-sync/railway/railway-sync-fns.ts b/backend/src/services/secret-sync/railway/railway-sync-fns.ts index ce18e526f..5f1fa4b8a 100644 --- a/backend/src/services/secret-sync/railway/railway-sync-fns.ts +++ b/backend/src/services/secret-sync/railway/railway-sync-fns.ts @@ -42,85 +42,109 @@ export const RailwaySyncFns = { /** * Syncs secrets to Railway and redeploys the service if needed. - * + * * Gets existing Railway vars, merges with new secrets (keeping Railway vars if deletion is disabled), - * then replaces every variable with the new values, if variable is not in the secretMap, it is deleted. + * then replaces every variable with the new values, if variable is not in the secretMap, it is deleted. * If there's a service, triggers a redeploy to pick up the changes. */ async syncSecrets(secretSync: TRailwaySyncWithCredentials, secretMap: TSecretMap) { - const { - syncOptions: { disableSecretDeletion } - } = secretSync; - const railwaySecrets = await this.getSecrets(secretSync); - const config = secretSync.destinationConfig; + try { + const { + syncOptions: { disableSecretDeletion } + } = secretSync; + const railwaySecrets = await this.getSecrets(secretSync); + const config = secretSync.destinationConfig; - const railwaySecretsMap = Object.fromEntries(Object.entries(railwaySecrets).map(([key, secret]) => [key, secret.value])); - const secretMapMap = Object.fromEntries(Object.entries(secretMap).map(([key, secret]) => [key, secret.value])); + const railwaySecretsMap = Object.fromEntries( + Object.entries(railwaySecrets).map(([key, secret]) => [key, secret.value]) + ); + const secretMapMap = Object.fromEntries(Object.entries(secretMap).map(([key, secret]) => [key, secret.value])); - const toReplace = disableSecretDeletion - ? { ...railwaySecretsMap, ...secretMapMap } - : secretMapMap; + const toReplace = disableSecretDeletion ? { ...railwaySecretsMap, ...secretMapMap } : secretMapMap; - const upserted = await RailwayPublicAPI.upsertCollection(secretSync.connection, { - input: { - projectId: config.projectId, - environmentId: config.environmentId, - serviceId: config.serviceId || undefined, - skipDeploys: true, - variables: toReplace, - replace: true, - } - }); + const upserted = await RailwayPublicAPI.upsertCollection(secretSync.connection, { + input: { + projectId: config.projectId, + environmentId: config.environmentId, + serviceId: config.serviceId || undefined, + skipDeploys: true, + variables: toReplace, + replace: true + } + }); - if (!upserted) throw new SecretSyncError({ - message: "Failed to upsert secrets to Railway", - }) + if (!upserted) + throw new SecretSyncError({ + message: "Failed to upsert secrets to Railway" + }); - if (!config.serviceId) return; + if (!config.serviceId) return; - const latestDeployment = await RailwayPublicAPI.getDeployments(secretSync.connection, { - input: { - serviceId: config.serviceId, - environmentId: config.environmentId - }, - first: 1, - }); + const latestDeployment = await RailwayPublicAPI.getDeployments(secretSync.connection, { + input: { + serviceId: config.serviceId, + environmentId: config.environmentId + }, + first: 1 + }); - const latestDeploymentId = latestDeployment?.deployments.edges[0].node.id; + const latestDeploymentId = latestDeployment?.deployments.edges[0].node.id; - if (!latestDeploymentId) throw new SecretSyncError({ - message: "Failed to get latest deployment from Railway", - }) + if (!latestDeploymentId) + throw new SecretSyncError({ + message: "Failed to get latest deployment from Railway" + }); - await RailwayPublicAPI.redeployDeployment(secretSync.connection, { - input: { - deploymentId: latestDeploymentId - } - }); + await RailwayPublicAPI.redeployDeployment(secretSync.connection, { + input: { + deploymentId: latestDeploymentId + } + }); + } catch (error) { + if (error instanceof SecretSyncError) throw error; + + throw new SecretSyncError({ + error, + message: "Failed to sync secrets to Railway" + }); + } }, 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 - } - }); + // Create a new variables object excluding secrets that exist in secretMap + const remainingVariables = Object.fromEntries( + Object.entries(existing) + .filter(([key]) => !(key in secretMap)) + .map(([key, secret]) => [key, secret.value]) + ); + + try { + const upserted = await RailwayPublicAPI.upsertCollection(secretSync.connection, { + input: { + projectId: config.projectId, + environmentId: config.environmentId, + serviceId: config.serviceId || undefined, + skipDeploys: true, + variables: remainingVariables, + replace: true } - } catch (error) { + }); + + if (!upserted) { throw new SecretSyncError({ - error, - secretKey: secret + message: "Failed to remove secrets from Railway" }); } + } catch (error) { + if (error instanceof SecretSyncError) throw error; + + throw new SecretSyncError({ + error, + message: "Failed to remove secrets from Railway" + }); } } }; From fb7cabd7d72bb364faf5e1ae9313bc268eb3aed3 Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Thu, 18 Sep 2025 10:08:23 -0300 Subject: [PATCH 3/5] remove matches schema --- backend/src/services/secret-sync/railway/railway-sync-fns.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/services/secret-sync/railway/railway-sync-fns.ts b/backend/src/services/secret-sync/railway/railway-sync-fns.ts index 5f1fa4b8a..8fd5e6597 100644 --- a/backend/src/services/secret-sync/railway/railway-sync-fns.ts +++ b/backend/src/services/secret-sync/railway/railway-sync-fns.ts @@ -2,7 +2,6 @@ /* 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"; From 2ab6142d8d2a27863724df5f1bf79f3004c98be1 Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Thu, 18 Sep 2025 10:17:58 -0300 Subject: [PATCH 4/5] add matches schema --- .../src/services/secret-sync/railway/railway-sync-fns.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/src/services/secret-sync/railway/railway-sync-fns.ts b/backend/src/services/secret-sync/railway/railway-sync-fns.ts index 8fd5e6597..731691acf 100644 --- a/backend/src/services/secret-sync/railway/railway-sync-fns.ts +++ b/backend/src/services/secret-sync/railway/railway-sync-fns.ts @@ -2,6 +2,7 @@ /* 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"; @@ -11,6 +12,8 @@ export const RailwaySyncFns = { async getSecrets(secretSync: TRailwaySyncWithCredentials): Promise { try { const config = secretSync.destinationConfig; + const { keySchema } = secretSync.syncOptions; + const { environment } = secretSync; const variables = await RailwayPublicAPI.getVariables(secretSync.connection, { projectId: config.projectId, @@ -25,6 +28,10 @@ export const RailwaySyncFns = { // eslint-disable-next-line no-continue if (key.startsWith("RAILWAY_")) continue; + // Check if key matches the schema + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + entries[key] = { value }; From ddffa3dc8f09a0003f114971f1a4e92ea526ada8 Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Tue, 30 Sep 2025 15:10:09 -0300 Subject: [PATCH 5/5] run lint --- .../railway-connection-public-client.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) 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 index 85cc309da..47eb3f396 100644 --- a/backend/src/services/app-connection/railway/railway-connection-public-client.ts +++ b/backend/src/services/app-connection/railway/railway-connection-public-client.ts @@ -117,8 +117,11 @@ class RailwayPublicClient { } } - async getDeployments(config: RailwaySendReqOptions, variables: { input: { serviceId: string; environmentId: string }, first?: number }) { - return await this.send>( + async getDeployments( + config: RailwaySendReqOptions, + variables: { input: { serviceId: string; environmentId: string }; first?: number } + ) { + return this.send>( `query deployments($input: DeploymentListInput!, $first: Int) { deployments(first: $first, input: $input) { edges { node { id } } } }`, config, variables @@ -126,7 +129,7 @@ class RailwayPublicClient { } async redeployDeployment(config: RailwaySendReqOptions, variables: { input: { deploymentId: string } }) { - return await this.send>( + return this.send>( `mutation deploymentRedeploy($deploymentId: String!) { deploymentRedeploy(id: $deploymentId) { id } }`, config, { deploymentId: variables.input.deploymentId } @@ -229,7 +232,9 @@ class RailwayPublicClient { async deleteVariable( config: RailwaySendReqOptions, - variables: { input: { projectId: string; environmentId: string; name: string; skipDeploys?: boolean; serviceId?: string } } + variables: { + input: { projectId: string; environmentId: string; name: string; skipDeploys?: boolean; serviceId?: string }; + } ) { await this.send }>>( `mutation variableDelete($input: VariableDeleteInput!) { variableDelete(input: $input) }`, @@ -240,12 +245,21 @@ class RailwayPublicClient { async upsertCollection( config: RailwaySendReqOptions, - variables: { input: { projectId: string; environmentId: string; variables: Record; skipDeploys?: boolean; serviceId?: string, replace?: boolean } } + variables: { + input: { + projectId: string; + environmentId: string; + variables: Record; + skipDeploys?: boolean; + serviceId?: string; + replace?: boolean; + }; + } ) { - return await this.send>( + return this.send>( `mutation variableCollectionUpsert($input: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $input) }`, config, - variables, + variables ); }