Merge pull request #4548 from ThallesP/main

fix: use upsert collection instead of upserting each variable on Railway
This commit is contained in:
Akhil Mohan
2025-10-09 11:23:35 +05:30
committed by GitHub
2 changed files with 133 additions and 60 deletions
@@ -75,7 +75,7 @@ class RailwayPublicClient {
async send<T extends TRailwayResponse>( async send<T extends TRailwayResponse>(
query: string, query: string,
options: RailwaySendReqOptions, options: RailwaySendReqOptions,
variables: Record<string, string | Record<string, string>> = {}, variables: Record<string, unknown> = {},
retryAttempt: number = 0 retryAttempt: number = 0
): Promise<T["data"] | undefined> { ): Promise<T["data"] | undefined> {
const body = { const body = {
@@ -117,6 +117,25 @@ class RailwayPublicClient {
} }
} }
async getDeployments(
config: RailwaySendReqOptions,
variables: { input: { serviceId: string; environmentId: string }; first?: number }
) {
return this.send<TRailwayResponse<{ deployments: { edges: { node: { id: string } }[] } }>>(
`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<TRailwayResponse<{ deploymentRedeploy: { id: string } }>>(
`mutation deploymentRedeploy($deploymentId: String!) { deploymentRedeploy(id: $deploymentId) { id } }`,
config,
{ deploymentId: variables.input.deploymentId }
);
}
async getSubscriptionType(config: RailwaySendReqOptions & { projectId: string }) { async getSubscriptionType(config: RailwaySendReqOptions & { projectId: string }) {
const res = await this.send( const res = await this.send(
`query project($projectId: String!) { project(id: $projectId) { subscriptionType }}`, `query project($projectId: String!) { project(id: $projectId) { subscriptionType }}`,
@@ -213,7 +232,9 @@ class RailwayPublicClient {
async deleteVariable( async deleteVariable(
config: RailwaySendReqOptions, 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<TRailwayResponse<{ variables: Record<string, string> }>>( await this.send<TRailwayResponse<{ variables: Record<string, string> }>>(
`mutation variableDelete($input: VariableDeleteInput!) { variableDelete(input: $input) }`, `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<string, string>;
skipDeploys?: boolean;
serviceId?: string;
replace?: boolean;
};
}
) {
return this.send<TRailwayResponse<boolean>>(
`mutation variableCollectionUpsert($input: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $input) }`,
config,
variables
);
}
async upsertVariable( async upsertVariable(
config: RailwaySendReqOptions, config: RailwaySendReqOptions,
variables: { input: { projectId: string; environmentId: string; name: string; value: string; serviceId?: string } } variables: { input: { projectId: string; environmentId: string; name: string; value: string; serviceId?: string } }
@@ -12,6 +12,8 @@ export const RailwaySyncFns = {
async getSecrets(secretSync: TRailwaySyncWithCredentials): Promise<TSecretMap> { async getSecrets(secretSync: TRailwaySyncWithCredentials): Promise<TSecretMap> {
try { try {
const config = secretSync.destinationConfig; const config = secretSync.destinationConfig;
const { keySchema } = secretSync.syncOptions;
const { environment } = secretSync;
const variables = await RailwayPublicAPI.getVariables(secretSync.connection, { const variables = await RailwayPublicAPI.getVariables(secretSync.connection, {
projectId: config.projectId, projectId: config.projectId,
@@ -26,6 +28,10 @@ export const RailwaySyncFns = {
// eslint-disable-next-line no-continue // eslint-disable-next-line no-continue
if (key.startsWith("RAILWAY_")) 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] = { entries[key] = {
value 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) { async syncSecrets(secretSync: TRailwaySyncWithCredentials, secretMap: TSecretMap) {
const { try {
environment, const {
syncOptions: { disableSecretDeletion, keySchema } syncOptions: { disableSecretDeletion }
} = secretSync; } = secretSync;
const railwaySecrets = await this.getSecrets(secretSync); const railwaySecrets = await this.getSecrets(secretSync);
const config = secretSync.destinationConfig; const config = secretSync.destinationConfig;
for await (const key of Object.keys(secretMap)) { const railwaySecretsMap = Object.fromEntries(
try { Object.entries(railwaySecrets).map(([key, secret]) => [key, secret.value])
const existing = railwaySecrets[key]; );
const secretMapMap = Object.fromEntries(Object.entries(secretMap).map(([key, secret]) => [key, secret.value]));
if (existing === undefined || existing.value !== secretMap[key].value) { const toReplace = disableSecretDeletion ? { ...railwaySecretsMap, ...secretMapMap } : secretMapMap;
await RailwayPublicAPI.upsertVariable(secretSync.connection, {
input: { const upserted = await RailwayPublicAPI.upsertCollection(secretSync.connection, {
projectId: config.projectId, input: {
environmentId: config.environmentId, projectId: config.projectId,
serviceId: config.serviceId || undefined, environmentId: config.environmentId,
name: key, serviceId: config.serviceId || undefined,
value: secretMap[key].value ?? "" skipDeploys: true,
} variables: toReplace,
}); replace: true
} }
} catch (error) { });
if (!upserted)
throw new SecretSyncError({ throw new SecretSyncError({
error, message: "Failed to upsert secrets to Railway"
secretKey: key
}); });
}
}
if (disableSecretDeletion) return; if (!config.serviceId) return;
for await (const key of Object.keys(railwaySecrets)) { const latestDeployment = await RailwayPublicAPI.getDeployments(secretSync.connection, {
try { input: {
// eslint-disable-next-line no-continue serviceId: config.serviceId,
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; environmentId: config.environmentId
},
first: 1
});
if (!secretMap[key]) { const latestDeploymentId = latestDeployment?.deployments.edges[0].node.id;
await RailwayPublicAPI.deleteVariable(secretSync.connection, {
input: { if (!latestDeploymentId)
projectId: config.projectId, throw new SecretSyncError({
environmentId: config.environmentId, message: "Failed to get latest deployment from Railway"
serviceId: config.serviceId || undefined, });
name: key
} await RailwayPublicAPI.redeployDeployment(secretSync.connection, {
}); input: {
deploymentId: latestDeploymentId
} }
} catch (error) { });
throw new SecretSyncError({ } catch (error) {
error, if (error instanceof SecretSyncError) throw error;
secretKey: key
}); 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 existing = await this.getSecrets(secretSync);
const config = secretSync.destinationConfig; const config = secretSync.destinationConfig;
for await (const secret of Object.keys(existing)) { // Create a new variables object excluding secrets that exist in secretMap
try { const remainingVariables = Object.fromEntries(
if (secret in secretMap) { Object.entries(existing)
await RailwayPublicAPI.deleteVariable(secretSync.connection, { .filter(([key]) => !(key in secretMap))
input: { .map(([key, secret]) => [key, secret.value])
projectId: config.projectId, );
environmentId: config.environmentId,
serviceId: config.serviceId || undefined, try {
name: secret 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({ throw new SecretSyncError({
error, message: "Failed to remove secrets from Railway"
secretKey: secret
}); });
} }
} catch (error) {
if (error instanceof SecretSyncError) throw error;
throw new SecretSyncError({
error,
message: "Failed to remove secrets from Railway"
});
} }
} }
}; };