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

View File

@@ -83,6 +83,7 @@
"ioredis": "^5.3.2",
"isomorphic-dompurify": "^2.22.0",
"jmespath": "^0.16.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"jsrp": "^0.2.4",
"jwks-rsa": "^3.1.0",
@@ -143,6 +144,7 @@
"@smithy/types": "^4.3.1",
"@types/bcrypt": "^5.0.2",
"@types/jmespath": "^0.15.2",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.5",
"@types/jsrp": "^0.2.6",
"@types/libsodium-wrappers": "^0.7.13",
@@ -13160,6 +13162,13 @@
"integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==",
"dev": true
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -20452,6 +20461,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},

View File

@@ -87,6 +87,7 @@
"@smithy/types": "^4.3.1",
"@types/bcrypt": "^5.0.2",
"@types/jmespath": "^0.15.2",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.5",
"@types/jsrp": "^0.2.6",
"@types/libsodium-wrappers": "^0.7.13",
@@ -203,6 +204,7 @@
"ioredis": "^5.3.2",
"isomorphic-dompurify": "^2.22.0",
"jmespath": "^0.16.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"jsrp": "^0.2.4",
"jwks-rsa": "^3.1.0",

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

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[] = [];

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" });
};

View File

@@ -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" });
}
}
});
};

View File

@@ -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);
};

View File

@@ -0,0 +1,2 @@
export type { TUpgradePathService, TUpgradePathServiceFactory } from "./upgrade-path-service";
export { upgradePathServiceFactory } from "./upgrade-path-service";

View File

@@ -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;
}

View File

@@ -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 })
};
};

78
backend/upgrade-path.yaml Normal file
View File

@@ -0,0 +1,78 @@
versions:
"infisical/v0.130.0-postgres":
breaking_changes:
- title: "API Key Authentication Deprecation"
description: "Legacy API key authentication method has been deprecated and will be removed in future versions"
action: "Migrate all integrations to use Machine Identity authentication with JWT tokens. Update your CI/CD pipelines and automation scripts"
impact: "high"
- title: "Environment Variable Structure Changes"
description: "Environment variable naming convention has changed from INFISICAL_ prefix to INF_ for better compatibility"
action: "Update all environment variable references in your deployment configurations, Docker files, and Kubernetes manifests"
impact: "medium"
- title: "RBAC Permission Model Updates"
description: "Role-based access control has been restructured with new permission granularity affecting existing role assignments"
action: "Review and reassign user roles and permissions. Test access to sensitive resources before production deployment"
impact: "high"
db_schema_changes: "Extensive database schema restructuring for authentication and RBAC systems. Requires table reorganization and reindexing which may cause extended downtime."
notes: "Critical authentication and permission system overhaul. Database migration is extensive and may cause extended downtime. Plan maintenance window accordingly and ensure health checks are adjusted for longer migration time."
"infisical/v0.131.0-postgres":
breaking_changes:
- title: "Webhook Payload Format Changes"
description: "Webhook event payloads now use a new standardized format that is incompatible with previous versions"
action: "Update all webhook consumers to handle the new payload structure. Test webhook integrations with Slack, Discord, and custom endpoints"
impact: "high"
- title: "Secret Versioning API Breaking Changes"
description: "Secret versioning endpoints have changed from /api/v2/secrets to /api/v3/secrets with modified request/response schemas"
action: "Update all API clients and SDKs to use the new v3 endpoints. Modify any custom integrations or scripts"
impact: "medium"
- title: "CLI Authentication Method Changes"
description: "Infisical CLI now requires explicit authentication method specification and no longer supports legacy token formats"
action: "Update CLI installation in all environments. Re-authenticate CLI instances using 'infisical login' command"
impact: "medium"
db_schema_changes: "Major database schema changes for API restructuring. Includes reindexing large tables and webhook payload modifications which significantly impact performance during migration."
notes: "Major API restructure with extensive database changes. Migration involves reindexing large tables and may significantly impact instance performance. Health checks will likely fail during migration. Schedule during lowest traffic period."
"v0.147.0":
breaking_changes:
- title: "Docker Tag Format Changes"
description: "Docker tags no longer contain the -postgres suffix. This affects deployment configurations"
action: "Update all deployment scripts, Docker Compose files, and Kubernetes manifests to use new tag format without -postgres suffix"
impact: "high"
- title: "Release Channel System Introduction"
description: "Formal release channels introduced with breaking changes to update mechanisms"
action: "Review release channel documentation and update your deployment strategy to align with new release channels"
impact: "medium"
db_schema_changes: "Database schema updates for release channel system implementation. Adds new tables for channel tracking and version management."
notes: "Docker tag format change requires deployment configuration updates. Review release channel documentation."
"0.147.0":
breaking_changes:
- title: "Docker Tag Format Changes"
description: "Docker tags no longer contain the -postgres suffix. This affects deployment configurations"
action: "Update all deployment scripts, Docker Compose files, and Kubernetes manifests to use new tag format without -postgres suffix"
impact: "high"
- title: "Release Channel System Introduction"
description: "Formal release channels introduced with breaking changes to update mechanisms"
action: "Review release channel documentation and update your deployment strategy to align with new release channels"
impact: "medium"
db_schema_changes: "Database schema updates for release channel system implementation. Adds new tables for channel tracking and version management."
notes: "Docker tag format change requires deployment configuration updates. Review release channel documentation."
"v0.148.0":
breaking_changes:
- title: "Secret Overview Page Removal"
description: "Secret overview page has been removed and replaced with revamped secret dashboard"
action: "Update any bookmarks, documentation, or automation that references the old overview page URLs"
impact: "medium"
- title: "Universal Auth Login Lockout"
description: "New lockout mechanism for Universal Auth that may affect existing authentication flows"
action: "Review and test authentication flows. Update monitoring and alerting for lockout scenarios"
impact: "high"
- title: "SAML Duplicate Account Handling Changes"
description: "Changes to how duplicate SAML accounts are handled during first-time sign-in"
action: "Test SAML authentication flows and ensure proper account linking procedures are in place"
impact: "medium"
db_schema_changes: "Database schema changes for authentication system improvements and UI restructuring. Includes new lockout mechanism tables and SAML account handling modifications."
notes: "UI changes and authentication flow updates. Test all authentication methods thoroughly."

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@@ -41,9 +41,16 @@ Now, migrations run automatically during boot-up. This improvement streamlines t
- Ensure you have a complete backup of your Postgres database.
- Verify that your backup is current and accessible.
2. **Select the Upgrade Version:**
- Visit the [Infisical releases page](https://github.com/Infisical/infisical/releases) for a list of available versions.
- Look for releases with the prefix `infisical/` as there are other releases that are not related to the Infisical instance.
2. **Plan Your Upgrade Path:**
- Use our [Upgrade Path Tool](https://app.infisical.com/upgrade-path) to analyze your upgrade path between your current version and target version.
- The tool will show you:
- **Breaking changes** that require action before upgrading
- **Database migrations** that may require additional settings or precautions
- **Step-by-step upgrade path** with intermediate versions if needed
- Review any breaking changes and plan necessary configuration updates before proceeding.
- Visit the [Infisical releases page](https://github.com/Infisical/infisical/releases) for a complete list of available versions.
![Upgrade Path Tool showing breaking changes and migration information](/images/self-hosting/helper/upgrade-path-tool.png)
3. **Start the Upgrade Process:**
- Launch the new version of Infisical. During startup, the application will automatically compare the current database schema with the updated schema in the code.

View File

@@ -0,0 +1,7 @@
export type {
CalculateUpgradePathParams,
GetUpgradePathVersionsParams,
GitHubVersion,
UpgradePathResult
} from "./queries";
export { useCalculateUpgradePath, useGetUpgradePathVersions } from "./queries";

View File

@@ -0,0 +1,83 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
export interface GitHubVersion {
tagName: string;
name: string;
publishedAt: string;
prerelease: boolean;
draft: boolean;
}
export interface UpgradePathResult {
path: Array<{
version: string;
name: string;
publishedAt: string;
prerelease: boolean;
}>;
breakingChanges: Array<{
version: string;
changes: Array<{
title: string;
description: string;
action: string;
}>;
}>;
features: Array<{
version: string;
name: string;
body: string;
publishedAt: string;
}>;
hasDbMigration: boolean;
config: Record<string, unknown>;
}
export interface GetUpgradePathVersionsParams {
includePrerelease?: boolean;
}
export interface CalculateUpgradePathParams {
fromVersion: string;
toVersion: string;
includePrerelease?: boolean;
}
const upgradePathKeys = {
all: ["upgrade-path"] as const,
versions: (params: GetUpgradePathVersionsParams) =>
[...upgradePathKeys.all, "versions", params] as const,
calculate: (params: CalculateUpgradePathParams) =>
[...upgradePathKeys.all, "calculate", params] as const
};
export const useGetUpgradePathVersions = (
params: GetUpgradePathVersionsParams,
options?: Omit<UseQueryOptions<{ versions: GitHubVersion[] }>, "queryKey" | "queryFn">
) => {
return useQuery({
queryKey: upgradePathKeys.versions(params),
queryFn: async () => {
const { data } = await apiRequest.get<{ versions: GitHubVersion[] }>(
"/api/v1/upgrade-path/versions",
{
params
}
);
return data;
},
...options
});
};
export const useCalculateUpgradePath = () => {
return async (params: CalculateUpgradePathParams): Promise<UpgradePathResult> => {
const { data } = await apiRequest.post<UpgradePathResult>(
"/api/v1/upgrade-path/calculate",
params
);
return data;
};
};

View File

@@ -0,0 +1,671 @@
/* eslint-disable no-nested-ternary */
import { useEffect, useMemo, useRef, useState } from "react";
import { Helmet } from "react-helmet";
import { faExternalLink, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMutation } from "@tanstack/react-query";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import {
useCalculateUpgradePath,
useGetUpgradePathVersions
} from "../../../hooks/api/upgradePath/queries";
interface UpgradeResult {
path: Array<{
version: string;
name: string;
publishedAt: string;
prerelease: boolean;
}>;
breakingChanges: Array<{
version: string;
changes: Array<{
title: string;
description: string;
action: string;
}>;
}>;
features: Array<{
version: string;
name: string;
body: string;
publishedAt: string;
}>;
hasDbMigration: boolean;
config: Record<string, unknown>;
}
export const UpgradePathPage = () => {
const [fromVersion, setFromVersion] = useState("");
const [toVersion, setToVersion] = useState("");
const [fromSearch, setFromSearch] = useState("");
const [toSearch, setToSearch] = useState("");
const [showFromDropdown, setShowFromDropdown] = useState(false);
const [showToDropdown, setShowToDropdown] = useState(false);
const [upgradeResult, setUpgradeResult] = useState<UpgradeResult | null>(null);
const [debouncedFromSearch, setDebouncedFromSearch] = useState("");
const [debouncedToSearch, setDebouncedToSearch] = useState("");
const fromDropdownRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null);
const fromSearchTimeoutRef = useRef<NodeJS.Timeout>();
const toSearchTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
if (fromSearchTimeoutRef.current) {
clearTimeout(fromSearchTimeoutRef.current);
}
fromSearchTimeoutRef.current = setTimeout(() => {
setDebouncedFromSearch(fromSearch);
}, 300);
return () => {
if (fromSearchTimeoutRef.current) {
clearTimeout(fromSearchTimeoutRef.current);
}
};
}, [fromSearch]);
useEffect(() => {
if (toSearchTimeoutRef.current) {
clearTimeout(toSearchTimeoutRef.current);
}
toSearchTimeoutRef.current = setTimeout(() => {
setDebouncedToSearch(toSearch);
}, 300);
return () => {
if (toSearchTimeoutRef.current) {
clearTimeout(toSearchTimeoutRef.current);
}
};
}, [toSearch]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (fromDropdownRef.current && !fromDropdownRef.current.contains(event.target as Node)) {
setShowFromDropdown(false);
}
if (toDropdownRef.current && !toDropdownRef.current.contains(event.target as Node)) {
setShowToDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const {
data: versions,
isLoading: versionsLoading,
isFetching: versionsFetching
} = useGetUpgradePathVersions(
{
includePrerelease: false
},
{
enabled: true,
staleTime: 24 * 60 * 60 * 1000,
refetchOnWindowFocus: false
}
);
const calculateMutation = useMutation({
mutationFn: useCalculateUpgradePath(),
onSuccess: (data) => {
setUpgradeResult(data);
},
onError: (error: unknown) => {
createNotification({
text: (error as any)?.response?.data?.message || "Failed to calculate upgrade path",
type: "error"
});
}
});
const filteredFromVersions = useMemo(() => {
if (!versions?.versions) return [];
const filtered = versions.versions
.filter((version) => !version.tagName.includes("nightly"))
.filter((version) => {
if (!debouncedFromSearch) return true;
const searchTerm = debouncedFromSearch.toLowerCase();
return version.tagName.toLowerCase().includes(searchTerm);
});
return filtered.slice(0, 25);
}, [versions?.versions, debouncedFromSearch]);
const filteredToVersions = useMemo(() => {
if (!versions?.versions) return [];
const filtered = versions.versions
.filter((version) => !version.tagName.includes("nightly"))
.filter((version) => {
if (!debouncedToSearch) return true;
const searchTerm = debouncedToSearch.toLowerCase();
return version.tagName.toLowerCase().includes(searchTerm);
});
return filtered.slice(0, 25);
}, [versions?.versions, debouncedToSearch]);
const handleFromVersionSelect = (version: string) => {
setFromVersion(version);
setFromSearch(version);
setShowFromDropdown(false);
};
const handleToVersionSelect = (version: string) => {
setToVersion(version);
setToSearch(version);
setShowToDropdown(false);
};
const handleCalculate = () => {
if (!fromVersion || !toVersion) {
createNotification({
text: "Please select both from and to versions",
type: "error"
});
return;
}
if (fromVersion === toVersion) {
createNotification({
text: "From and To versions cannot be the same",
type: "error"
});
return;
}
calculateMutation.mutate({
fromVersion,
toVersion,
includePrerelease: false
});
};
return (
<>
<Helmet>
<title>Infisical Upgrade Path Tool | Infisical</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content="Infisical Upgrade Path Tool" />
<meta
name="og:description"
content="Plan your Infisical upgrade path safely and efficiently."
/>
</Helmet>
<div className="dark h-full">
<div className="flex h-screen flex-col justify-between overflow-auto bg-mineshaft-900 text-bunker-200 dark:[color-scheme:dark]">
<div />
<div className="mx-auto w-full max-w-4xl px-4 py-4 md:px-0">
{/* Header */}
<div className="mb-8 text-center">
<div className="mb-4 flex justify-center pt-8">
<a target="_blank" rel="noopener noreferrer" href="https://infisical.com">
<img
src="/images/gradientLogo.svg"
height={90}
width={120}
alt="Infisical logo"
className="cursor-pointer"
/>
</a>
</div>
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-4xl font-medium text-transparent">
Upgrade your Infisical Version
</h1>
</div>
{/* Calculator Card */}
<div className="mb-8 rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-6">
<h2 className="mb-6 text-xl font-semibold text-bunker-200">Calculate Upgrade Path</h2>
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* From Version Selector */}
<div className="relative" ref={fromDropdownRef}>
<FormControl label="From Version" isRequired>
<div className="relative">
<Input
value={fromSearch}
onChange={(e) => {
setFromSearch(e.target.value);
setFromVersion("");
setShowFromDropdown(true);
}}
onFocus={() => setShowFromDropdown(true)}
placeholder="Search or select version..."
className="border-mineshaft-600 bg-mineshaft-900"
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
isDisabled={versionsLoading || versionsFetching}
/>
{showFromDropdown && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 shadow-lg">
{(() => {
if (versionsLoading || versionsFetching) {
return (
<div className="flex items-center justify-center px-3 py-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="ml-2 text-sm text-bunker-300">
Loading versions...
</span>
</div>
);
}
if (filteredFromVersions.length > 0) {
return filteredFromVersions.slice(0, 8).map((version) => (
<button
key={version.tagName}
type="button"
onClick={() => handleFromVersionSelect(version.tagName)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-bunker-200 hover:bg-mineshaft-700 focus:bg-mineshaft-700 focus:outline-none"
>
<span>{version.tagName}</span>
</button>
));
}
return (
<div className="px-3 py-2 text-sm text-bunker-400">
{debouncedFromSearch
? `No versions found matching "${debouncedFromSearch}"`
: "No versions available"}
</div>
);
})()}
</div>
)}
</div>
</FormControl>
</div>
{/* To Version Selector */}
<div className="relative" ref={toDropdownRef}>
<FormControl label="To Version" isRequired>
<div className="relative">
<Input
value={toSearch}
onChange={(e) => {
setToSearch(e.target.value);
setToVersion("");
setShowToDropdown(true);
}}
onFocus={() => setShowToDropdown(true)}
placeholder="Search or select version..."
className="border-mineshaft-600 bg-mineshaft-900"
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
isDisabled={versionsLoading || versionsFetching}
/>
{showToDropdown && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 shadow-lg">
{(() => {
if (versionsLoading || versionsFetching) {
return (
<div className="flex items-center justify-center px-3 py-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="ml-2 text-sm text-bunker-300">
Loading versions...
</span>
</div>
);
}
if (filteredToVersions.length > 0) {
return filteredToVersions.slice(0, 8).map((version) => (
<button
key={version.tagName}
type="button"
onClick={() => handleToVersionSelect(version.tagName)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-bunker-200 hover:bg-mineshaft-700 focus:bg-mineshaft-700 focus:outline-none"
>
<span>{version.tagName}</span>
<div className="flex items-center space-x-2">
{versions?.versions?.[0]?.tagName === version.tagName && (
<span className="text-xs text-primary">(Latest)</span>
)}
</div>
</button>
));
}
return (
<div className="px-3 py-2 text-sm text-bunker-400">
{debouncedToSearch
? `No versions found matching "${debouncedToSearch}"`
: "No versions available"}
</div>
);
})()}
</div>
)}
</div>
</FormControl>
</div>
</div>
<Button
onClick={handleCalculate}
isLoading={calculateMutation.isPending}
className="w-full bg-primary font-medium text-black hover:bg-primary/80"
>
{calculateMutation.isPending ? "Calculating..." : "Calculate Upgrade Path"}
</Button>
</div>
</div>
{/* Results Section */}
{upgradeResult && (
<div className="space-y-6">
{/* Action Required Banner */}
{(() => {
const versionsWithBreakingChanges = upgradeResult.breakingChanges
.filter((bc) => bc.changes.length > 0)
.map((bc) => bc.version);
const versionsWithDbMigrations = upgradeResult.path
.filter((step, index) => {
const isStartingVersion = index === 0;
if (isStartingVersion) return false;
const versionConfig = upgradeResult.config as Record<string, any>;
const possibleKeys = [
step.version,
step.version.replace(/^v/, ""),
step.version.replace(/^infisical\/v?/, ""),
step.version.replace(/^infisical\/v?/, "").replace(/-[a-zA-Z]+$/, "")
];
const dbSchemaChanges = possibleKeys
.map((key) => versionConfig?.[key]?.db_schema_changes)
.find((changes) => changes);
return (
dbSchemaChanges &&
(typeof dbSchemaChanges === "string"
? dbSchemaChanges.trim()
: dbSchemaChanges)
);
})
.map((step) => step.version);
const allConflictVersions = [
...new Set([...versionsWithBreakingChanges, ...versionsWithDbMigrations])
];
const hasIssues = allConflictVersions.length > 0;
return (
<div
className={`rounded-lg border p-4 ${
hasIssues
? "border-yellow-500/20 bg-yellow-500/10"
: "border-green-500/20 bg-green-500/10"
}`}
>
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
{hasIssues ? (
<svg
className="h-5 w-5 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="h-5 w-5 text-green-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<div className="flex-1">
<h3
className={`font-medium ${
hasIssues ? "text-yellow-400" : "text-green-400"
}`}
>
{hasIssues ? "Action Required:" : "Ready to Upgrade:"}
</h3>
<p className="mt-1 text-sm text-bunker-300">
{hasIssues
? `Your upgrade path contains conflicts in the following versions: ${allConflictVersions.join(", ")}. Please review and resolve each item before proceeding to the next version.`
: "Your upgrade path is clear with no breaking changes or conflicts. You can proceed with the upgrade."}
</p>
</div>
</div>
</div>
);
})()}
{/* Upgrade Steps */}
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-6">
<div className="mb-6 flex items-center space-x-2">
<svg
className="h-5 w-5 text-bunker-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
<h2 className="text-xl font-semibold text-bunker-200">Upgrade Steps</h2>
</div>
<div className="space-y-6">
{upgradeResult.path.map((step, index) => {
const isFirst = index === 0;
const isLast = index === upgradeResult.path.length - 1;
const versionChanges = upgradeResult.breakingChanges.find((bc) => {
if (bc.version === step.version) return true;
const normalizeVersion = (v: string) => {
return v.replace(/^(infisical\/)?v?/, "").replace(/-[a-zA-Z]+$/, "");
};
const normalizedStep = normalizeVersion(step.version);
const normalizedBC = normalizeVersion(bc.version);
return normalizedStep === normalizedBC;
});
const versionConfig = upgradeResult.config as Record<string, any>;
const possibleKeys = [
step.version,
step.version.replace(/^v/, ""),
step.version.replace(/^infisical\/v?/, ""),
step.version.replace(/^infisical\/v?/, "").replace(/-[a-zA-Z]+$/, "")
];
const dbMigrationDescription = possibleKeys
.map((key) => versionConfig?.[key]?.db_schema_changes)
.find((changes) => changes);
const hasDbMigration =
!isFirst &&
dbMigrationDescription &&
(typeof dbMigrationDescription === "string"
? dbMigrationDescription.trim()
: dbMigrationDescription);
const hasBreakingChanges =
versionChanges && versionChanges.changes.length > 0;
return (
<div key={step.version} className="relative flex">
{/* Timeline Column */}
<div className="mr-4 flex flex-col items-center">
{/* Timeline Circle */}
<div className="z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 border-mineshaft-500 bg-mineshaft-700">
{isFirst || isLast ? (
<div className="h-3 w-3 rounded-full bg-primary" />
) : hasBreakingChanges || hasDbMigration ? (
<svg
className="h-4 w-4 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
) : (
<div className="h-2 w-2 rounded-full bg-bunker-300" />
)}
</div>
{/* Timeline Line */}
{!isLast && <div className="w-px flex-1 bg-mineshaft-500" />}
</div>
{/* Content Column */}
<div className="flex-1 pb-6">
{/* Version Header */}
<div className="flex items-center space-x-3">
<h3 className="font-medium text-bunker-200">{step.version}</h3>
{isFirst && (
<span className="rounded border border-primary/30 bg-primary/20 px-2 py-1 text-xs font-medium text-primary">
Starting Version
</span>
)}
{isLast && (
<span className="rounded border border-primary/30 bg-primary/20 px-2 py-1 text-xs font-medium text-primary">
Target Version
</span>
)}
<a
href={`https://github.com/Infisical/infisical/releases/tag/${step.version}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center space-x-1 text-xs text-primary transition-colors hover:text-primary/80"
>
<FontAwesomeIcon icon={faExternalLink} className="h-3 w-3" />
<span>View Changelog</span>
</a>
</div>
{/* Version Notes */}
{(() => {
if (isFirst) return null;
const notes = possibleKeys
.map((key) => versionConfig?.[key]?.notes)
.find((note) => note);
if (!notes) return null;
return (
<div className="mt-3 rounded border border-bunker-500/20 bg-bunker-700/20 p-3">
<div className="text-sm text-bunker-300">{notes}</div>
</div>
);
})()}
{/* Database Schema Changes */}
{hasDbMigration && (
<div className="mt-3 rounded border border-yellow-500/20 bg-yellow-500/10 p-3">
<div className="mb-1 text-sm font-medium text-yellow-400">
Database Schema Changes Required
</div>
<div className="mb-2 text-xs text-bunker-300">
{typeof dbMigrationDescription === "string"
? dbMigrationDescription
: "This version includes database schema changes that require migrations."}
</div>
<div className="text-xs font-medium text-yellow-400">
Action:{" "}
<span className="font-normal italic">
Make sure to backup your database before proceeding
</span>
</div>
</div>
)}
{/* Breaking Changes */}
{hasBreakingChanges && (
<div className="mt-3 space-y-3">
<div className="flex items-center space-x-2">
<svg
className="h-4 w-4 text-red-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm font-medium text-red-400">
Breaking Changes ({versionChanges.changes.length})
</span>
</div>
{versionChanges.changes.map((change) => (
<div
key={`${step.version}-${change.title}`}
className="rounded border border-red-500/20 bg-red-500/10 p-3"
>
<div className="mb-1 text-sm font-medium text-red-400">
{change.title}
</div>
<div className="mb-2 text-xs text-bunker-300">
{change.description}
</div>
<div className="text-xs font-medium text-red-400">
Action:{" "}
<span className="font-normal italic">{change.action}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
<div className="w-full bg-mineshaft-800 p-2">
<p className="text-center text-sm text-bunker-400">
Made with by{" "}
<a className="text-primary hover:text-primary/80" href="https://infisical.com">
Infisical
</a>
<br />
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
</p>
</div>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { UpgradePathPage } from "./UpgradePathPage";
export const Route = createFileRoute("/upgrade-path")({
component: UpgradePathPage
});

View File

@@ -15,6 +15,7 @@ import { createFileRoute } from '@tanstack/react-router'
import { Route as rootRoute } from './pages/root'
import { Route as middlewaresRestrictLoginSignupImport } from './pages/middlewares/restrict-login-signup'
import { Route as middlewaresAuthenticateImport } from './pages/middlewares/authenticate'
import { Route as publicUpgradePathPageRouteImport } from './pages/public/UpgradePathPage/route'
import { Route as publicShareSecretPageRouteImport } from './pages/public/ShareSecretPage/route'
import { Route as authCliRedirectPageRouteImport } from './pages/auth/CliRedirectPage/route'
import { Route as indexImport } from './pages/index'
@@ -318,6 +319,14 @@ const middlewaresAuthenticateRoute = middlewaresAuthenticateImport.update({
getParentRoute: () => rootRoute,
} as any)
const publicUpgradePathPageRouteRoute = publicUpgradePathPageRouteImport.update(
{
id: '/upgrade-path',
path: '/upgrade-path',
getParentRoute: () => rootRoute,
} as any,
)
const publicShareSecretPageRouteRoute = publicShareSecretPageRouteImport.update(
{
id: '/share-secret',
@@ -2037,6 +2046,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof publicShareSecretPageRouteImport
parentRoute: typeof rootRoute
}
'/upgrade-path': {
id: '/upgrade-path'
path: '/upgrade-path'
fullPath: '/upgrade-path'
preLoaderRoute: typeof publicUpgradePathPageRouteImport
parentRoute: typeof rootRoute
}
'/_authenticate': {
id: '/_authenticate'
path: ''
@@ -4600,6 +4616,7 @@ export interface FileRoutesByFullPath {
'/': typeof indexRoute
'/cli-redirect': typeof authCliRedirectPageRouteRoute
'/share-secret': typeof publicShareSecretPageRouteRoute
'/upgrade-path': typeof publicUpgradePathPageRouteRoute
'': typeof organizationLayoutRouteWithChildren
'/password-setup': typeof authPasswordSetupPageRouteRoute
'/email-not-verified': typeof authEmailNotVerifiedPageRouteRoute
@@ -4821,6 +4838,7 @@ export interface FileRoutesByTo {
'/': typeof indexRoute
'/cli-redirect': typeof authCliRedirectPageRouteRoute
'/share-secret': typeof publicShareSecretPageRouteRoute
'/upgrade-path': typeof publicUpgradePathPageRouteRoute
'': typeof organizationLayoutRouteWithChildren
'/password-setup': typeof authPasswordSetupPageRouteRoute
'/email-not-verified': typeof authEmailNotVerifiedPageRouteRoute
@@ -5029,6 +5047,7 @@ export interface FileRoutesById {
'/': typeof indexRoute
'/cli-redirect': typeof authCliRedirectPageRouteRoute
'/share-secret': typeof publicShareSecretPageRouteRoute
'/upgrade-path': typeof publicUpgradePathPageRouteRoute
'/_authenticate': typeof middlewaresAuthenticateRouteWithChildren
'/_restrict-login-signup': typeof middlewaresRestrictLoginSignupRouteWithChildren
'/_authenticate/password-setup': typeof authPasswordSetupPageRouteRoute
@@ -5262,6 +5281,7 @@ export interface FileRouteTypes {
| '/'
| '/cli-redirect'
| '/share-secret'
| '/upgrade-path'
| ''
| '/password-setup'
| '/email-not-verified'
@@ -5482,6 +5502,7 @@ export interface FileRouteTypes {
| '/'
| '/cli-redirect'
| '/share-secret'
| '/upgrade-path'
| ''
| '/password-setup'
| '/email-not-verified'
@@ -5688,6 +5709,7 @@ export interface FileRouteTypes {
| '/'
| '/cli-redirect'
| '/share-secret'
| '/upgrade-path'
| '/_authenticate'
| '/_restrict-login-signup'
| '/_authenticate/password-setup'
@@ -5920,6 +5942,7 @@ export interface RootRouteChildren {
indexRoute: typeof indexRoute
authCliRedirectPageRouteRoute: typeof authCliRedirectPageRouteRoute
publicShareSecretPageRouteRoute: typeof publicShareSecretPageRouteRoute
publicUpgradePathPageRouteRoute: typeof publicUpgradePathPageRouteRoute
middlewaresAuthenticateRoute: typeof middlewaresAuthenticateRouteWithChildren
middlewaresRestrictLoginSignupRoute: typeof middlewaresRestrictLoginSignupRouteWithChildren
publicViewSecretRequestByIDPageRouteRoute: typeof publicViewSecretRequestByIDPageRouteRoute
@@ -5930,6 +5953,7 @@ const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
authCliRedirectPageRouteRoute: authCliRedirectPageRouteRoute,
publicShareSecretPageRouteRoute: publicShareSecretPageRouteRoute,
publicUpgradePathPageRouteRoute: publicUpgradePathPageRouteRoute,
middlewaresAuthenticateRoute: middlewaresAuthenticateRouteWithChildren,
middlewaresRestrictLoginSignupRoute:
middlewaresRestrictLoginSignupRouteWithChildren,
@@ -5952,6 +5976,7 @@ export const routeTree = rootRoute
"/",
"/cli-redirect",
"/share-secret",
"/upgrade-path",
"/_authenticate",
"/_restrict-login-signup",
"/secret-request/secret/$secretRequestId",
@@ -5967,6 +5992,9 @@ export const routeTree = rootRoute
"/share-secret": {
"filePath": "public/ShareSecretPage/route.tsx"
},
"/upgrade-path": {
"filePath": "public/UpgradePathPage/route.tsx"
},
"/_authenticate": {
"filePath": "middlewares/authenticate.tsx",
"children": [

View File

@@ -373,6 +373,7 @@ export const routes = rootRoute("root.tsx", [
route("/shared/secret/$secretId", "public/ViewSharedSecretByIDPage/route.tsx"),
route("/secret-request/secret/$secretRequestId", "public/ViewSecretRequestByIDPage/route.tsx"),
route("/share-secret", "public/ShareSecretPage/route.tsx"),
route("/upgrade-path", "public/UpgradePathPage/route.tsx"),
route("/cli-redirect", "auth/CliRedirectPage/route.tsx"),
middleware("restrict-login-signup.tsx", [
route("/admin/signup", "admin/SignUpPage/route.tsx"),

7
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"license": "ISC",
"dependencies": {
"@radix-ui/react-radio-group": "^1.1.3",
"js-yaml": "^4.1.0",
"secrets.js-grempe": "^2.0.0"
},
"devDependencies": {
@@ -561,7 +562,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/balanced-match": {
@@ -1104,7 +1104,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -1891,8 +1890,7 @@
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"balanced-match": {
"version": "1.0.2",
@@ -2284,7 +2282,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"requires": {
"argparse": "^2.0.1"
}

View File

@@ -25,6 +25,7 @@
},
"dependencies": {
"@radix-ui/react-radio-group": "^1.1.3",
"js-yaml": "^4.1.0",
"secrets.js-grempe": "^2.0.0"
}
}