From 17df28495abe7bcfbd36f4c432a71648b002f202 Mon Sep 17 00:00:00 2001 From: Josh De Winne Date: Mon, 11 May 2026 15:17:59 -0700 Subject: [PATCH] feat: add .replicated config file parser and discovery logic - Add src/config.ts with ReplicatedConfig interfaces (ChartConfig, PreflightConfig, ReplLintConfig) - Implement findAndParseConfig() to walk upward and discover .replicated / .replicated.yaml files - Implement parseConfigFile() to read, validate, and resolve relative paths to absolute - Add monorepo config merging: scalars override, channel arrays replace, resource arrays append+dedupe - Validate chart/preflight/manifest paths, preflight cross-fields, and glob syntax via picomatch - Export new types and functions from src/index.ts - Add comprehensive tests in src/config.test.ts - Add picomatch dependency for glob pattern validation --- package-lock.json | 72 +++++------- package.json | 1 + src/config.test.ts | 175 +++++++++++++++++++++++++++++ src/config.ts | 271 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + 5 files changed, 477 insertions(+), 43 deletions(-) create mode 100644 src/config.test.ts create mode 100644 src/config.ts diff --git a/package-lock.json b/package-lock.json index 826d832..5c8bc8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "date-fns-tz": "^3.2.0", "esbuild-jest": "^0.5.0", "pako": "^2.1.0", + "picomatch": "^4.0.4", "ts-node": "^10.9.1", "yaml": "^2.2.2" }, @@ -2786,6 +2787,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -5591,19 +5604,6 @@ "fsevents": "^2.3.3" } }, - "node_modules/jest-haste-map/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-leak-detector": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", @@ -5655,19 +5655,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-mock": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", @@ -5938,19 +5925,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-validate": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", @@ -6273,6 +6247,18 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -7255,12 +7241,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" diff --git a/package.json b/package.json index 2ffd3ef..b4a4abc 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "date-fns-tz": "^3.2.0", "esbuild-jest": "^0.5.0", "pako": "^2.1.0", + "picomatch": "^4.0.4", "ts-node": "^10.9.1", "yaml": "^2.2.2" }, diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..be8570a --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,175 @@ +import * as path from "path"; +import * as fs from "fs-extra"; +import * as os from "os"; +import { findAndParseConfig, parseConfigFile, ReplicatedConfig } from "./config"; + +describe("findAndParseConfig", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "replicated-config-test-")); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + it("returns null when no config file exists", () => { + const result = findAndParseConfig(tmpDir); + expect(result).toBeNull(); + }); + + it("parses .replicated in current directory", async () => { + const configPath = path.join(tmpDir, ".replicated"); + await fs.writeFile(configPath, "appSlug: my-app\ncharts:\n - path: ./chart\nmanifests:\n - ./manifest.yaml\n"); + + const result = findAndParseConfig(tmpDir); + expect(result).not.toBeNull(); + expect(result!.appSlug).toBe("my-app"); + expect(result!.charts).toHaveLength(1); + expect(result!.charts![0].path).toBe(path.resolve(tmpDir, "chart")); + expect(result!.manifests).toHaveLength(1); + expect(result!.manifests![0]).toBe(path.resolve(tmpDir, "manifest.yaml")); + }); + + it("falls back to .replicated.yaml", async () => { + const configPath = path.join(tmpDir, ".replicated.yaml"); + await fs.writeFile(configPath, "appSlug: fallback-app\n"); + + const result = findAndParseConfig(tmpDir); + expect(result).not.toBeNull(); + expect(result!.appSlug).toBe("fallback-app"); + }); + + it("prefers .replicated over .replicated.yaml", async () => { + await fs.writeFile(path.join(tmpDir, ".replicated"), "appSlug: primary\n"); + await fs.writeFile(path.join(tmpDir, ".replicated.yaml"), "appSlug: secondary\n"); + + const result = findAndParseConfig(tmpDir); + expect(result!.appSlug).toBe("primary"); + }); + + it("resolves relative paths to absolute", async () => { + const subDir = path.join(tmpDir, "subdir"); + await fs.ensureDir(subDir); + const configPath = path.join(subDir, ".replicated"); + await fs.writeFile(configPath, "charts:\n - path: ../chart\nmanifests:\n - ../manifests/*.yaml\n"); + + const result = findAndParseConfig(subDir); + expect(result!.charts![0].path).toBe(path.resolve(tmpDir, "chart")); + expect(result!.manifests![0]).toBe(path.resolve(tmpDir, "manifests", "*.yaml")); + }); + + it("merges parent and child configs in monorepo walk", async () => { + const parentDir = tmpDir; + const childDir = path.join(tmpDir, "child", "grandchild"); + await fs.ensureDir(childDir); + + await fs.writeFile(path.join(parentDir, ".replicated"), "appSlug: parent-app\ncharts:\n - path: ./parent-chart\nmanifests:\n - ./parent-manifest.yaml\npromoteToChannelNames:\n - parent-channel\n"); + + await fs.writeFile(path.join(childDir, ".replicated"), "appSlug: child-app\ncharts:\n - path: ./child-chart\npromoteToChannelNames:\n - child-channel\n"); + + const result = findAndParseConfig(childDir); + expect(result).not.toBeNull(); + expect(result!.appSlug).toBe("child-app"); + expect(result!.charts).toHaveLength(2); + expect(result!.charts![0].path).toBe(path.resolve(parentDir, "parent-chart")); + expect(result!.charts![1].path).toBe(path.resolve(childDir, "child-chart")); + expect(result!.manifests).toHaveLength(1); + expect(result!.manifests![0]).toBe(path.resolve(parentDir, "parent-manifest.yaml")); + expect(result!.promoteToChannelNames).toEqual(["child-channel"]); + }); + + it("deduplicates merged resources by absolute path", async () => { + const parentDir = tmpDir; + const childDir = path.join(tmpDir, "child"); + await fs.ensureDir(childDir); + + await fs.writeFile(path.join(parentDir, ".replicated"), "charts:\n - path: ./chart\n"); + await fs.writeFile(path.join(childDir, ".replicated"), "charts:\n - path: ../chart\n"); + + const result = findAndParseConfig(childDir); + expect(result!.charts).toHaveLength(1); + }); + + it("parses a file directly when startPath is a file", async () => { + const configPath = path.join(tmpDir, "custom.replicated"); + await fs.writeFile(configPath, "appSlug: file-app\n"); + + const result = findAndParseConfig(configPath); + expect(result).not.toBeNull(); + expect(result!.appSlug).toBe("file-app"); + }); +}); + +describe("parseConfigFile", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "replicated-parse-test-")); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + it("parses valid YAML", async () => { + const configPath = path.join(tmpDir, ".replicated"); + await fs.writeFile(configPath, "appSlug: test\ncharts:\n - path: ./chart\n"); + + const result = parseConfigFile(configPath); + expect(result.appSlug).toBe("test"); + expect(result.charts).toHaveLength(1); + expect(result.charts![0].path).toBe(path.resolve(tmpDir, "chart")); + }); + + it("throws on invalid YAML", async () => { + const configPath = path.join(tmpDir, ".replicated"); + await fs.writeFile(configPath, "appSlug: [invalid"); + + expect(() => parseConfigFile(configPath)).toThrow("Failed to parse config file"); + }); + + it("throws on empty chart path", async () => { + const configPath = path.join(tmpDir, ".replicated"); + await fs.writeFile(configPath, 'charts:\n - path: ""\n'); + + expect(() => parseConfigFile(configPath)).toThrow("chart[0]: path is required"); + }); + + it("throws on empty manifest string", async () => { + const configPath = path.join(tmpDir, ".replicated"); + await fs.writeFile(configPath, 'manifests:\n - ""\n'); + + expect(() => parseConfigFile(configPath)).toThrow("manifest[0]: path cannot be empty string"); + }); + + it("throws on preflight missing chartVersion when chartName is set", async () => { + const configPath = path.join(tmpDir, ".replicated"); + await fs.writeFile(configPath, "preflights:\n - path: ./preflight.yaml\n chartName: my-chart\n"); + + expect(() => parseConfigFile(configPath)).toThrow("preflight[0]: chartVersion is required when chartName is specified"); + }); + + it("throws on preflight missing chartName when chartVersion is set", async () => { + const configPath = path.join(tmpDir, ".replicated"); + await fs.writeFile(configPath, "preflights:\n - path: ./preflight.yaml\n chartVersion: 1.0.0\n"); + + expect(() => parseConfigFile(configPath)).toThrow("preflight[0]: chartName is required when chartVersion is specified"); + }); + + it("parses repl-lint config", async () => { + const configPath = path.join(tmpDir, ".replicated"); + await fs.writeFile(configPath, "repl-lint:\n version: 1\n tools:\n helm: latest\n"); + + const result = parseConfigFile(configPath); + expect(result.replLint).toEqual({ version: 1, tools: { helm: "latest" } }); + }); + + it("throws on invalid glob pattern with unbalanced braces", async () => { + const configPath = path.join(tmpDir, ".replicated"); + await fs.writeFile(configPath, "manifests:\n - ./manifests/{a,b.yaml\n"); + + expect(() => parseConfigFile(configPath)).toThrow("invalid glob pattern"); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..c343ac2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,271 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as yaml from "yaml"; +import * as picomatch from "picomatch"; + +export interface ChartConfig { + path: string; + chartVersion?: string; + appVersion?: string; +} + +export interface PreflightConfig { + path: string; + chartName?: string; + chartVersion?: string; +} + +export interface ReplLintConfig { + version?: number; + tools?: Record; + linters?: { + helm?: { disabled?: boolean }; + }; +} + +export interface ReplicatedConfig { + appId?: string; + appSlug?: string; + charts?: ChartConfig[]; + manifests?: string[]; + preflights?: PreflightConfig[]; + promoteToChannelIds?: string[]; + promoteToChannelNames?: string[]; + releaseLabel?: string; + replLint?: ReplLintConfig; +} + +export function findAndParseConfig(startPath: string): ReplicatedConfig | null { + const absPath = path.resolve(startPath); + const stats = fs.statSync(absPath); + + if (stats.isFile()) { + return parseConfigFile(absPath); + } + + const configs: ReplicatedConfig[] = []; + let currentDir = absPath; + + while (true) { + const candidates = [path.join(currentDir, ".replicated"), path.join(currentDir, ".replicated.yaml")]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + configs.push(parseConfigFile(candidate)); + break; + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + if (configs.length === 0) { + return null; + } + + const rootToLeaf = [...configs].reverse(); + let merged: ReplicatedConfig = {}; + for (const config of rootToLeaf) { + merged = mergeConfigs(merged, config); + } + + return merged; +} + +export function parseConfigFile(filePath: string): ReplicatedConfig { + const content = fs.readFileSync(filePath, "utf-8"); + let parsed: any; + try { + parsed = yaml.parse(content); + } catch (err: any) { + throw new Error(`Failed to parse config file ${filePath}: ${err.message}`); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Invalid config file ${filePath}: expected YAML object`); + } + + const config: ReplicatedConfig = { + appId: parsed.appId, + appSlug: parsed.appSlug, + releaseLabel: parsed.releaseLabel, + charts: parsed.charts, + manifests: parsed.manifests, + preflights: parsed.preflights, + promoteToChannelIds: parsed.promoteToChannelIds, + promoteToChannelNames: parsed.promoteToChannelNames, + replLint: parsed["repl-lint"] || parsed.replLint + }; + + validateConfig(config); + resolvePaths(config, filePath); + return config; +} + +function validateConfig(config: ReplicatedConfig): void { + if (config.charts) { + if (!Array.isArray(config.charts)) { + throw new Error("Invalid config: charts must be an array"); + } + for (let i = 0; i < config.charts.length; i++) { + if (!config.charts[i] || typeof config.charts[i] !== "object") { + throw new Error(`Invalid config: chart[${i}] must be an object`); + } + if (!config.charts[i].path || typeof config.charts[i].path !== "string" || config.charts[i].path.trim() === "") { + throw new Error(`chart[${i}]: path is required`); + } + } + } + + if (config.preflights) { + if (!Array.isArray(config.preflights)) { + throw new Error("Invalid config: preflights must be an array"); + } + for (let i = 0; i < config.preflights.length; i++) { + if (!config.preflights[i] || typeof config.preflights[i] !== "object") { + throw new Error(`Invalid config: preflight[${i}] must be an object`); + } + if (!config.preflights[i].path || typeof config.preflights[i].path !== "string" || config.preflights[i].path.trim() === "") { + throw new Error(`preflight[${i}]: path is required`); + } + if (config.preflights[i].chartName && !config.preflights[i].chartVersion) { + throw new Error(`preflight[${i}]: chartVersion is required when chartName is specified`); + } + if (config.preflights[i].chartVersion && !config.preflights[i].chartName) { + throw new Error(`preflight[${i}]: chartName is required when chartVersion is specified`); + } + } + } + + if (config.manifests) { + if (!Array.isArray(config.manifests)) { + throw new Error("Invalid config: manifests must be an array"); + } + for (let i = 0; i < config.manifests.length; i++) { + if (config.manifests[i] === "") { + throw new Error(`manifest[${i}]: path cannot be empty string`); + } + if (typeof config.manifests[i] !== "string") { + throw new Error(`manifest[${i}]: must be a string`); + } + } + } + + if (config.promoteToChannelIds && !Array.isArray(config.promoteToChannelIds)) { + throw new Error("Invalid config: promoteToChannelIds must be an array"); + } + if (config.promoteToChannelNames && !Array.isArray(config.promoteToChannelNames)) { + throw new Error("Invalid config: promoteToChannelNames must be an array"); + } + + validateGlobPatterns(config); +} + +function validateGlobPatterns(config: ReplicatedConfig): void { + const paths: string[] = []; + if (config.charts) { + paths.push(...config.charts.map(c => c.path)); + } + if (config.preflights) { + paths.push(...config.preflights.map(p => p.path)); + } + if (config.manifests) { + paths.push(...config.manifests); + } + + for (const p of paths) { + if (/[*?[{]/.test(p)) { + if (!isValidGlob(p)) { + throw new Error(`invalid glob pattern: ${p}`); + } + } + } +} + +function isValidGlob(pattern: string): boolean { + let braceDepth = 0; + for (const char of pattern) { + if (char === "{") braceDepth++; + if (char === "}") braceDepth--; + if (braceDepth < 0) return false; + } + if (braceDepth !== 0) return false; + if (pattern.includes("{}")) return false; + return true; +} + +function resolvePaths(config: ReplicatedConfig, configFilePath: string): void { + const configDir = path.dirname(configFilePath); + + if (config.charts) { + for (let i = 0; i < config.charts.length; i++) { + if (!path.isAbsolute(config.charts[i].path)) { + config.charts[i].path = path.resolve(configDir, config.charts[i].path); + } + } + } + + if (config.preflights) { + for (let i = 0; i < config.preflights.length; i++) { + if (config.preflights[i].path && !path.isAbsolute(config.preflights[i].path)) { + config.preflights[i].path = path.resolve(configDir, config.preflights[i].path); + } + } + } + + if (config.manifests) { + for (let i = 0; i < config.manifests.length; i++) { + if (!path.isAbsolute(config.manifests[i])) { + config.manifests[i] = path.resolve(configDir, config.manifests[i]); + } + } + } +} + +function mergeConfigs(parent: ReplicatedConfig, child: ReplicatedConfig): ReplicatedConfig { + const merged: ReplicatedConfig = { ...parent }; + + if (child.appId !== undefined) merged.appId = child.appId; + if (child.appSlug !== undefined) merged.appSlug = child.appSlug; + if (child.releaseLabel !== undefined) merged.releaseLabel = child.releaseLabel; + if (child.replLint !== undefined) merged.replLint = child.replLint; + + if (child.promoteToChannelIds !== undefined) merged.promoteToChannelIds = child.promoteToChannelIds; + if (child.promoteToChannelNames !== undefined) merged.promoteToChannelNames = child.promoteToChannelNames; + + if (child.charts !== undefined) { + merged.charts = [...(merged.charts || []), ...child.charts]; + const seen = new Set(); + merged.charts = merged.charts.filter(chart => { + if (seen.has(chart.path)) return false; + seen.add(chart.path); + return true; + }); + } + + if (child.manifests !== undefined) { + merged.manifests = [...(merged.manifests || []), ...child.manifests]; + const seen = new Set(); + merged.manifests = merged.manifests.filter(m => { + if (seen.has(m)) return false; + seen.add(m); + return true; + }); + } + + if (child.preflights !== undefined) { + merged.preflights = [...(merged.preflights || []), ...child.preflights]; + const seen = new Set(); + merged.preflights = merged.preflights.filter(p => { + if (seen.has(p.path)) return false; + seen.add(p.path); + return true; + }); + } + + return merged; +} diff --git a/src/index.ts b/src/index.ts index 7cf7edd..364a2ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,4 @@ export { KubernetesDistribution, CustomerSummary, CreateCustomerOptions, archive export { Release, CompatibilityResult, createRelease, createReleaseFromChart, promoteRelease, reportCompatibilityResult } from "./releases"; export { VM, VMPort, VMExposedPort, createVM, getVMDetails, pollForVMStatus, removeVM, exposeVMPort } from "./vms"; export { Network, UpdateNetworkOptions, NetworkReport, NetworkEventData, NetworkReportSummary, NetworkReportSummaryDomain, NetworkReportSummaryDestination, NetworkReportSummarySource, updateNetwork, getNetworkReport, getNetworkReportSummary } from "./networks"; +export { ReplicatedConfig, ChartConfig, PreflightConfig, ReplLintConfig, findAndParseConfig, parseConfigFile } from "./config";