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..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 @@ -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,25 @@ class RailwayPublicClient { } } + 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 + ); + } + + async redeployDeployment(config: RailwaySendReqOptions, variables: { input: { deploymentId: string } }) { + return 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 +232,9 @@ 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 +243,26 @@ class RailwayPublicClient { ); } + async upsertCollection( + config: RailwaySendReqOptions, + variables: { + input: { + projectId: string; + environmentId: string; + variables: Record; + skipDeploys?: boolean; + serviceId?: string; + replace?: boolean; + }; + } + ) { + return 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..731691acf 100644 --- a/backend/src/services/secret-sync/railway/railway-sync-fns.ts +++ b/backend/src/services/secret-sync/railway/railway-sync-fns.ts @@ -12,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, @@ -26,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 }; @@ -40,60 +46,73 @@ 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 } - } = 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; - 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 ?? "" - } - }); + 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 } - } catch (error) { + }); + + if (!upserted) throw new SecretSyncError({ - error, - secretKey: key + message: "Failed to upsert secrets to Railway" }); - } - } - if (disableSecretDeletion) return; + if (!config.serviceId) return; - for await (const key of Object.keys(railwaySecrets)) { - try { - // eslint-disable-next-line no-continue - if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + const latestDeployment = await RailwayPublicAPI.getDeployments(secretSync.connection, { + input: { + serviceId: config.serviceId, + environmentId: config.environmentId + }, + first: 1 + }); - if (!secretMap[key]) { - await RailwayPublicAPI.deleteVariable(secretSync.connection, { - input: { - projectId: config.projectId, - environmentId: config.environmentId, - serviceId: config.serviceId || undefined, - name: key - } - }); + 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 } - } catch (error) { - throw new SecretSyncError({ - error, - secretKey: key - }); - } + }); + } catch (error) { + if (error instanceof SecretSyncError) throw error; + + throw new SecretSyncError({ + error, + message: "Failed to sync secrets to Railway" + }); } }, @@ -101,24 +120,37 @@ export const RailwaySyncFns = { 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" + }); } } };