diff --git a/.github/workflows/validate-upgrade-path.yml b/.github/workflows/validate-upgrade-path.yml new file mode 100644 index 000000000..dabdccae0 --- /dev/null +++ b/.github/workflows/validate-upgrade-path.yml @@ -0,0 +1,39 @@ +name: "Validate Upgrade Path Configuration" + +on: + pull_request: + types: [opened, synchronize] + paths: + - "backend/upgrade-path.yaml" + - "backend/scripts/validate-upgrade-path-file.ts" + - "backend/src/services/upgrade-path/upgrade-path-schemas.ts" + + workflow_call: + +jobs: + validate-upgrade-path: + name: Validate upgrade-path.yaml + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'backend/package-lock.json' + + - name: Install minimal dependencies + working-directory: backend + run: | + npm install --no-package-lock js-yaml@^4.1.0 zod@^3.22.0 tsx@^4.0.0 @types/js-yaml@^4.0.0 re2@^1.20.0 + + - name: Validate upgrade-path.yaml format + working-directory: backend + run: npx tsx ./scripts/validate-upgrade-path-file.ts \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index c6ac0b147..db4e9e7dd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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" }, diff --git a/backend/package.json b/backend/package.json index 0c8464eaf..f06c1d69f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -73,7 +73,8 @@ "seed": "knex --knexfile ./dist/db/knexfile.ts --client pg seed:run", "seed-dev": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run", "db:reset": "npm run migration:rollback -- --all && npm run migration:latest", - "email:dev": "email dev --dir src/services/smtp/emails" + "email:dev": "email dev --dir src/services/smtp/emails", + "validate-upgrade-path": "tsx ./scripts/validate-upgrade-path-file.ts" }, "keywords": [], "author": "", @@ -87,6 +88,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 +205,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", diff --git a/backend/scripts/validate-upgrade-path-file.ts b/backend/scripts/validate-upgrade-path-file.ts new file mode 100644 index 000000000..566300cde --- /dev/null +++ b/backend/scripts/validate-upgrade-path-file.ts @@ -0,0 +1,107 @@ +/* eslint-disable no-console */ +import { readFile } from "fs/promises"; +import * as yaml from "js-yaml"; +import * as path from "path"; +import { z } from "zod"; + +import { upgradePathConfigSchema } from "../src/services/upgrade-path/upgrade-path-schemas"; + +async function validateUpgradePathConfig(): Promise { + try { + const yamlPath = path.join(__dirname, "..", "upgrade-path.yaml"); + const resolvedPath = path.resolve(yamlPath); + const expectedBaseDir = path.resolve(__dirname, ".."); + + if (!resolvedPath.startsWith(expectedBaseDir)) { + throw new Error("Invalid configuration file path"); + } + + try { + await readFile(yamlPath, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + console.log("Warning: No upgrade-path.yaml file found"); + return; + } + throw error; + } + + const yamlContent = await readFile(yamlPath, "utf8"); + + if (yamlContent.length > 1024 * 1024) { + throw new Error("Config file too large (>1MB)"); + } + + let config: unknown; + try { + config = yaml.load(yamlContent, { + schema: yaml.FAILSAFE_SCHEMA, + filename: yamlPath, + onWarning: (warning) => { + console.log(`YAML Warning: ${warning.message}`); + } + }); + } catch (yamlError) { + if (yamlError instanceof yaml.YAMLException) { + throw new Error( + `YAML parsing failed: ${yamlError.message} at line ${yamlError.mark?.line}, column ${yamlError.mark?.column}` + ); + } + throw new Error(`YAML parsing failed: ${yamlError instanceof Error ? yamlError.message : "Unknown YAML error"}`); + } + + if (!config) { + console.log("Warning: Empty configuration file"); + return; + } + + if (typeof config !== "object" || config === null) { + throw new Error("Configuration must be a valid YAML object"); + } + + const result = upgradePathConfigSchema.safeParse(config); + + if (!result.success) { + console.log("Validation failed with the following errors:"); + result.error.issues.forEach((issue: z.ZodIssue) => { + const issuePath = issue.path.length > 0 ? `[${issue.path.join(".")}]` : ""; + console.log(` - ${issuePath}: ${issue.message}`); + }); + throw new Error("Schema validation failed"); + } + + const validatedConfig = result.data; + const versions = validatedConfig?.versions || {}; + const versionCount = Object.keys(versions).length; + + if (versionCount === 0) { + console.log("Warning: No versions found in the configuration"); + } else { + console.log(`Validated ${versionCount} version configuration(s)`); + + const commonPatterns = [ + /^v?\d+\.\d+\.\d+$/, + /^v?\d+\.\d+\.\d+\.\d+$/, + /^infisical\/v?\d+\.\d+\.\d+$/, + /^infisical\/v?\d+\.\d+\.\d+-\w+$/ + ]; + + for (const versionKey of Object.keys(versions)) { + const isCommonPattern = commonPatterns.some((pattern) => pattern.test(versionKey)); + if (!isCommonPattern) { + console.log(`Warning: Version key '${versionKey}' doesn't match common patterns. This may be intentional.`); + } + } + } + + console.log("upgrade-path.yaml format is valid"); + } catch (error) { + console.error(`Validation failed: ${error instanceof Error ? error.message : "Unknown error"}`); + process.exit(1); + } +} + +validateUpgradePathConfig().catch((error) => { + console.error("Unexpected error:", error); + process.exit(1); +}); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index e30d55d8c..c814ab9d8 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -115,6 +115,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"; @@ -314,6 +315,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 diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index d8e39e718..15f878323 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -129,6 +129,8 @@ const envSchema = z POSTHOG_HOST: zpStr(z.string().optional().default("https://app.posthog.com")), POSTHOG_PROJECT_API_KEY: zpStr(z.string().optional().default("phc_nSin8j5q2zdhpFDI1ETmFNUIuTG4DwKVyIigrY10XiE")), LOOPS_API_KEY: zpStr(z.string().optional()), + // GitHub API token for upgrade path tool + GITHUB_API_TOKEN: zpStr(z.string().optional()), // jwt options AUTH_SECRET: zpStr(z.string()).default(process.env.JWT_AUTH_SECRET), // for those still using old JWT_AUTH_SECRET JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")), diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2fb7e9ff7..dc4c06c4a 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -313,6 +313,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"; @@ -764,6 +765,8 @@ export const registerRoutes = async ( userAliasDAL }); + const upgradePathService = upgradePathServiceFactory({ keyStore }); + const totpService = totpServiceFactory({ totpConfigDAL, userDAL, @@ -2236,7 +2239,8 @@ export const registerRoutes = async ( reminder: reminderService, bus: eventBusService, sse: sseService, - notification: notificationService + notification: notificationService, + upgradePath: upgradePathService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 0332d27a9..84b1442f6 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -58,6 +58,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"; @@ -217,4 +218,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { ); await server.register(registerEventRouter, { prefix: "/events" }); + await server.register(registerUpgradePathRouter, { prefix: "/upgrade-path" }); }; diff --git a/backend/src/server/routes/v1/upgrade-path-router.ts b/backend/src/server/routes/v1/upgrade-path-router.ts new file mode 100644 index 000000000..481bee5e6 --- /dev/null +++ b/backend/src/server/routes/v1/upgrade-path-router.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { publicEndpointLimit } from "@app/server/config/rateLimiter"; +import { versionSchema } from "@app/services/upgrade-path/upgrade-path-schemas"; + +export const registerUpgradePathRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/versions", + config: { + rateLimit: publicEndpointLimit + }, + schema: { + 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 versions = await req.server.services.upgradePath.getGitHubReleases(); + + return { + versions + }; + } catch (error) { + logger.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 + }), + 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 } = req.body; + + const result = await req.server.services.upgradePath.calculateUpgradePath(fromVersion, toVersion); + + logger.info( + { pathLength: result.path.length, hasBreaking: result.breakingChanges.length > 0 }, + "Upgrade path calculated" + ); + + return result; + } catch (error) { + logger.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" }); + } + } + }); +}; diff --git a/backend/src/services/upgrade-path/github-client.ts b/backend/src/services/upgrade-path/github-client.ts new file mode 100644 index 000000000..44aacca6d --- /dev/null +++ b/backend/src/services/upgrade-path/github-client.ts @@ -0,0 +1,242 @@ +/* eslint-disable no-await-in-loop */ +import RE2 from "re2"; + +import { getConfig } from "@app/lib/config/env"; + +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: getConfig().GITHUB_API_TOKEN, + timeout: 30000, + maxRetries: 3, + retryDelay: 1000, + maxPagesPerRequest: 10, + perPage: 100 +}); + +const getHeaders = (token?: string): Record => { + const headers: Record = { + 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 => { + 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; + } + + const patterns = [ + new RE2(/^v\d+\.\d+\.\d+/), + new RE2(/^\d+\.\d+\.\d+/), + new RE2(/^infisical\/v?\d+\.\d+\.\d+/), + new RE2(/^infisical\/v?\d+\.\d+\.\d+[-\w]*/) + ]; + + return patterns.some((pattern) => pattern.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 compareVersions = (v1: string, v2: string): number => { + const normalize = (v: string) => { + const versionMatch = v.match(new RE2(/(\d+\.\d+\.\d+(?:\.\d+)?)/)); + if (versionMatch) { + return versionMatch[1]; + } + if (v.startsWith("infisical/")) { + return v.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), ""); + } + return v.replace(new RE2(/^v/), "").replace(new RE2(/-[a-zA-Z]+$/), ""); + }; + + const clean1 = normalize(v1); + const clean2 = normalize(v2); + + const parts1 = clean1.split(".").map(Number); + const parts2 = clean2.split(".").map(Number); + + const maxLength = Math.max(parts1.length, parts2.length); + while (parts1.length < maxLength) parts1.push(0); + while (parts2.length < maxLength) parts2.push(0); + + for (let i = 0; i < maxLength; i += 1) { + if (parts1[i] > parts2[i]) return 1; + if (parts1[i] < parts2[i]) return -1; + } + return 0; +}; + +const isVersionAtLeastMinimum = (tagName: string, minimumVersion = "0.147.0"): boolean => { + return compareVersions(tagName, minimumVersion) >= 0; +}; + +const makeRequest = async ( + 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(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") { + if (retryCount < config.maxRetries) { + await delay(config.retryDelay * 2 ** retryCount); + return await makeRequest(url, config, retryCount + 1); + } + 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(url, config, retryCount + 1); + } + + throw error; + } +}; + +export const fetchReleases = async (includePrerelease = false): Promise => { + const config = getDefaultConfig(); + const allReleases: GitHubRelease[] = []; + let page = 1; + let hasMorePages = true; + let reachedMinimumVersion = false; + + const maxConcurrentRequests = Math.min(3, config.maxPagesPerRequest); + + while (hasMorePages && page <= config.maxPagesPerRequest && !reachedMinimumVersion) { + 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(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) { + for (const release of data) { + if (!release.draft && isMainInfisicalRelease(release.tag_name)) { + if (isVersionAtLeastMinimum(release.tag_name)) { + allReleases.push(release); + } else { + reachedMinimumVersion = true; + break; + } + } + } + hasData = true; + } + } + } + + if (!hasData || results.every((r) => r.status === "fulfilled" && r.value.data.length < config.perPage)) { + hasMorePages = false; + } + } + + const formattedReleases = allReleases + .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); +}; diff --git a/backend/src/services/upgrade-path/index.ts b/backend/src/services/upgrade-path/index.ts new file mode 100644 index 000000000..1000e2bfa --- /dev/null +++ b/backend/src/services/upgrade-path/index.ts @@ -0,0 +1,2 @@ +export type { TUpgradePathService, TUpgradePathServiceFactory } from "./upgrade-path-service"; +export { upgradePathServiceFactory } from "./upgrade-path-service"; diff --git a/backend/src/services/upgrade-path/types.ts b/backend/src/services/upgrade-path/types.ts new file mode 100644 index 000000000..83d3d1546 --- /dev/null +++ b/backend/src/services/upgrade-path/types.ts @@ -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; +} + +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; +} + +export interface GitHubApiError extends Error { + status?: number; + headers?: Headers; +} + +export interface CacheEntry { + data: T; + timestamp: number; + ttl: number; +} diff --git a/backend/src/services/upgrade-path/upgrade-path-schemas.ts b/backend/src/services/upgrade-path/upgrade-path-schemas.ts new file mode 100644 index 000000000..a283505c7 --- /dev/null +++ b/backend/src/services/upgrade-path/upgrade-path-schemas.ts @@ -0,0 +1,24 @@ +import RE2 from "re2"; +import { z } from "zod"; + +export const versionSchema = z + .string() + .min(1) + .max(50) + .regex(new RE2(/^[a-zA-Z0-9._/-]+$/), "Invalid version format"); + +export const breakingChangeSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().min(1).max(1000), + action: z.string().min(1).max(500) +}); + +export const versionConfigSchema = z.object({ + breaking_changes: z.array(breakingChangeSchema).optional(), + db_schema_changes: z.string().max(1000).optional(), + notes: z.string().max(2000).optional() +}); + +export const upgradePathConfigSchema = z.object({ + versions: z.record(versionSchema, versionConfigSchema).optional().nullable() +}); diff --git a/backend/src/services/upgrade-path/upgrade-path-service.ts b/backend/src/services/upgrade-path/upgrade-path-service.ts new file mode 100644 index 000000000..45c602d78 --- /dev/null +++ b/backend/src/services/upgrade-path/upgrade-path-service.ts @@ -0,0 +1,259 @@ +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 { logger } from "@app/lib/logger"; + +import { fetchReleases } from "./github-client"; +import { BreakingChange, FormattedRelease, UpgradePathConfig, UpgradePathResult, VersionConfig } from "./types"; +import { versionConfigSchema, versionSchema } from "./upgrade-path-schemas"; + +export type TUpgradePathServiceFactory = { + keyStore: TKeyStoreFactory; +}; +export type TUpgradePathService = ReturnType; + +interface CalculateUpgradePathParams { + fromVersion: string; + toVersion: string; +} + +export const upgradePathServiceFactory = ({ keyStore }: TUpgradePathServiceFactory) => { + const sanitizeCacheKey = (key: string): string => { + return key.replace(new RE2(/[^a-zA-Z0-9\-:._]/g), "_"); + }; + const getGitHubReleases = async (): Promise => { + const cacheKey = "upgrade-path:releases"; + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) { + const cachedReleases = JSON.parse(cached) as FormattedRelease[]; + if (cachedReleases.length > 0) { + return cachedReleases; + } + } + } catch (error) { + logger.error(error, "Failed to retrieve releases from cache"); + } + + try { + const releases = await fetchReleases(false); + 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>> => { + const cacheKey = "upgrade-path:config"; + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) return JSON.parse(cached) as Record; + } catch (error) { + logger.error(error, "Failed to retrieve config from cache"); + } + + try { + const yamlPath = path.join(__dirname, "..", "..", "..", "upgrade-path.yaml"); + const resolvedPath = path.resolve(yamlPath); + const expectedBaseDir = path.resolve(__dirname, "..", "..", ".."); + if (!resolvedPath.startsWith(expectedBaseDir)) { + throw new Error("Invalid configuration file path"); + } + + const yamlContent = await readFile(yamlPath, "utf8"); + + if (yamlContent.length > 1024 * 1024) { + throw new Error("Config file too large"); + } + + const config = yaml.load(yamlContent, { schema: yaml.FAILSAFE_SCHEMA }) 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 => { + const versionRegex = new RE2(/(\d+\.\d+\.\d+(?:\.\d+)?)/); + const versionMatch = version.match(versionRegex); + if (versionMatch) { + return versionMatch[1]; + } + + 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 validateParams = (params: CalculateUpgradePathParams) => { + const { fromVersion, toVersion } = 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 }; + }; + + const calculateUpgradePath = async (params: CalculateUpgradePathParams): Promise => { + const { fromVersion, toVersion } = validateParams(params); + const cacheKey = sanitizeCacheKey(`upgrade-path:${fromVersion}:${toVersion}`); + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) return JSON.parse(cached) as UpgradePathResult; + } catch (error) { + logger.error(error, "Failed to retrieve upgrade path from cache"); + } + + const [releases, config] = await Promise.all([getGitHubReleases(), getUpgradePathConfig()]); + + const cleanFrom = normalizeVersion(fromVersion); + const cleanTo = normalizeVersion(toVersion); + + const compareVersions = (v1: string, v2: string): number => { + const normalize = (v: string) => normalizeVersion(v); + const clean1 = normalize(v1); + const clean2 = normalize(v2); + + const parts1 = clean1.split(".").map(Number); + const parts2 = clean2.split(".").map(Number); + + const maxLength = Math.max(parts1.length, parts2.length); + while (parts1.length < maxLength) parts1.push(0); + while (parts2.length < maxLength) parts2.push(0); + + for (let i = 0; i < maxLength; i += 1) { + if (parts1[i] > parts2[i]) return 1; + if (parts1[i] < parts2[i]) return -1; + } + return 0; + }; + + if (compareVersions(cleanFrom, cleanTo) >= 0) { + throw new Error("fromVersion must be older than toVersion"); + } + + const fromIdx = releases.findIndex((r) => normalizeVersion(r.normalizedTagName) === cleanFrom); + const toIdx = releases.findIndex((r) => normalizeVersion(r.normalizedTagName) === cleanTo); + + let upgradePath: FormattedRelease[] = []; + const filteredPath: FormattedRelease[] = []; + + if (fromIdx !== -1 && toIdx !== -1) { + if (fromIdx <= toIdx) throw new Error("Invalid version order"); + upgradePath = releases.slice(toIdx, fromIdx + 1).reverse(); + const [first, last] = [upgradePath[0], upgradePath[upgradePath.length - 1]]; + + filteredPath.push(first); + 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; + + const isVersionInRange = (version: string, fromVer: string, toVer: string): boolean => { + const versionComp = compareVersions(version, fromVer); + const toVersionComp = compareVersions(version, toVer); + return versionComp > 0 && toVersionComp < 0; + }; + + Object.keys(config).forEach((configVersion) => { + const versionConfig = config[configVersion]; + if (versionConfig?.breaking_changes?.length) { + if (isVersionInRange(configVersion, cleanFrom, cleanTo)) { + breakingChanges.push({ + version: configVersion, + changes: versionConfig.breaking_changes + }); + } + } + }); + for (let i = 0; i < upgradePath.length; i += 1) { + const version = upgradePath[i]; + const isFromVersion = normalizeVersion(version.normalizedTagName) === cleanFrom; + + 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) => calculateUpgradePath({ fromVersion, toVersion }) + }; +}; diff --git a/backend/upgrade-path.yaml b/backend/upgrade-path.yaml new file mode 100644 index 000000000..4ccb7e775 --- /dev/null +++ b/backend/upgrade-path.yaml @@ -0,0 +1,26 @@ +# Upgrade Path Configuration File +# +# This file defines breaking changes and database migration information for Infisical versions. +# Used by the upgrade path tool to help users understand what changes are required between versions. +# +# Expected format: +# versions: +# "version_key": # Can be "v1.2.3", "1.2.3", or "infisical/v1.2.3-postgres" +# breaking_changes: # Optional: list of breaking changes for this version +# - title: "Short descriptive title" +# description: "Detailed description of what changed" +# action: "Specific steps users need to take" +# db_schema_changes: "Optional: Description of database changes and migration details" +# notes: "Optional: Additional notes or important information about this version" +# +# Example: +# versions: +# "v1.2.3": +# breaking_changes: +# - title: "API Endpoint Changes" +# description: "Authentication endpoints have been restructured" +# action: "Update all API calls to use new /auth/v2/ endpoints" +# db_schema_changes: "Major schema restructuring with table reorganization. Extended migration time: 3 minutes." +# notes: "Critical update requiring maintenance window. Test thoroughly before production deployment." + +versions: \ No newline at end of file diff --git a/docs/images/self-hosting/helper/upgrade-path-tool.png b/docs/images/self-hosting/helper/upgrade-path-tool.png new file mode 100644 index 000000000..a8a538aaf Binary files /dev/null and b/docs/images/self-hosting/helper/upgrade-path-tool.png differ diff --git a/docs/self-hosting/guides/upgrading-infisical.mdx b/docs/self-hosting/guides/upgrading-infisical.mdx index cae8193bf..0ffe32346 100644 --- a/docs/self-hosting/guides/upgrading-infisical.mdx +++ b/docs/self-hosting/guides/upgrading-infisical.mdx @@ -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. diff --git a/frontend/src/hooks/api/upgradePath/index.ts b/frontend/src/hooks/api/upgradePath/index.ts new file mode 100644 index 000000000..d0eaefdb5 --- /dev/null +++ b/frontend/src/hooks/api/upgradePath/index.ts @@ -0,0 +1,2 @@ +export type { CalculateUpgradePathParams, GitHubVersion, UpgradePathResult } from "./queries"; +export { useCalculateUpgradePath, useGetUpgradePathVersions } from "./queries"; diff --git a/frontend/src/hooks/api/upgradePath/queries.tsx b/frontend/src/hooks/api/upgradePath/queries.tsx new file mode 100644 index 000000000..d7e8d705e --- /dev/null +++ b/frontend/src/hooks/api/upgradePath/queries.tsx @@ -0,0 +1,75 @@ +import { useMutation, 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; +} + +export interface CalculateUpgradePathParams { + fromVersion: string; + toVersion: string; +} + +const upgradePathKeys = { + all: ["upgrade-path"] as const, + versions: () => [...upgradePathKeys.all, "versions"] as const, + calculate: (params: CalculateUpgradePathParams) => + [...upgradePathKeys.all, "calculate", params] as const +}; + +export const useGetUpgradePathVersions = ( + options?: Omit, "queryKey" | "queryFn"> +) => { + return useQuery({ + queryKey: upgradePathKeys.versions(), + queryFn: async () => { + const { data } = await apiRequest.get<{ versions: GitHubVersion[] }>( + "/api/v1/upgrade-path/versions" + ); + return data; + }, + ...options + }); +}; + +export const useCalculateUpgradePath = () => { + return useMutation({ + mutationFn: async (params: CalculateUpgradePathParams): Promise => { + const { data } = await apiRequest.post( + "/api/v1/upgrade-path/calculate", + params + ); + return data; + } + }); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 71be38b11..128760ab4 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -13,6 +13,7 @@ import { faInfoCircle, faServer, faSignOut, + faToolbox, faUser, faUsers } from "@fortawesome/free-solid-svg-icons"; @@ -104,6 +105,11 @@ export const INFISICAL_SUPPORT_OPTIONS = [ , "Instance Admins", () => "server-admins" + ], + [ + , + "Version Upgrade Tool", + () => "/upgrade-path" ] ] as const; @@ -345,6 +351,9 @@ export const Navbar = () => { if (url === "server-admins" && isInfisicalCloud()) { return null; } + if (url === "upgrade-path" && isInfisicalCloud()) { + return null; + } return ( {url === "server-admins" ? ( diff --git a/frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx b/frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx new file mode 100644 index 000000000..4e81afcd5 --- /dev/null +++ b/frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx @@ -0,0 +1,554 @@ +/* eslint-disable no-nested-ternary */ +import React, { useMemo, useState } from "react"; +import { Helmet } from "react-helmet"; +import { SingleValue } from "react-select"; +import { faExternalLink } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FilterableSelect, FormControl } from "@app/components/v2"; + +import { + useCalculateUpgradePath, + useGetUpgradePathVersions +} from "../../../hooks/api/upgradePath/queries"; + +type VersionOption = { + label: string; + value: string; + isLatest: boolean; +}; + +const formatVersionOption = (option: VersionOption) => ( +
+ {option.label} + {option.isLatest && (Latest)} +
+); +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; +} + +export const UpgradePathPage = () => { + const [fromVersion, setFromVersion] = useState(null); + const [toVersion, setToVersion] = useState(null); + const [upgradeResult, setUpgradeResult] = useState(null); + + const { + data: versions, + isLoading: versionsLoading, + isFetching: versionsFetching + } = useGetUpgradePathVersions({ + enabled: true, + staleTime: 24 * 60 * 60 * 1000, + refetchOnWindowFocus: false + }); + + const calculateMutation = useCalculateUpgradePath(); + + // Handle mutation results + React.useEffect(() => { + if (calculateMutation.isSuccess && calculateMutation.data) { + setUpgradeResult(calculateMutation.data); + } + }, [calculateMutation.isSuccess, calculateMutation.data]); + + React.useEffect(() => { + if (calculateMutation.isError) { + createNotification({ + text: + (calculateMutation.error as any)?.response?.data?.message || + "Failed to calculate upgrade path", + type: "error" + }); + } + }, [calculateMutation.isError, calculateMutation.error]); + + const versionOptions = useMemo(() => { + if (!versions?.versions) return []; + + return versions.versions + .filter((version) => !version.tagName.includes("nightly")) + .map((version) => ({ + label: version.tagName, + value: version.tagName, + isLatest: versions.versions[0]?.tagName === version.tagName + })); + }, [versions?.versions]); + + const handleFromVersionSelect = (value: unknown) => { + const selected = value as SingleValue; + setFromVersion(selected?.value || null); + }; + + const handleToVersionSelect = (value: unknown) => { + const selected = value as SingleValue; + setToVersion(selected?.value || null); + }; + + 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 + }); + }; + + return ( + <> + + Infisical Upgrade Path Tool | Infisical + + + + + +
+
+
+
+ {/* Header */} +
+
+ + Infisical logo + +
+

+ Upgrade your Infisical Version +

+
+ + {/* Calculator Card */} +
+

Calculate Upgrade Path

+
+
+ {/* From Version Selector */} + + opt.value === fromVersion) || null} + onChange={handleFromVersionSelect} + placeholder="Search or select version..." + isLoading={versionsLoading || versionsFetching} + isDisabled={versionsLoading || versionsFetching} + isSearchable + isClearable + menuPortalTarget={document.body} + formatOptionLabel={formatVersionOption} + /> + + + {/* To Version Selector */} + + opt.value === toVersion) || null} + onChange={handleToVersionSelect} + placeholder="Search or select version..." + isLoading={versionsLoading || versionsFetching} + isDisabled={versionsLoading || versionsFetching} + isSearchable + isClearable + menuPortalTarget={document.body} + formatOptionLabel={formatVersionOption} + /> + +
+ + +
+
+ + {/* Results Section */} + {upgradeResult && ( +
+ {/* 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; + + 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 ( +
+
+
+ {hasIssues ? ( + + + + ) : ( + + + + )} +
+
+

+ {hasIssues ? "Action Required:" : "Ready to Upgrade:"} +

+

+ {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."} +

+
+
+
+ ); + })()} + + {/* Upgrade Steps */} +
+
+ + + +

Upgrade Steps

+
+ +
+ {(() => { + const pathSteps = upgradeResult.path.map((step) => ({ + ...step, + hasGithubRelease: true + })); + + const breakingChangeSteps = upgradeResult.breakingChanges + .filter( + (bc) => + !upgradeResult.path.some((step) => { + const normalizeVersion = (v: string) => + v.replace(/^(infisical\/)?v?/, "").replace(/-[a-zA-Z]+$/, ""); + return ( + normalizeVersion(step.version) === normalizeVersion(bc.version) + ); + }) + ) + .map((bc) => ({ + version: bc.version, + name: bc.version, + publishedAt: new Date().toISOString(), + prerelease: false, + hasGithubRelease: false + })); + + const allSteps = [...pathSteps, ...breakingChangeSteps]; + + allSteps.sort((a, b) => { + const normalizeForSort = (v: string) => { + const cleaned = v + .replace(/^(infisical\/)?v?/, "") + .replace(/-[a-zA-Z]+$/, ""); + const parts = cleaned.split(".").map(Number); + return parts[0] * 1000000 + (parts[1] || 0) * 1000 + (parts[2] || 0); + }; + return normalizeForSort(a.version) - normalizeForSort(b.version); + }); + + return allSteps; + })().map((step, index, allSteps) => { + const isFirst = index === 0; + const isLast = index === allSteps.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; + + 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 ( +
+ {/* Timeline Column */} +
+
+ {index + 1} +
+ + {/* Timeline Line */} + {!isLast && ( +
+ )} +
+ + {/* Content Column */} +
+ {/* Version Header */} +
+

{step.version}

+ {isFirst && ( + + Starting Version + + )} + {isLast && ( + + Target Version + + )} + {step.hasGithubRelease ? ( + + + View Changelog + + ) : ( + + No GitHub Release + + )} +
+ + {/* Version Notes */} + {(() => { + if (isFirst) return null; + + const notes = possibleKeys + .map((key) => versionConfig?.[key]?.notes) + .find((note) => note); + + if (!notes) return null; + + return ( +
+
{notes}
+
+ ); + })()} + + {/* Database Schema Changes */} + {hasDbMigration && ( +
+
+ Database Schema Changes Required +
+
+ {typeof dbMigrationDescription === "string" + ? dbMigrationDescription + : "This version includes database schema changes that require migrations."} +
+
+ Action:{" "} + + Make sure to backup your database before proceeding + +
+
+ )} + + {/* Breaking Changes */} + {hasBreakingChanges && ( +
+
+ + + + + Breaking Changes ({versionChanges.changes.length}) + +
+ {versionChanges.changes.map((change) => ( +
+
+ {change.title} +
+
+ {change.description} +
+
+ Action:{" "} + {change.action} +
+
+ ))} +
+ )} +
+
+ ); + })} +
+
+
+ )} +
+
+

+ Made with ❤️ by{" "} + + Infisical + +
+ 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸 +

+
+
+
+ + ); +}; diff --git a/frontend/src/pages/public/UpgradePathPage/route.tsx b/frontend/src/pages/public/UpgradePathPage/route.tsx new file mode 100644 index 000000000..cc91b74e4 --- /dev/null +++ b/frontend/src/pages/public/UpgradePathPage/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { UpgradePathPage } from "./UpgradePathPage"; + +export const Route = createFileRoute("/upgrade-path")({ + component: UpgradePathPage +}); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index e32632ba1..5369764a8 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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' @@ -327,6 +328,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', @@ -2092,6 +2101,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: '' @@ -4726,6 +4742,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 @@ -4953,6 +4970,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 @@ -5166,6 +5184,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 @@ -5405,6 +5424,7 @@ export interface FileRouteTypes { | '/' | '/cli-redirect' | '/share-secret' + | '/upgrade-path' | '' | '/password-setup' | '/email-not-verified' @@ -5631,6 +5651,7 @@ export interface FileRouteTypes { | '/' | '/cli-redirect' | '/share-secret' + | '/upgrade-path' | '' | '/password-setup' | '/email-not-verified' @@ -5842,6 +5863,7 @@ export interface FileRouteTypes { | '/' | '/cli-redirect' | '/share-secret' + | '/upgrade-path' | '/_authenticate' | '/_restrict-login-signup' | '/_authenticate/password-setup' @@ -6080,6 +6102,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 @@ -6090,6 +6113,7 @@ const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, authCliRedirectPageRouteRoute: authCliRedirectPageRouteRoute, publicShareSecretPageRouteRoute: publicShareSecretPageRouteRoute, + publicUpgradePathPageRouteRoute: publicUpgradePathPageRouteRoute, middlewaresAuthenticateRoute: middlewaresAuthenticateRouteWithChildren, middlewaresRestrictLoginSignupRoute: middlewaresRestrictLoginSignupRouteWithChildren, @@ -6112,6 +6136,7 @@ export const routeTree = rootRoute "/", "/cli-redirect", "/share-secret", + "/upgrade-path", "/_authenticate", "/_restrict-login-signup", "/secret-request/secret/$secretRequestId", @@ -6127,6 +6152,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": [ diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index fe1ca676d..f65c75c70 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -380,6 +380,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"),