From 0d5c0108a7805027c034b5eb8cdf75805959f07b Mon Sep 17 00:00:00 2001 From: Koby Bass Date: Thu, 6 Mar 2025 21:22:42 +0100 Subject: [PATCH] Add argo-synth package --- .github/workflows/release.yaml | 2 +- README.md | 26 +++ config/tsconfig/lib.json | 41 ++++ docs/contribute.md | 116 +++++++++++ packages/argo-synth/README.md | 272 ++++++++++++++++++++++++++ packages/argo-synth/eslint.config.js | 39 ++++ packages/argo-synth/package.json | 70 +++++++ packages/argo-synth/src/index.ts | 51 +++++ packages/argo-synth/src/paths.test.ts | 72 +++++++ packages/argo-synth/src/paths.ts | 38 ++++ packages/argo-synth/src/synth.test.ts | 142 ++++++++++++++ packages/argo-synth/src/synth.ts | 60 ++++++ packages/argo-synth/tsconfig.json | 8 + packages/argo-synth/tsup.config.ts | 12 ++ packages/argo-synth/vitest.config.ts | 23 +++ packages/config/eslint.config.js | 25 +-- packages/config/package.json | 3 +- packages/config/tsconfig.json | 39 +--- pnpm-lock.yaml | 109 +++++++++-- 19 files changed, 1072 insertions(+), 76 deletions(-) create mode 100644 README.md create mode 100644 config/tsconfig/lib.json create mode 100644 docs/contribute.md create mode 100644 packages/argo-synth/README.md create mode 100644 packages/argo-synth/eslint.config.js create mode 100644 packages/argo-synth/package.json create mode 100644 packages/argo-synth/src/index.ts create mode 100644 packages/argo-synth/src/paths.test.ts create mode 100644 packages/argo-synth/src/paths.ts create mode 100644 packages/argo-synth/src/synth.test.ts create mode 100644 packages/argo-synth/src/synth.ts create mode 100644 packages/argo-synth/tsconfig.json create mode 100644 packages/argo-synth/tsup.config.ts create mode 100644 packages/argo-synth/vitest.config.ts diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8060af1..83b1094 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,7 +14,7 @@ on: default: '@cdklib/config' options: - '@cdklib/config' - + - '@cdklib/argo-synth' permissions: contents: write diff --git a/README.md b/README.md new file mode 100644 index 0000000..554f75a --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# CDK Libraries + +A collection of lightweight, focused libraries to enhance your AWS CDK, cdktf, and cdk8s development experience. + +## Available Libraries + +- **[@cdklib/config](./packages/config/README.md)** - Type-safe, hierarchical configuration management for CDK projects +- **[@cdklib/argo-synth](./packages/argo-synth/README.md)** - ArgoCD-friendly directory structure for CDK8s + +## Philosophy + +These libraries follow a set of principles: + +- **Simple**: Small, focused APIs that do one thing well +- **Unopinionated**: Provide tools, not constraints +- **Composable**: Work well together and with other libraries +- **Minimal Dependencies**: Only depend on what's absolutely necessary + +## Issues and Contributions + +- **Reporting Issues**: When opening an issue, please include the library name in the title (e.g., "@cdklib/config - Issue with environment inheritance") +- **Contributing**: See our [contribution guidelines](./docs/contribute.md) for details on how to contribute + +## License + +MIT diff --git a/config/tsconfig/lib.json b/config/tsconfig/lib.json new file mode 100644 index 0000000..bd88c2e --- /dev/null +++ b/config/tsconfig/lib.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "display": "Default", + "ts-node": { + "require": ["tsconfig-paths/register"] + }, + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleDetection": "force", + "moduleResolution": "bundler", + "isolatedModules": true, + "esModuleInterop": true, + "noUncheckedIndexedAccess": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noImplicitOverride": true, + "noImplicitReturns": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strictNullChecks": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "useUnknownInCatchVariables": true, + "resolveJsonModule": true, + "strictPropertyInitialization": true, + "skipLibCheck": true, + "strict": true, + "declaration": true, + "declarationMap": true, + "incremental": false + } +} diff --git a/docs/contribute.md b/docs/contribute.md new file mode 100644 index 0000000..bfe2a2a --- /dev/null +++ b/docs/contribute.md @@ -0,0 +1,116 @@ +# Contributing to CDK Libraries + +Thank you for your interest in contributing to our CDK libraries! This is a monorepo containing multiple packages under the `packages/` directory, including: + +- `@cdklib/argo-synth` - ArgoCD directory structure for CDK8s +- `@cdklib/config` - Type-safe configuration management + +This guide will help you get started with contributing to any of these packages. + +## Project Philosophy + +This library follows a few key principles: + +- **Simplicity**: Keep the API surface small and focused +- **Composability**: Work well with other libraries like `@cdklib/config` +- **Minimal Dependencies**: Only depend on what's absolutely necessary +- **Unopinionated**: Provide tools, not constraints + +## Prerequisites + +- Node.js (v20+) +- pnpm +- Basic knowledge of TypeScript and cdk8s + +## Setting Up Your Environment + +1. **Fork the repository**: + + - Visit the GitHub repository and click "Fork" + +2. **Clone your fork**: + + ```bash + git clone https://github.com/your-username/cdk-libs.git + cd cdk-libs + ``` + +3. **Install dependencies**: + ```bash + pnpm install + ``` + +## Development Workflow + +1. **Create a branch**: + + ```bash + git checkout -b feature/your-feature-name + ``` + +2. **Make your changes**: + + - Navigate to the appropriate package directory under `packages/` + - Keep changes focused on a single issue/feature + - Follow the existing code style + - Add tests for new functionality + +3. **Run tests**: + + ```bash + # Run tests for all packages + pnpm -r test + + # Or run tests for a specific package + cd packages/argo-synth + pnpm test + ``` + +4. **Commit your changes**: + - Use clear commit messages + - Reference issues when applicable + +## Pull Request Process + +1. **Push your branch**: + + ```bash + git push origin feature/your-feature-name + ``` + +2. **Create a pull request**: + + - Go to the original repository + - Click "New Pull Request" + - Select your branch + - Provide a clear title starting with the package name (e.g., "@cdklib/argo-synth - Add new feature") + +3. **Respond to feedback**: + - Be open to suggestions + - Make requested changes promptly + +## Coding Guidelines + +- Write clean, readable TypeScript +- Document public APIs with JSDoc comments (hover over your function to see the comment) +- Keep functions small and focused +- Follow the Keep It Simple principle +- Add proper tests for new functionality + +## Testing + +We use Vitest for testing. Tests should: + +- Be located alongside the source files +- Follow the pattern `*.test.ts` +- Use descriptive test names +- Test both success and failure cases + +## Need Help? + +If you have questions or need clarification: + +- Open an issue on GitHub with your question +- Add the "question" label and specify which package you're asking about + +Thank you for contributing to make our CDK libraries better! diff --git a/packages/argo-synth/README.md b/packages/argo-synth/README.md new file mode 100644 index 0000000..f16d008 --- /dev/null +++ b/packages/argo-synth/README.md @@ -0,0 +1,272 @@ +# @cdklib/argo-synth + +- [Table of Contents](#table-of-contents) + +A directory structure management library for cdk8s projects that enables GitOps-friendly output organization for ArgoCD. + +## Why This Exists + +cdk8s is a great way to build Kubernetes applications. However, cdk8s's default synthesis behaviors are not argo-friendly. + +ArgoCD works best with a directory structure that organizes Kubernetes resources by environment and application. + +`@cdklib/argo-synth` provides a simple way to organize your cdk8s synthesized resources into a clean directory structure that's optimal for ArgoCD's path-based applications. + +### Key Features + +- **Simple** - Offers a clear path-based organization approach for cdk8s projects +- **ArgoCD-Optimized** - Creates a directory structure that maps perfectly to ArgoCD application paths +- **Path Inheritance** - Supports path building for complex directory structures +- **Multi-Environment** - Easily manage multiple environments in a single GitOps repository + +# Table of Contents + +- [Installation](#installation) +- [Basic Usage](#basic-usage) +- [Integrated Usage](#integrated-usage) +- [Directory Structure](#directory-structure) +- [Path Management](#path-management) +- [Integration with @cdklib/config](#integration-with-cdklibconfig) +- [Best Practices](#best-practices) +- [License](#license) + +## Installation + +```bash +# Using npm +npm install @cdklib/argo-synth + +# Using yarn +yarn add @cdklib/argo-synth + +# Using pnpm +pnpm add @cdklib/argo-synth +``` + +## Key Features + +- 🗂️ **Structured Output**: Organize Kubernetes manifests in a directory structure that mirrors your environments and applications +- 🚀 **GitOps Ready**: Generate files in a format optimal for ArgoCD and GitOps workflows +- 🔄 **Multi-Environment Support**: Easily manage multiple environments in a single repository +- 🔄 **App Synthesis**: Synthesize multiple cdk8s apps seamlessly +- 👀 **Visibility**: Clearly understand what changed by keeping generated kube manifests in a separate directory + +## Basic Usage + +```typescript +import { App, Chart } from 'cdk8s' +import { ArgoSynth } from '@cdklib/argo-synth' + +// Create an app for each environment +const stagingApp = new App() +const prodApp = new App() + +// Create charts for your services +const stagingWebChart = new Chart(stagingApp, 'web') +const prodWebChart = new Chart(prodApp, 'web') + +// Set paths for ArgoCD directory structure +ArgoSynth.addPath(stagingWebChart, 'staging', 'web') +ArgoSynth.addPath(prodWebChart, 'prod', 'web') + +// Synthesize to output directory +await ArgoSynth.synth('gitops', [stagingApp, prodApp]) +``` + +This creates a directory structure like: + +``` +gitops/ +├── staging/ +│ └── web/ +│ └── ... (manifests) +└── prod/ + └── web/ + └── ... (manifests) +``` + +Which maps cleanly to ArgoCD applications targeting paths like: + +- `staging/web` +- `prod/web` + +## Integrated Usage + +A recommended approach is to create base classes that automatically handle path management: + +```typescript +import { App, Chart } from 'cdk8s' +import { Construct } from 'constructs' +import { CdkArgo } from '@cdklib/argo-synth' + +// Create a base App class that handles environment path setup +class BaseApp extends App { + constructor(envId: EnvId, props?: AppProps) { + super(props) + // The environment ID becomes the first path segment + ArgoSynth.addPath(this, envId) + } +} + +// Create a base Chart class that automatically adds service paths +class BaseChart extends Chart { + constructor(scope: Construct, id: string) { + super(scope, id) + // The chart ID becomes the service name in the path + ArgoSynth.addPath(this, id) + } +} + +// Usage: +const stagingApp = new BaseApp('staging') +const prodApp = new BaseApp('prod') + +// Service charts automatically get the correct paths +const stagingWebChart = new BaseChart(stagingApp, 'web') +const prodWebChart = new BaseChart(prodApp, 'web') + +// Synthesize to output directory +await ArgoSynth.synth('gitops', [stagingApp, prodApp]) +``` + +## Integration with @cdklib/config + +The library works seamlessly with `@cdklib/config` for type-safe environment management: + +```typescript +import { App, Chart, ApiObject } from 'cdk8s' +import { ArgoSynth } from '@cdklib/argo-synth' +import { CdkConfig, setEnvContext, EnvId } from '@cdklib/config' +import { z } from 'zod' + +const webConfig = new CdkConfig(k8sSchema) + .set('staging', { + replicas: 2, + namespace: 'staging', + image: 'web-app:latest', + }) + .set('prod', { + replicas: 5, + namespace: 'production', + image: 'web-app:stable', + }) + +// Create a base App class that uses environment IDs from config +class EnvApp extends App { + constructor( + readonly envId: EnvId, + props?: AppProps, + ) { + super(props) + // Set the environment context for @cdklib/config integration + setEnvContext(this, envId) + // Set the path for ArgoCD structure + ArgoSynth.addPath(this, envId) + } +} + +// Usage +const stagingApp = new EnvApp('staging') +const prodApp = new EnvApp('prod') + +// Create charts for specific services with built-in config handling +class WebChart extends Chart { + constructor(scope: Construct) { + super(scope, 'web') + // Set the path for ArgoCD structure + ArgoSynth.addPath(this, 'web') + + // Access config directly using this construct + const { replicas, image } = webConfig.get(this) + + // Create resources using the environment-specific config + new ApiObject(this, 'deployment', { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: 'web' }, + spec: { + replicas: replicas, + template: { + spec: { + containers: [ + { + name: 'web', + image: image, + }, + ], + }, + }, + // ... other properties + }, + }) + } +} + +// Usage +const stagingApp = new EnvApp('staging') +const prodApp = new EnvApp('prod') + +// Create service charts - configuration is handled internally +const stagingWebChart = new WebChart(stagingApp) +const prodWebChart = new WebChart(prodApp) + +// Synthesize +await ArgoSynth.synth('gitops', [stagingApp, prodApp]) +``` + +## Best Practices + +1. **Environment Base Classes**: Create a base App class that handles environment paths: + + ```typescript + class EnvApp extends App { + constructor(envId: EnvId, props?: AppProps) { + super(props) + // Set up both ArgoCD paths and @cdklib/config context + setEnvContext(this, envId) + ArgoSynth.addPath(this, envId) + } + } + ``` + +2. **Service Base Classes**: Create a base Chart class for services: + + ```typescript + class ServiceChart extends Chart { + constructor(scope: Construct, id: string) { + super(scope, id) + ArgoSynth.addPath(this, id) + } + } + ``` + +3. **ArgoCD Application Structure**: Design your ArgoCD applications to match your path structure: + + ```yaml + # staging-web.yaml + apiVersion: argoproj.io/v1alpha1 + kind: Application + metadata: + name: staging-web + namespace: argocd + spec: + project: default + source: + repoURL: https://github.com/your-org/your-gitops-repo.git + targetRevision: main + path: gitops/staging/web + destination: + server: https://kubernetes.default.svc + namespace: staging + ``` + +4. **Integration with Config**: Leverage `@cdklib/config` for type-safe environment configuration management: + + ```typescript + // In your construct code + const { replicas, image } = webConfig.get(scope) + ``` + +## License + +MIT diff --git a/packages/argo-synth/eslint.config.js b/packages/argo-synth/eslint.config.js new file mode 100644 index 0000000..0fa90df --- /dev/null +++ b/packages/argo-synth/eslint.config.js @@ -0,0 +1,39 @@ +import js from '@eslint/js' +import prettier from 'eslint-plugin-prettier' +import prettierConfig from 'eslint-config-prettier' +import tsParser from '@typescript-eslint/parser' +import typescript from '@typescript-eslint/eslint-plugin' +import globals from 'globals' + +/** @type {import("eslint").Linter.Config} */ +const config = [ + { + ignores: ['node_modules/*', 'dist/*'], + }, + js.configs.recommended, + prettierConfig, + { + plugins: { '@typescript-eslint': typescript, prettier }, + languageOptions: { + parser: tsParser, + globals: { + ...globals.node, + }, + }, + files: ['src/**/*.ts', 'src/**/*.js'], + rules: { + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + disallowTypeAnnotations: true, + fixStyle: 'inline-type-imports', + }, + ], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error'], + }, + }, +] + +export default config diff --git a/packages/argo-synth/package.json b/packages/argo-synth/package.json new file mode 100644 index 0000000..d229482 --- /dev/null +++ b/packages/argo-synth/package.json @@ -0,0 +1,70 @@ +{ + "name": "@cdklib/argocd-synth", + "version": "0.0.0", + "description": "Manage ArgoCD structure with cdk8s", + "main": "./dist/index.cjs", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "test": "vitest", + "test:coverage": "vitest --coverage", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "version": "pnpm version", + "publish": "pnpm build && pnpm publish --ignore-scripts --no-git-checks --access public" + }, + "keywords": [ + "kubernetes", + "k8s", + "cdk8s", + "argocd" + ], + "author": { + "name": "Koby Bass", + "url": "https://github.com/kobybum" + }, + "license": "MIT", + "devDependencies": { + "@eslint/js": "^9.21.0", + "@types/node": "^22.13.9", + "@typescript-eslint/eslint-plugin": "^8.26.0", + "@typescript-eslint/parser": "^8.26.0", + "@vitest/coverage-v8": "3.0.7", + "eslint": "^9.21.0", + "eslint-config-prettier": "^10.0.2", + "eslint-plugin-prettier": "^5.2.3", + "globals": "^16.0.0", + "tsup": "^8.4.0", + "type-fest": "^4.37.0", + "typescript": "^5.8.2", + "vitest": "^3.0.7", + "yaml": "^2.7.0" + }, + "dependencies": { + "cdk8s": "^2.0.0", + "constructs": "^10.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/kobybum/cdk-libs.git", + "directory": "packages/config" + }, + "bugs": { + "url": "https://github.com/kobybum/cdk-libs/issues" + }, + "homepage": "https://github.com/kobybum/cdk-libs", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + } +} diff --git a/packages/argo-synth/src/index.ts b/packages/argo-synth/src/index.ts new file mode 100644 index 0000000..cee6dc9 --- /dev/null +++ b/packages/argo-synth/src/index.ts @@ -0,0 +1,51 @@ +import { addSynthPath, getSynthPath } from './paths' +import { synthApps } from './synth' + +/** + * ArgoSynth provides utilities for organizing and synthesizing CDK8s apps into + * directory structures that work well with ArgoCD's path-based application definitions. + * + * @example + * ```typescript + * import { App, Chart } from 'cdk8s'; + * import { ArgoSynth } from '@cdklib/argo-synth'; + * + * // Create an app for each environment + * const stagingApp = new App(); + * const prodApp = new App(); + * + * // Create charts for your services + * const stagingWebChart = new Chart(stagingApp, 'web'); + * const prodWebChart = new Chart(prodApp, 'web'); + * + * // Set paths for ArgoCD directory structure + * ArgoSynth.addPath(stagingWebChart, 'staging', 'web'); + * ArgoSynth.addPath(prodWebChart, 'prod', 'web'); + * + * // Synthesize to output directory + * await ArgoSynth.synth('gitops', [stagingApp, prodApp]); + * ``` + * + * This creates a directory structure like: + * ``` + * gitops/ + * ├── staging/ + * │ └── web/ + * │ └── ... (manifests) + * └── prod/ + * └── web/ + * └── ... (manifests) + * ``` + * + * Which maps cleanly to ArgoCD applications targeting paths like: + * - `staging/web` + * - `prod/web` + */ +export class ArgoSynth { + /** Synthesizes multiple applications with regards to ArgoCD paths */ + static synth = synthApps + /** Adds a suffix to the synthesized path for ArgoCD apps */ + static addPath = addSynthPath + /** Gets the synth path for the cdk8s App / Chart */ + static getPath = getSynthPath +} diff --git a/packages/argo-synth/src/paths.test.ts b/packages/argo-synth/src/paths.test.ts new file mode 100644 index 0000000..3c49704 --- /dev/null +++ b/packages/argo-synth/src/paths.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { addSynthPath, getSynthPath } from './paths' +import { Construct } from 'constructs' +import { App } from 'cdk8s' + +// Create actual construct instances since we're testing real behavior +class TestConstruct extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id) + } +} + +describe('synth-path', () => { + let app: App + + beforeEach(() => { + app = new App() + }) + + it('should set and get a path', () => { + const construct = new TestConstruct(app, 'test1') + + addSynthPath(construct, 'staging') + + expect(getSynthPath(construct)).toBe('staging') + }) + + it('should set and get multiple path segments', () => { + const construct = new TestConstruct(app, 'test2') + + addSynthPath(construct, 'staging', 'web-api') + + expect(getSynthPath(construct)).toBe('staging/web-api') + }) + + it('should keep paths independent between constructs', () => { + const construct1 = new TestConstruct(app, 'construct1') + const construct2 = new TestConstruct(app, 'construct2') + + addSynthPath(construct1, 'staging') + addSynthPath(construct2, 'prod') + + expect(getSynthPath(construct1)).toBe('staging') + expect(getSynthPath(construct2)).toBe('prod') + }) + + it('should append to existing paths', () => { + const construct = new TestConstruct(app, 'test3') + + addSynthPath(construct, 'staging') + expect(getSynthPath(construct)).toBe('staging') + + addSynthPath(construct, 'web-api') + expect(getSynthPath(construct)).toBe('staging/web-api') + }) + + it('should work with parent-child constructs', () => { + const parent = new TestConstruct(app, 'parent') + addSynthPath(parent, 'staging') + + const child = new TestConstruct(parent, 'child') + addSynthPath(child, 'web-api') + + expect(getSynthPath(parent)).toBe('staging') + expect(getSynthPath(child)).toBe('staging/web-api') + }) + + it('should throw when no path is set', () => { + const construct = new TestConstruct(app, 'test4') + expect(() => getSynthPath(construct)).toThrow(/No synth path found/) + }) +}) diff --git a/packages/argo-synth/src/paths.ts b/packages/argo-synth/src/paths.ts new file mode 100644 index 0000000..8baf226 --- /dev/null +++ b/packages/argo-synth/src/paths.ts @@ -0,0 +1,38 @@ +import { type Construct } from 'constructs' +import path from 'path' + +const SYNTH_PATH_KEY = '@cdklib/argocd/synthPath' + +/** + * Adds a suffix to the synthesized path + * + * Use this to structure your environments / apps for ArgoCD + */ +export const addSynthPath = (scope: Construct, ...suffixes: string[]) => { + let currentPath: string = '' + try { + currentPath = getSynthPath(scope) + } catch { + // no-op + } + + const basePath = currentPath ? [currentPath] : [] + + scope.node.setContext(SYNTH_PATH_KEY, path.join(...basePath, ...suffixes)) +} + +/** + * Gets the synth path for the cdk8s App / Chart + * + * Uses context to store the path (addSynthPath) + */ +export const getSynthPath = (scope: Construct): string => { + try { + return scope.node.getContext(SYNTH_PATH_KEY) + } catch (e) { + if (e instanceof Error && e.message.includes('No context value')) { + throw new Error('No synth path found - call addSynthPath first') + } + throw e + } +} diff --git a/packages/argo-synth/src/synth.test.ts b/packages/argo-synth/src/synth.test.ts new file mode 100644 index 0000000..4768608 --- /dev/null +++ b/packages/argo-synth/src/synth.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { App, Chart, ApiObject } from 'cdk8s' +import * as fs from 'fs/promises' +import * as path from 'path' +import { existsSync } from 'fs' +import { addSynthPath } from './paths' +import * as YAML from 'yaml' +import { synthApp, synthApps } from './synth' +import * as os from 'os' + +describe('synth functionality', () => { + let tempDir: string + + // Simple deployment definition + const simpleDeployment = { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: 'deployment' }, + spec: { replicas: 1 }, + } + + // Simple service definition + const simpleService = { + apiVersion: 'v1', + kind: 'Service', + metadata: { name: 'service' }, + spec: { ports: [{ port: 80 }] }, + } + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `cdk8s-test-${Date.now()}`) + await fs.mkdir(tempDir, { recursive: true }) + }) + + afterEach(async () => { + if (existsSync(tempDir)) { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + it('should synthesize ApiObjects to the correct paths with proper YAML content', async () => { + // Create app and chart + const app = new App() + const chart = new Chart(app, 'chart') + + // Add synth path to chart + addSynthPath(chart, 'staging', 'web-api') + + // Create API objects + new ApiObject(chart, 'deployment', simpleDeployment) + new ApiObject(chart, 'service', simpleService) + + // Synthesize + await synthApp(tempDir, app) + + // Verify files exist + const deploymentPath = path.join( + tempDir, + 'staging', + 'web-api', + 'Deployment.deployment.yaml', + ) + const servicePath = path.join(tempDir, 'staging', 'web-api', 'Service.service.yaml') + + expect(existsSync(deploymentPath)).toBe(true) + expect(existsSync(servicePath)).toBe(true) + + // Verify YAML content + const deploymentContent = await fs.readFile(deploymentPath, 'utf8') + const serviceContent = await fs.readFile(servicePath, 'utf8') + + // Parse YAML to verify structure + const deploymentYaml = YAML.parse(deploymentContent) + const serviceYaml = YAML.parse(serviceContent) + + // Expected YAML structures + const expectedDeployment = { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: 'deployment' }, + spec: { replicas: 1 }, + } + + const expectedService = { + apiVersion: 'v1', + kind: 'Service', + metadata: { name: 'service' }, + spec: { ports: [{ port: 80 }] }, + } + + // Compare parsed YAML with expected structure + expect(deploymentYaml).toEqual(expect.objectContaining(expectedDeployment)) + expect(serviceYaml).toEqual(expect.objectContaining(expectedService)) + }) + + it('should synthesize multiple charts with different paths', async () => { + const app = new App() + + // Create charts with different paths + const stagingChart = new Chart(app, 'staging-chart') + addSynthPath(stagingChart, 'staging', 'api') + new ApiObject(stagingChart, 'deployment', simpleDeployment) + + const prodChart = new Chart(app, 'prod-chart') + addSynthPath(prodChart, 'prod', 'api') + new ApiObject(prodChart, 'deployment', simpleDeployment) + + // Synthesize + await synthApp(tempDir, app) + + // Verify files exist + const stagingPath = path.join(tempDir, 'staging', 'api', 'Deployment.deployment.yaml') + const prodPath = path.join(tempDir, 'prod', 'api', 'Deployment.deployment.yaml') + + expect(existsSync(stagingPath)).toBe(true) + expect(existsSync(prodPath)).toBe(true) + }) + + it('should synthesize multiple apps with synthApps function', async () => { + // Create app1 + const app1 = new App() + const chart1 = new Chart(app1, 'chart1') + addSynthPath(chart1, 'app1', 'service') + new ApiObject(chart1, 'deployment', simpleDeployment) + + // Create app2 + const app2 = new App() + const chart2 = new Chart(app2, 'chart2') + addSynthPath(chart2, 'app2', 'service') + new ApiObject(chart2, 'deployment', simpleDeployment) + + // Synthesize both apps + await synthApps(tempDir, [app1, app2]) + + // Verify files exist + const app1Path = path.join(tempDir, 'app1', 'service', 'Deployment.deployment.yaml') + const app2Path = path.join(tempDir, 'app2', 'service', 'Deployment.deployment.yaml') + + expect(existsSync(app1Path)).toBe(true) + expect(existsSync(app2Path)).toBe(true) + }) +}) diff --git a/packages/argo-synth/src/synth.ts b/packages/argo-synth/src/synth.ts new file mode 100644 index 0000000..b4d06db --- /dev/null +++ b/packages/argo-synth/src/synth.ts @@ -0,0 +1,60 @@ +import { ApiObject, type App, Yaml } from 'cdk8s' +import { type Construct } from 'constructs' +import { mkdir, writeFile } from 'fs/promises' +import path from 'path' +import { getSynthPath } from './paths' + +/** Returns all API objects and cdk8s-plus resources */ +const getApiObjects = (scope: Construct) => + scope.node + .findAll() + .map((s) => { + if (s instanceof ApiObject) return s + + /** Handle cdk8s-plus resources */ + const apiObject = (s as unknown as { apiObject: ApiObject }).apiObject + if (apiObject) return apiObject + + return undefined + }) + .filter(Boolean) as ApiObject[] + +/** Synthesize all API object to the relevant path */ +const synthApiObjects = async (outputPath: string, scope: Construct) => { + const apiObjects = getApiObjects(scope) + if (!apiObjects.length) return + + const synthPath = path.join(outputPath, getSynthPath(apiObjects[0]!)) + await mkdir(synthPath, { recursive: true }) + + // Render chart resources under templates/ directory + await Promise.all( + apiObjects.map((o) => + writeFile(path.join(synthPath, `${o.kind}.${o.name}.yaml`), Yaml.stringify(o.toJson())), + ), + ) +} + +/** + * Synthesizes an application with regards to ArgoCD paths. + * + * @param outputPath - The base path to output the synthesized resources + * @param app - The application to synthesize + */ +export const synthApp = async (outputPath: string, app: App) => { + const appSynthPromises = app.charts.map(async (app) => { + await synthApiObjects(outputPath, app) + }) + + await Promise.all(appSynthPromises) +} + +/** + * Synthesizes all applications with regards to ArgoCD paths. + * + * @param outputPath - The base path to output the synthesized resources + * @param apps - The applications to synthesize + */ +export const synthApps = async (outputPath: string, apps: App[]) => { + await Promise.all(apps.map((app) => synthApp(outputPath, app))) +} diff --git a/packages/argo-synth/tsconfig.json b/packages/argo-synth/tsconfig.json new file mode 100644 index 0000000..b918436 --- /dev/null +++ b/packages/argo-synth/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../config/tsconfig/lib.json", + "include": ["src/**/*.ts", "vitest.config.ts", "tsup.config.ts", "dev/overrides.d.ts"], + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/packages/argo-synth/tsup.config.ts b/packages/argo-synth/tsup.config.ts new file mode 100644 index 0000000..ba0e892 --- /dev/null +++ b/packages/argo-synth/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + splitting: true, + sourcemap: true, + clean: true, + treeshake: true, + external: ['cdk8s', 'constructs'], +}) diff --git a/packages/argo-synth/vitest.config.ts b/packages/argo-synth/vitest.config.ts new file mode 100644 index 0000000..c6652c3 --- /dev/null +++ b/packages/argo-synth/vitest.config.ts @@ -0,0 +1,23 @@ +/// +import { defineConfig } from 'vitest/config' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +export default defineConfig({ + plugins: [], + test: { + environment: 'node', + globals: true, + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + include: ['src/**/*.ts'], + exclude: ['src/index.ts'], + }, + }, + resolve: { + alias: { + '@': resolve(dirname(fileURLToPath(import.meta.url)), './src'), + }, + }, +}) diff --git a/packages/config/eslint.config.js b/packages/config/eslint.config.js index 26cd7ad..4bb56c3 100644 --- a/packages/config/eslint.config.js +++ b/packages/config/eslint.config.js @@ -1,39 +1,26 @@ import js from '@eslint/js' -import prettier from 'eslint-plugin-prettier' // Prettier plugin -import prettierConfig from 'eslint-config-prettier' // Prettier config +import prettier from 'eslint-plugin-prettier' +import prettierConfig from 'eslint-config-prettier' import tsParser from '@typescript-eslint/parser' import typescript from '@typescript-eslint/eslint-plugin' import globals from 'globals' -// const project = resolve(process.cwd(), 'tsconfig.json') - /** @type {import("eslint").Linter.Config} */ const config = [ + { + ignores: ['node_modules/*', 'dist/*'], + }, js.configs.recommended, prettierConfig, { plugins: { '@typescript-eslint': typescript, prettier }, - // settings: { - // 'import/resolver': { - // typescript: { - // project, - // }, - // }, - // }, languageOptions: { parser: tsParser, globals: { ...globals.node, }, }, - ignores: [ - // Ignore dotfiles - '.*.js', - '*.js', - 'node_modules/', - 'dist/', - ], - files: ['src/**/*.ts', 'src/**/*.js'], + files: ['**/*.ts', '**/*.js'], rules: { '@typescript-eslint/consistent-type-imports': [ 'error', diff --git a/packages/config/package.json b/packages/config/package.json index 08f8db2..df5143e 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -8,8 +8,7 @@ ".": { "import": "./dist/index.js", "require": "./dist/index.cjs", - "types": "./dist/index.d.ts", - "default": "./dist/index.cjs" + "types": "./dist/index.d.ts" } }, "files": [ diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json index cee66ca..b918436 100644 --- a/packages/config/tsconfig.json +++ b/packages/config/tsconfig.json @@ -1,43 +1,8 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "display": "Default", - "ts-node": { - "require": ["tsconfig-paths/register"] - }, + "extends": "../../config/tsconfig/lib.json", "include": ["src/**/*.ts", "vitest.config.ts", "tsup.config.ts", "dev/overrides.d.ts"], "compilerOptions": { - "baseUrl": ".", - "target": "ES2022", - "module": "ES2022", - "lib": ["ES2022"], - "moduleDetection": "force", - "moduleResolution": "bundler", - "isolatedModules": true, - "esModuleInterop": true, - "noUncheckedIndexedAccess": true, - "noImplicitAny": true, - "noImplicitThis": true, - "noImplicitOverride": true, - "noImplicitReturns": false, - "noUnusedLocals": true, - "noUnusedParameters": true, - "strictNullChecks": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "useUnknownInCatchVariables": true, - "resolveJsonModule": true, - "strictPropertyInitialization": true, - "skipLibCheck": true, - "strict": true, - "declaration": true, - "declarationMap": true, - "incremental": false + "baseUrl": "." } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b32e234..0969f99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,58 @@ importers: specifier: ^0.0.1 version: 0.0.1 + packages/argo-synth: + dependencies: + cdk8s: + specifier: ^2.0.0 + version: 2.69.48(constructs@10.4.2) + constructs: + specifier: ^10.0.0 + version: 10.4.2 + devDependencies: + '@eslint/js': + specifier: ^9.21.0 + version: 9.21.0 + '@types/node': + specifier: ^22.13.9 + version: 22.13.9 + '@typescript-eslint/eslint-plugin': + specifier: ^8.26.0 + version: 8.26.0(@typescript-eslint/parser@8.26.0(eslint@9.21.0)(typescript@5.8.2))(eslint@9.21.0)(typescript@5.8.2) + '@typescript-eslint/parser': + specifier: ^8.26.0 + version: 8.26.0(eslint@9.21.0)(typescript@5.8.2) + '@vitest/coverage-v8': + specifier: 3.0.7 + version: 3.0.7(vitest@3.0.7(@types/node@22.13.9)(yaml@2.7.0)) + eslint: + specifier: ^9.21.0 + version: 9.21.0 + eslint-config-prettier: + specifier: ^10.0.2 + version: 10.0.2(eslint@9.21.0) + eslint-plugin-prettier: + specifier: ^5.2.3 + version: 5.2.3(eslint-config-prettier@10.0.2(eslint@9.21.0))(eslint@9.21.0)(prettier@3.5.3) + globals: + specifier: ^16.0.0 + version: 16.0.0 + tsup: + specifier: ^8.4.0 + version: 8.4.0(postcss@8.5.3)(typescript@5.8.2)(yaml@2.7.0) + type-fest: + specifier: ^4.37.0 + version: 4.37.0 + typescript: + specifier: ^5.8.2 + version: 5.8.2 + vitest: + specifier: ^3.0.7 + version: 3.0.7(@types/node@22.13.9)(yaml@2.7.0) + yaml: + specifier: ^2.7.0 + version: 2.7.0 + packages/config: dependencies: constructs: @@ -41,7 +93,7 @@ importers: version: 8.26.0(eslint@9.21.0)(typescript@5.8.2) '@vitest/coverage-v8': specifier: 3.0.7 - version: 3.0.7(vitest@3.0.7(@types/node@22.13.9)) + version: 3.0.7(vitest@3.0.7(@types/node@22.13.9)(yaml@2.7.0)) eslint: specifier: ^9.21.0 version: 9.21.0 @@ -56,7 +108,7 @@ importers: version: 16.0.0 tsup: specifier: ^8.4.0 - version: 8.4.0(postcss@8.5.3)(typescript@5.8.2) + version: 8.4.0(postcss@8.5.3)(typescript@5.8.2)(yaml@2.7.0) type-fest: specifier: ^4.37.0 version: 4.37.0 @@ -65,7 +117,7 @@ importers: version: 5.8.2 vitest: specifier: ^3.0.7 - version: 3.0.7(@types/node@22.13.9) + version: 3.0.7(@types/node@22.13.9)(yaml@2.7.0) packages: @@ -599,6 +651,16 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + cdk8s@2.69.48: + resolution: {integrity: sha512-kp/vwn6i4Xt/rK54xy4dSAk6MFcV6uVqHV4rOvHoBrOKuMV7NpdMxwqK+82vxey6HWa4brSbtnjN7AEoIpCKdw==} + engines: {node: '>= 16.20.0'} + peerDependencies: + constructs: ^10 + bundledDependencies: + - fast-json-patch + - follow-redirects + - yaml + chai@5.2.0: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} @@ -1397,6 +1459,11 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + yaml@2.7.0: + resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} + engines: {node: '>= 14'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1743,7 +1810,7 @@ snapshots: '@typescript-eslint/types': 8.26.0 eslint-visitor-keys: 4.2.0 - '@vitest/coverage-v8@3.0.7(vitest@3.0.7(@types/node@22.13.9))': + '@vitest/coverage-v8@3.0.7(vitest@3.0.7(@types/node@22.13.9)(yaml@2.7.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -1757,7 +1824,7 @@ snapshots: std-env: 3.8.1 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.0.7(@types/node@22.13.9) + vitest: 3.0.7(@types/node@22.13.9)(yaml@2.7.0) transitivePeerDependencies: - supports-color @@ -1768,13 +1835,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.7(vite@6.2.0(@types/node@22.13.9))': + '@vitest/mocker@3.0.7(vite@6.2.0(@types/node@22.13.9)(yaml@2.7.0))': dependencies: '@vitest/spy': 3.0.7 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.2.0(@types/node@22.13.9) + vite: 6.2.0(@types/node@22.13.9)(yaml@2.7.0) '@vitest/pretty-format@3.0.7': dependencies: @@ -1854,6 +1921,10 @@ snapshots: callsites@3.1.0: {} + cdk8s@2.69.48(constructs@10.4.2): + dependencies: + constructs: 10.4.2 + chai@5.2.0: dependencies: assertion-error: 2.0.1 @@ -2279,11 +2350,12 @@ snapshots: pirates@4.0.6: {} - postcss-load-config@6.0.1(postcss@8.5.3): + postcss-load-config@6.0.1(postcss@8.5.3)(yaml@2.7.0): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.3 + yaml: 2.7.0 postcss@8.5.3: dependencies: @@ -2450,7 +2522,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.4.0(postcss@8.5.3)(typescript@5.8.2): + tsup@8.4.0(postcss@8.5.3)(typescript@5.8.2)(yaml@2.7.0): dependencies: bundle-require: 5.1.0(esbuild@0.25.0) cac: 6.7.14 @@ -2460,7 +2532,7 @@ snapshots: esbuild: 0.25.0 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.3) + postcss-load-config: 6.0.1(postcss@8.5.3)(yaml@2.7.0) resolve-from: 5.0.0 rollup: 4.34.9 source-map: 0.8.0-beta.0 @@ -2520,13 +2592,13 @@ snapshots: dependencies: punycode: 2.3.1 - vite-node@3.0.7(@types/node@22.13.9): + vite-node@3.0.7(@types/node@22.13.9)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 2.0.3 - vite: 6.2.0(@types/node@22.13.9) + vite: 6.2.0(@types/node@22.13.9)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -2541,7 +2613,7 @@ snapshots: - tsx - yaml - vite@6.2.0(@types/node@22.13.9): + vite@6.2.0(@types/node@22.13.9)(yaml@2.7.0): dependencies: esbuild: 0.25.0 postcss: 8.5.3 @@ -2549,11 +2621,12 @@ snapshots: optionalDependencies: '@types/node': 22.13.9 fsevents: 2.3.3 + yaml: 2.7.0 - vitest@3.0.7(@types/node@22.13.9): + vitest@3.0.7(@types/node@22.13.9)(yaml@2.7.0): dependencies: '@vitest/expect': 3.0.7 - '@vitest/mocker': 3.0.7(vite@6.2.0(@types/node@22.13.9)) + '@vitest/mocker': 3.0.7(vite@6.2.0(@types/node@22.13.9)(yaml@2.7.0)) '@vitest/pretty-format': 3.0.7 '@vitest/runner': 3.0.7 '@vitest/snapshot': 3.0.7 @@ -2569,8 +2642,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.2.0(@types/node@22.13.9) - vite-node: 3.0.7(@types/node@22.13.9) + vite: 6.2.0(@types/node@22.13.9)(yaml@2.7.0) + vite-node: 3.0.7(@types/node@22.13.9)(yaml@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.13.9 @@ -2619,6 +2692,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + yaml@2.7.0: {} + yocto-queue@0.1.0: {} zod@3.24.2: {}