diff --git a/.github/workflows/validate-upgrade-path.yml b/.github/workflows/validate-upgrade-path.yml index edc494dc37..10ec4fec13 100644 --- a/.github/workflows/validate-upgrade-path.yml +++ b/.github/workflows/validate-upgrade-path.yml @@ -5,6 +5,8 @@ on: 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: @@ -12,170 +14,26 @@ jobs: validate-upgrade-path: name: Validate upgrade-path.yaml runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 3 steps: - name: Checkout source uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 - - name: Check for changes in upgrade-path.yaml - id: check-changes - run: | - # For local testing with act, always run validation - if [ "${ACT:-false}" = "true" ]; then - echo "changed=true" >> $GITHUB_OUTPUT - echo "Running validation (local act mode)" - else - # Check if upgrade-path.yaml was modified in this PR - if git diff --name-only HEAD^ HEAD | grep -q "backend/upgrade-path.yaml"; then - echo "changed=true" >> $GITHUB_OUTPUT - echo "Changes detected in backend/upgrade-path.yaml" - else - echo "changed=false" >> $GITHUB_OUTPUT - echo "No changes detected in backend/upgrade-path.yaml" - fi - fi - - - name: Setup Node.js for YAML validation - if: steps.check-changes.outputs.changed == 'true' + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' + cache: 'npm' + cache-dependency-path: 'backend/package-lock.json' - - name: Create lightweight validation script - if: steps.check-changes.outputs.changed == 'true' + - name: Install minimal dependencies + working-directory: backend run: | - # Create a temporary package.json with only the required dependencies - cat > package.json << 'EOF' - { - "name": "upgrade-path-validator", - "version": "1.0.0", - "dependencies": { - "js-yaml": "^4.1.0", - "zod": "^3.22.0" - } - } - EOF - - - name: Install minimal validation dependencies - if: steps.check-changes.outputs.changed == 'true' - run: | - npm install --no-package-lock --production + 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 - if: steps.check-changes.outputs.changed == 'true' - run: | - echo "Running upgrade-path.yaml validation..." - node << 'EOF' - const fs = require('fs'); - const yaml = require('js-yaml'); - const { z } = require('zod'); - - // Validation schemas matching backend service - const versionSchema = z - .string() - .min(1) - .max(50) - .regex(/^[a-zA-Z0-9._/-]+$/, "Invalid version format"); - - 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) - }); - - const versionConfigSchema = z.object({ - breaking_changes: z.array(breakingChangeSchema).optional(), - db_schema_changes: z.string().max(1000).optional(), - notes: z.string().max(2000).optional() - }); - - const upgradePathConfigSchema = z.object({ - versions: z.record(versionSchema, versionConfigSchema).optional().nullable() - }); - - function validateUpgradePathConfig() { - try { - const yamlPath = './backend/upgrade-path.yaml'; - - if (!fs.existsSync(yamlPath)) { - console.log('Warning: No upgrade-path.yaml file found'); - return true; - } - - const yamlContent = fs.readFileSync(yamlPath, 'utf8'); - - if (yamlContent.length > 1024 * 1024) { - throw new Error('Config file too large (>1MB)'); - } - - // Parse YAML safely - const config = yaml.load(yamlContent, { schema: yaml.FAILSAFE_SCHEMA }); - - if (!config) { - console.log('Warning: Empty configuration file'); - return true; - } - - // Validate schema - const result = upgradePathConfigSchema.safeParse(config); - - if (!result.success) { - console.log('Validation failed with the following errors:'); - result.error.issues.forEach(issue => { - const path = issue.path.length > 0 ? `[${issue.path.join('.')}]` : ''; - console.log(` - ${path}: ${issue.message}`); - }); - return false; - } - - const versions = config.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)`); - - // Check for common version patterns - const commonPatterns = [ - /^v?\d+\.\d+\.\d+$/, // v1.2.3 or 1.2.3 - /^v?\d+\.\d+\.\d+\.\d+$/, // v1.2.3.4 or 1.2.3.4 - /^infisical\/v?\d+\.\d+\.\d+$/, // infisical/v1.2.3 - /^infisical\/v?\d+\.\d+\.\d+-\w+$/ // infisical/v1.2.3-postgres - ]; - - 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'); - return true; - - } catch (error) { - console.log(`Validation failed: ${error.message}`); - return false; - } - } - - if (!validateUpgradePathConfig()) { - process.exit(1); - } - EOF - - - name: Validation completed - if: steps.check-changes.outputs.changed == 'true' - run: | - echo "upgrade-path.yaml validation passed!" - echo "The configuration file follows the expected format and all version entries are valid." - - - name: Skipping validation - if: steps.check-changes.outputs.changed == 'false' - run: | - echo "Skipping validation - no changes detected in backend/upgrade-path.yaml" \ No newline at end of file + working-directory: backend + run: npx tsx ./scripts/validate-upgrade-path-file.ts \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 8fece4ebe1..f06c1d69f4 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": "", diff --git a/backend/scripts/validate-upgrade-path-file.ts b/backend/scripts/validate-upgrade-path-file.ts new file mode 100644 index 0000000000..934d5d822d --- /dev/null +++ b/backend/scripts/validate-upgrade-path-file.ts @@ -0,0 +1,111 @@ +/* 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); + } +} + +if (require.main === module) { + validateUpgradePathConfig().catch((error) => { + console.error("Unexpected error:", error); + process.exit(1); + }); +} + +export { validateUpgradePathConfig }; diff --git a/backend/src/server/routes/v1/upgrade-path-router.ts b/backend/src/server/routes/v1/upgrade-path-router.ts index 484b12f53e..481bee5e64 100644 --- a/backend/src/server/routes/v1/upgrade-path-router.ts +++ b/backend/src/server/routes/v1/upgrade-path-router.ts @@ -1,15 +1,9 @@ -import RE2 from "re2"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; 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"); +import { versionSchema } from "@app/services/upgrade-path/upgrade-path-schemas"; export const registerUpgradePathRouter = async (server: FastifyZodProvider) => { server.route({ 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 0000000000..a283505c7d --- /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 index ebba09de6d..45c602d785 100644 --- a/backend/src/services/upgrade-path/upgrade-path-service.ts +++ b/backend/src/services/upgrade-path/upgrade-path-service.ts @@ -9,30 +9,13 @@ 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; -const versionSchema = z - .string() - .min(1) - .max(50) - .regex(new RE2(/^[a-zA-Z0-9._/-]+$/), "Invalid version format"); - -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) -}); - -const versionConfigSchema = z.object({ - breaking_changes: z.array(breakingChangeSchema).optional(), - db_schema_changes: z.string().max(1000).optional(), - notes: z.string().max(2000).optional() -}); - interface CalculateUpgradePathParams { fromVersion: string; toVersion: string; diff --git a/package-lock.json b/package-lock.json index 6e37ffeab9..4d72220afd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "license": "ISC", "dependencies": { "@radix-ui/react-radio-group": "^1.1.3", - "js-yaml": "^4.1.0", "secrets.js-grempe": "^2.0.0" }, "devDependencies": { @@ -562,6 +561,7 @@ "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,6 +1104,7 @@ "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" @@ -1890,7 +1891,8 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "balanced-match": { "version": "1.0.2", @@ -2282,6 +2284,7 @@ "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" } diff --git a/package.json b/package.json index 2efd0def8d..db15de3fb7 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ }, "dependencies": { "@radix-ui/react-radio-group": "^1.1.3", - "js-yaml": "^4.1.0", "secrets.js-grempe": "^2.0.0" } }