Infisical Version Upgrade Tool

This commit is contained in:
Carlos Monastyrski
2025-09-15 12:15:59 -03:00
parent c24b222ff0
commit 8578265b0f
21 changed files with 1577 additions and 9 deletions
+2
View File
@@ -114,6 +114,7 @@ import { TSlackServiceFactory } from "@app/services/slack/slack-service";
import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service";
import { TTotpServiceFactory } from "@app/services/totp/totp-service";
import { TUpgradePathService } from "@app/services/upgrade-path/upgrade-path-service";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TUserServiceFactory } from "@app/services/user/user-service";
import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
@@ -312,6 +313,7 @@ declare module "fastify" {
identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
notification: TNotificationServiceFactory;
offlineUsageReport: TOfflineUsageReportServiceFactory;
upgradePath: TUpgradePathService;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer
+5 -1
View File
@@ -309,6 +309,7 @@ import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-
import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service";
import { totpConfigDALFactory } from "@app/services/totp/totp-config-dal";
import { totpServiceFactory } from "@app/services/totp/totp-service";
import { upgradePathServiceFactory } from "@app/services/upgrade-path/upgrade-path-service";
import { userDALFactory } from "@app/services/user/user-dal";
import { userServiceFactory } from "@app/services/user/user-service";
import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
@@ -759,6 +760,8 @@ export const registerRoutes = async (
userAliasDAL
});
const upgradePathService = upgradePathServiceFactory({ keyStore });
const totpService = totpServiceFactory({
totpConfigDAL,
userDAL,
@@ -2174,7 +2177,8 @@ export const registerRoutes = async (
reminder: reminderService,
bus: eventBusService,
sse: sseService,
notification: notificationService
notification: notificationService,
upgradePath: upgradePathService
});
const cronJobs: CronJob[] = [];
+2
View File
@@ -51,6 +51,7 @@ import { registerSecretRequestsRouter } from "./secret-requests-router";
import { registerSecretSharingRouter } from "./secret-sharing-router";
import { registerSecretTagRouter } from "./secret-tag-router";
import { registerSlackRouter } from "./slack-router";
import { registerUpgradePathRouter } from "./upgrade-path-router";
import { registerSsoRouter } from "./sso-router";
import { registerUserActionRouter } from "./user-action-router";
import { registerUserEngagementRouter } from "./user-engagement-router";
@@ -188,4 +189,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
);
await server.register(registerEventRouter, { prefix: "/events" });
await server.register(registerUpgradePathRouter, { prefix: "/upgrade-path" });
};
@@ -0,0 +1,143 @@
import RE2 from "re2";
import { z } from "zod";
import { BadRequestError } from "@app/lib/errors";
import { publicEndpointLimit } from "@app/server/config/rateLimiter";
const versionSchema = z
.string()
.min(1)
.max(50)
.regex(new RE2(/^[a-zA-Z0-9._/-]+$/), "Invalid version format");
const booleanSchema = z.boolean().default(false);
const queryBooleanSchema = z
.union([z.boolean(), z.string()])
.transform((val) => {
if (typeof val === "string") {
return val === "true" || val === "1";
}
return val;
})
.default(false);
export const registerUpgradePathRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/versions",
config: {
rateLimit: publicEndpointLimit
},
schema: {
querystring: z.object({
includePrerelease: queryBooleanSchema
}),
response: {
200: z.object({
versions: z.array(
z.object({
tagName: z.string(),
name: z.string(),
publishedAt: z.string(),
prerelease: z.boolean(),
draft: z.boolean()
})
)
})
}
},
handler: async (req) => {
try {
const { includePrerelease } = req.query;
const versions = await req.server.services.upgradePath.getGitHubReleases(includePrerelease);
return {
versions
};
} catch (error) {
req.log.error(error, "Failed to fetch versions");
if (error instanceof z.ZodError) {
throw new BadRequestError({ message: "Invalid query parameters" });
}
throw new BadRequestError({ message: "Failed to fetch GitHub releases" });
}
}
});
server.route({
method: "POST",
url: "/calculate",
config: {
rateLimit: publicEndpointLimit
},
schema: {
body: z.object({
fromVersion: versionSchema,
toVersion: versionSchema,
includePrerelease: booleanSchema
}),
response: {
200: z.object({
path: z.array(
z.object({
version: z.string(),
name: z.string(),
publishedAt: z.string(),
prerelease: z.boolean()
})
),
breakingChanges: z.array(
z.object({
version: z.string(),
changes: z.array(
z.object({
title: z.string(),
description: z.string(),
action: z.string()
})
)
})
),
features: z.array(
z.object({
version: z.string(),
name: z.string(),
body: z.string(),
publishedAt: z.string()
})
),
hasDbMigration: z.boolean(),
config: z.record(z.unknown())
})
}
},
handler: async (req) => {
try {
const { fromVersion, toVersion, includePrerelease } = req.body;
req.log.info({ fromVersion, toVersion, includePrerelease }, "Calculating upgrade path");
const result = await req.server.services.upgradePath.calculateUpgradePath(
fromVersion,
toVersion,
includePrerelease
);
req.log.info(
{ pathLength: result.path.length, hasBreaking: result.breakingChanges.length > 0 },
"Upgrade path calculated"
);
return result;
} catch (error) {
req.log.error(error, "Failed to calculate upgrade path");
if (error instanceof z.ZodError) {
throw new BadRequestError({ message: `Invalid input: ${error.errors.map((e) => e.message).join(", ")}` });
}
if (error instanceof Error) {
throw new BadRequestError({ message: error.message });
}
throw new BadRequestError({ message: "Failed to calculate upgrade path" });
}
}
});
};
@@ -0,0 +1,187 @@
/* eslint-disable no-await-in-loop */
import RE2 from "re2";
import { FormattedRelease, GitHubApiError, GitHubRelease } from "./types";
interface GitHubClientConfig {
token?: string;
timeout: number;
maxRetries: number;
retryDelay: number;
maxPagesPerRequest: number;
perPage: number;
}
interface RateLimitInfo {
remaining: number;
reset: Date;
used: number;
limit: number;
}
const getDefaultConfig = (): GitHubClientConfig => ({
token: process.env.GITHUB_TOKEN,
timeout: 30000,
maxRetries: 3,
retryDelay: 1000,
maxPagesPerRequest: 10,
perPage: 100
});
const getHeaders = (token?: string): Record<string, string> => {
const headers: Record<string, string> = {
Accept: "application/vnd.github.v3+json",
"User-Agent": "Infisical-Upgrade-Path-Tool/1.0",
"X-GitHub-Api-Version": "2022-11-28"
};
if (token) {
headers.Authorization = `token ${token}`;
}
return headers;
};
const delay = (ms: number): Promise<void> => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
const isMainInfisicalRelease = (tagName: string): boolean => {
if (
tagName.startsWith("infisical-cli/") ||
tagName.startsWith("infisical-k8-operator/") ||
tagName.startsWith("infisical-k8s-operator/")
) {
return false;
}
return tagName.startsWith("v") || tagName.startsWith("infisical/v") || new RE2(/^\d+\.\d+\.\d+/).test(tagName);
};
const normalizeVersion = (tagName: string): string => {
const versionMatch = tagName.match(new RE2(/(\d+\.\d+\.\d+(?:\.\d+)?)/));
if (versionMatch) {
return `v${versionMatch[1]}`;
}
if (tagName.startsWith("infisical/")) {
const withoutPrefix = tagName.replace(new RE2(/^infisical\//), "");
return withoutPrefix.replace(new RE2(/-[a-zA-Z]+$/), "");
}
return tagName.replace(new RE2(/-[a-zA-Z]+$/), "");
};
const makeRequest = async <T>(
url: string,
config: GitHubClientConfig,
retryCount = 0
): Promise<{ data: T; rateLimit: RateLimitInfo }> => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(url, {
headers: getHeaders(config.token),
signal: controller.signal
});
clearTimeout(timeout);
const rateLimit: RateLimitInfo = {
remaining: parseInt(response.headers.get("X-RateLimit-Remaining") || "0", 10),
reset: new Date(parseInt(response.headers.get("X-RateLimit-Reset") || "0", 10) * 1000),
used: parseInt(response.headers.get("X-RateLimit-Used") || "0", 10),
limit: parseInt(response.headers.get("X-RateLimit-Limit") || "5000", 10)
};
if (!response.ok) {
const error: GitHubApiError = new Error(`GitHub API error: ${response.status}`);
error.status = response.status;
error.headers = response.headers;
if (response.status === 403) {
const resetTime = rateLimit.reset.toISOString();
error.message = `GitHub API rate limit exceeded. Remaining: ${rateLimit.remaining}, Reset at: ${resetTime}. ${
!config.token ? "Consider setting GITHUB_TOKEN environment variable." : ""
}`;
}
if (retryCount < config.maxRetries && (response.status >= 500 || response.status === 403)) {
await delay(config.retryDelay * 2 ** retryCount);
return await makeRequest<T>(url, config, retryCount + 1);
}
throw error;
}
const data = (await response.json()) as T;
return { data, rateLimit };
} catch (error) {
clearTimeout(timeout);
if (error instanceof Error && error.name === "AbortError") {
throw new Error(`Request timeout after ${config.timeout}ms`);
}
if (retryCount < config.maxRetries && !(error as GitHubApiError).status) {
await delay(config.retryDelay * 2 ** retryCount);
return await makeRequest<T>(url, config, retryCount + 1);
}
throw error;
}
};
export const fetchReleases = async (includePrerelease = false): Promise<FormattedRelease[]> => {
const config = getDefaultConfig();
const allReleases: GitHubRelease[] = [];
let page = 1;
let hasMorePages = true;
const maxConcurrentRequests = Math.min(3, config.maxPagesPerRequest);
while (hasMorePages && page <= config.maxPagesPerRequest) {
const requests: Promise<{ data: GitHubRelease[]; rateLimit: RateLimitInfo }>[] = [];
for (let i = 0; i < maxConcurrentRequests && page <= config.maxPagesPerRequest; i += 1, page += 1) {
const url = `https://api.github.com/repos/Infisical/infisical/releases?page=${page}&per_page=${config.perPage}`;
requests.push(makeRequest<GitHubRelease[]>(url, config));
}
const results = await Promise.allSettled(requests);
let hasData = false;
for (const result of results) {
if (result.status === "fulfilled") {
const { data } = result.value;
if (data.length > 0) {
allReleases.push(...data);
hasData = true;
}
}
}
if (!hasData || results.every((r) => r.status === "fulfilled" && r.value.data.length < config.perPage)) {
hasMorePages = false;
}
}
const formattedReleases = allReleases
.filter((release) => !release.draft)
.filter((release) => isMainInfisicalRelease(release.tag_name))
.map(
(release): FormattedRelease => ({
tagName: release.tag_name,
normalizedTagName: normalizeVersion(release.tag_name),
name: release.name,
body: release.body,
publishedAt: release.published_at,
prerelease: release.prerelease,
draft: release.draft
})
)
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
return formattedReleases.filter((release) => includePrerelease || !release.prerelease);
};
@@ -0,0 +1,2 @@
export type { TUpgradePathService, TUpgradePathServiceFactory } from "./upgrade-path-service";
export { upgradePathServiceFactory } from "./upgrade-path-service";
@@ -0,0 +1,66 @@
export interface GitHubRelease {
tag_name: string;
name: string;
body: string;
published_at: string;
prerelease: boolean;
draft: boolean;
}
export interface FormattedRelease {
tagName: string;
normalizedTagName: string;
name: string;
body: string;
publishedAt: string;
prerelease: boolean;
draft: boolean;
}
export interface BreakingChange {
title: string;
description: string;
action: string;
}
export interface VersionConfig {
breaking_changes?: BreakingChange[];
db_schema_changes?: string;
notes?: string;
}
export interface UpgradePathConfig {
versions?: Record<string, VersionConfig>;
}
export interface UpgradePathResult {
path: Array<{
version: string;
name: string;
publishedAt: string;
prerelease: boolean;
}>;
breakingChanges: Array<{
version: string;
changes: BreakingChange[];
}>;
features: Array<{
version: string;
name: string;
body: string;
publishedAt: string;
}>;
hasDbMigration: boolean;
config: Record<string, unknown>;
}
export interface GitHubApiError extends Error {
status?: number;
headers?: Headers;
}
export interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
@@ -0,0 +1,270 @@
import { readFile } from "fs/promises";
import * as yaml from "js-yaml";
import * as path from "path";
import RE2 from "re2";
import { z } from "zod";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { fetchReleases } from "./github-client";
import { BreakingChange, FormattedRelease, UpgradePathConfig, UpgradePathResult, VersionConfig } from "./types";
export type TUpgradePathServiceFactory = {
keyStore: TKeyStoreFactory;
};
export type TUpgradePathService = ReturnType<typeof upgradePathServiceFactory>;
const versionSchema = z
.string()
.min(1)
.max(50)
.regex(new RE2(/^[a-zA-Z0-9._/-]+$/), "Invalid version format");
const booleanSchema = z.boolean().default(false);
interface CalculateUpgradePathParams {
fromVersion: string;
toVersion: string;
includePrerelease?: boolean;
}
export const upgradePathServiceFactory = ({ keyStore }: TUpgradePathServiceFactory) => {
const getGitHubReleases = async (includePrerelease = false): Promise<FormattedRelease[]> => {
const cacheKey = `upgrade-path:releases:${includePrerelease}`;
try {
const cached = await keyStore.getItem(cacheKey);
if (cached) return JSON.parse(cached) as FormattedRelease[];
} catch (error) {
// Cache miss, continue to fetch from source
}
try {
const releases = await fetchReleases(booleanSchema.parse(includePrerelease));
const filteredReleases = releases.filter((v) => !v.tagName.includes("nightly"));
await keyStore.setItemWithExpiry(cacheKey, 24 * 60 * 60, JSON.stringify(filteredReleases));
return filteredReleases;
} catch (error) {
throw new Error(`GitHub releases unavailable: ${error instanceof Error ? error.message : "Unknown error"}`);
}
};
const getUpgradePathConfig = async (): Promise<Record<string, VersionConfig>> => {
const cacheKey = "upgrade-path:config";
try {
const cached = await keyStore.getItem(cacheKey);
if (cached) return JSON.parse(cached) as Record<string, VersionConfig>;
} catch (error) {
// Cache miss, continue to fetch from source
}
try {
const yamlPath = path.join(__dirname, "..", "..", "..", "upgrade-path.yaml");
const yamlContent = await readFile(yamlPath, "utf8");
if (yamlContent.length > 1024 * 1024) {
throw new Error("Config file too large");
}
const config = yaml.load(yamlContent) as UpgradePathConfig;
const versionConfig = config?.versions || {};
await keyStore.setItemWithExpiry(cacheKey, 24 * 60 * 60, JSON.stringify(versionConfig));
return versionConfig;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
const empty = {};
await keyStore.setItemWithExpiry(cacheKey, 24 * 60 * 60, JSON.stringify(empty));
return empty;
}
throw new Error(`Config load failed: ${error instanceof Error ? error.message : "Unknown error"}`);
}
};
const normalizeVersion = (version: string): string => {
// Extract just the X.X.X.X part from any version format
const versionMatch = version.match(/(\d+\.\d+\.\d+(?:\.\d+)?)/);
if (versionMatch) {
return versionMatch[1];
}
// Handle legacy version formats
if (version.startsWith("infisical/")) {
return version.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), "");
}
return version.replace(new RE2(/^v/), "").replace(new RE2(/-[a-zA-Z]+$/), "");
};
const findBreakingChangesForVersion = (
version: FormattedRelease,
config: Record<string, VersionConfig>
): BreakingChange[] => {
// Check multiple key variations for breaking changes configuration
const versionNumber = normalizeVersion(version.tagName);
const possibleKeys = [
version.tagName,
version.normalizedTagName,
versionNumber,
`v${versionNumber}`,
version.tagName.replace(new RE2(/^infisical\//), ""),
version.tagName.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), "")
];
for (const key of possibleKeys) {
const versionConfig = config[key];
if (versionConfig?.breaking_changes?.length) {
return versionConfig.breaking_changes;
}
}
return [];
};
const validateParams = (params: CalculateUpgradePathParams) => {
const { fromVersion, toVersion, includePrerelease = false } = params;
versionSchema.parse(fromVersion);
versionSchema.parse(toVersion);
if (fromVersion === toVersion) {
throw new Error("Versions cannot be identical");
}
if (fromVersion.includes("nightly") || toVersion.includes("nightly")) {
throw new Error("Nightly releases are not supported for upgrade path calculation");
}
return { fromVersion, toVersion, includePrerelease: booleanSchema.parse(includePrerelease) };
};
const calculateUpgradePath = async (params: CalculateUpgradePathParams): Promise<UpgradePathResult> => {
const { fromVersion, toVersion, includePrerelease } = validateParams(params);
const cacheKey = `upgrade-path:${fromVersion}:${toVersion}:${includePrerelease}`;
try {
const cached = await keyStore.getItem(cacheKey);
if (cached) return JSON.parse(cached) as UpgradePathResult;
} catch (error) {
// Cache miss, continue to fetch from source
}
const [releases, config] = await Promise.all([getGitHubReleases(includePrerelease), getUpgradePathConfig()]);
const cleanFrom = normalizeVersion(fromVersion);
const cleanTo = normalizeVersion(toVersion);
const fromIdx = releases.findIndex((r) => normalizeVersion(r.normalizedTagName) === cleanFrom);
const toIdx = releases.findIndex((r) => normalizeVersion(r.normalizedTagName) === cleanTo);
if (fromIdx === -1) throw new Error(`Version ${fromVersion} not found`);
if (toIdx === -1) throw new Error(`Version ${toVersion} not found`);
if (fromIdx <= toIdx) throw new Error("Invalid version order");
const upgradePath = releases.slice(toIdx, fromIdx + 1).reverse();
const [first, last] = [upgradePath[0], upgradePath[upgradePath.length - 1]];
// Find all versions with breaking changes in the upgrade path
const withBreakingChanges = upgradePath.filter((version) => {
const breakingChanges = findBreakingChangesForVersion(version, config);
return breakingChanges.length > 0;
});
// Build the filtered path with breaking change versions
const filteredPath = [first];
// Get intermediate versions with breaking changes (excluding first and last)
const allIntermediateWithBreaking = withBreakingChanges
.filter((v) => v !== first && v !== last)
.sort((a, b) => new Date(a.publishedAt).getTime() - new Date(b.publishedAt).getTime());
// Limit intermediate steps to avoid overly complex upgrade paths
const maxIntermediateSteps = 8;
const intermediate =
allIntermediateWithBreaking.length > maxIntermediateSteps
? allIntermediateWithBreaking.slice(-maxIntermediateSteps)
: allIntermediateWithBreaking;
filteredPath.push(...intermediate);
if (last !== first) filteredPath.push(last);
const breakingChanges: Array<{ version: string; changes: BreakingChange[] }> = [];
const features: Array<{ version: string; name: string; body: string; publishedAt: string }> = [];
let hasDbMigration = false;
// Process versions in upgrade path, excluding starting version
for (let i = 1; i < upgradePath.length; i += 1) {
const version = upgradePath[i];
const isFromVersion = normalizeVersion(version.normalizedTagName) === cleanFrom;
// Process breaking changes for intermediate versions only
if (!isFromVersion) {
const versionBreakingChanges = findBreakingChangesForVersion(version, config);
if (versionBreakingChanges.length > 0) {
breakingChanges.push({
version: version.tagName,
changes: versionBreakingChanges
});
}
}
// Process database migrations for intermediate versions only
if (!isFromVersion) {
const versionNumber = normalizeVersion(version.tagName);
const possibleKeys = [
version.tagName,
version.normalizedTagName,
versionNumber,
`v${versionNumber}`,
version.tagName.replace(new RE2(/^infisical\//), ""),
version.tagName.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), "")
];
for (const key of possibleKeys) {
const versionConfig = config[key];
if (
versionConfig?.db_schema_changes &&
typeof versionConfig.db_schema_changes === "string" &&
versionConfig.db_schema_changes.trim()
) {
hasDbMigration = true;
break;
}
}
}
// Collect release notes and features
if (version.body) {
features.push({
version: version.tagName,
name: version.name,
body: version.body,
publishedAt: version.publishedAt
});
}
}
const result: UpgradePathResult = {
path: filteredPath.map((r) => ({
version: r.tagName,
name: r.name,
publishedAt: r.publishedAt,
prerelease: r.prerelease
})),
breakingChanges,
features,
hasDbMigration,
config
};
await keyStore.setItemWithExpiry(cacheKey, 60 * 60, JSON.stringify(result));
return result;
};
return {
getGitHubReleases,
getUpgradePathConfig,
calculateUpgradePath: (fromVersion: string, toVersion: string, includePrerelease = false) =>
calculateUpgradePath({ fromVersion, toVersion, includePrerelease })
};
};