timonel 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,44 @@
1
+ /**
2
+ * @fileoverview Rutter - Main class for building Kubernetes manifests and Helm charts
3
+ * @since 1.0.0
4
+ */
1
5
  import { App, Chart, Testing, ApiObject } from 'cdk8s';
2
6
  import YAML from 'yaml';
3
7
  import { include, helm } from './helm.js';
4
8
  import { HelmChartWriter } from './HelmChartWriter.js';
9
+ import { SecurityUtils } from './security.js';
5
10
  /**
6
- * Rutter builds Kubernetes manifests using cdk8s and writes a Helm chart.
11
+ * Rutter - Main class for building Kubernetes manifests and Helm charts
12
+ *
13
+ * Rutter (maritime pilot) guides the generation of Kubernetes resources
14
+ * using cdk8s and outputs complete Helm charts with proper templating.
15
+ *
16
+ * @class Rutter
17
+ * @since 1.0.0
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * const rutter = new Rutter({
22
+ * meta: { name: 'my-app', version: '1.0.0' },
23
+ * defaultValues: { replicas: 3 }
24
+ * });
25
+ *
26
+ * rutter.addDeployment({
27
+ * name: 'web',
28
+ * image: 'nginx:1.21',
29
+ * replicas: 3
30
+ * });
31
+ *
32
+ * rutter.write('./charts/my-app');
33
+ * ```
7
34
  */
8
35
  export class Rutter {
36
+ /**
37
+ * Creates a new Rutter instance
38
+ *
39
+ * @param {RutterProps} props - Configuration properties
40
+ * @since 1.0.0
41
+ */
9
42
  constructor(props) {
10
43
  this.assets = [];
11
44
  this.valueOverrides = {};
@@ -14,7 +47,21 @@ export class Rutter {
14
47
  this.chart = new Chart(this.app, props.manifestName ?? props.meta.name);
15
48
  }
16
49
  /**
17
- * Set dynamic value overrides (from --set flags)
50
+ * Sets dynamic value overrides for Helm values
51
+ *
52
+ * Used by CLI --set flags to override default values at runtime.
53
+ *
54
+ * @param {Record<string, string>} overrides - Key-value pairs to override
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * rutter.setValues({
59
+ * 'image.tag': 'v2.0.0',
60
+ * 'replicas': '5'
61
+ * });
62
+ * ```
63
+ *
64
+ * @since 1.0.0
18
65
  */
19
66
  setValues(overrides) {
20
67
  this.valueOverrides = { ...this.valueOverrides, ...overrides };
@@ -55,11 +102,32 @@ export class Rutter {
55
102
  try {
56
103
  return JSON.parse(value);
57
104
  }
58
- catch {
105
+ catch (error) {
106
+ // Log parsing error for debugging purposes
107
+ console.debug(`Failed to parse value as JSON: ${value}`, error);
59
108
  // If not valid JSON, treat as string
60
109
  return value;
61
110
  }
62
111
  }
112
+ /**
113
+ * Adds a Kubernetes Deployment to the chart
114
+ *
115
+ * @param {DeploymentSpec} spec - Deployment specification
116
+ * @returns {ApiObject} The created Deployment object
117
+ *
118
+ * @example
119
+ * ```typescript
120
+ * rutter.addDeployment({
121
+ * name: 'web-app',
122
+ * image: 'nginx:1.21',
123
+ * replicas: 3,
124
+ * containerPort: 80,
125
+ * env: { NODE_ENV: 'production' }
126
+ * });
127
+ * ```
128
+ *
129
+ * @since 1.0.0
130
+ */
63
131
  addDeployment(spec) {
64
132
  const match = spec.matchLabels ?? {
65
133
  [Rutter.LABEL_NAME]: include(Rutter.HELPER_NAME),
@@ -133,6 +201,23 @@ export class Rutter {
133
201
  this.capture(dep, `${spec.name}-deployment`);
134
202
  return dep;
135
203
  }
204
+ /**
205
+ * Adds a Kubernetes Service to the chart
206
+ *
207
+ * @param {ServiceSpec} spec - Service specification
208
+ * @returns {ApiObject} The created Service object
209
+ *
210
+ * @example
211
+ * ```typescript
212
+ * rutter.addService({
213
+ * name: 'web-service',
214
+ * ports: [{ port: 80, targetPort: 8080 }],
215
+ * type: 'LoadBalancer'
216
+ * });
217
+ * ```
218
+ *
219
+ * @since 1.0.0
220
+ */
136
221
  addService(spec) {
137
222
  const svc = new ApiObject(this.chart, spec.name, {
138
223
  apiVersion: 'v1',
@@ -240,6 +325,24 @@ export class Rutter {
240
325
  this.capture(rs, `${spec.name}-replicaset`);
241
326
  return rs;
242
327
  }
328
+ /**
329
+ * Adds a Kubernetes Job to the chart
330
+ *
331
+ * @param {JobSpec} spec - Job specification
332
+ * @returns {ApiObject} The created Job object
333
+ *
334
+ * @example
335
+ * ```typescript
336
+ * rutter.addJob({
337
+ * name: 'data-migration',
338
+ * image: 'migrate:latest',
339
+ * command: ['./migrate.sh'],
340
+ * backoffLimit: 3
341
+ * });
342
+ * ```
343
+ *
344
+ * @since 2.1.0
345
+ */
243
346
  addJob(spec) {
244
347
  // Validate Job specification
245
348
  this.validateJobSpec(spec);
@@ -262,6 +365,24 @@ export class Rutter {
262
365
  this.capture(job, `${spec.name}-job`);
263
366
  return job;
264
367
  }
368
+ /**
369
+ * Adds a Kubernetes CronJob to the chart
370
+ *
371
+ * @param {CronJobSpec} spec - CronJob specification
372
+ * @returns {ApiObject} The created CronJob object
373
+ *
374
+ * @example
375
+ * ```typescript
376
+ * rutter.addCronJob({
377
+ * name: 'backup-job',
378
+ * schedule: '0 2 * * *',
379
+ * image: 'backup:latest',
380
+ * command: ['./backup.sh']
381
+ * });
382
+ * ```
383
+ *
384
+ * @since 2.1.0
385
+ */
265
386
  addCronJob(spec) {
266
387
  // Validate CronJob specification
267
388
  this.validateCronJobSpec(spec);
@@ -284,6 +405,29 @@ export class Rutter {
284
405
  this.capture(cronJob, `${spec.name}-cronjob`);
285
406
  return cronJob;
286
407
  }
408
+ /**
409
+ * Adds a Kubernetes Ingress to the chart
410
+ *
411
+ * @param {IngressSpec} spec - Ingress specification
412
+ * @returns {ApiObject} The created Ingress object
413
+ *
414
+ * @example
415
+ * ```typescript
416
+ * rutter.addIngress({
417
+ * name: 'web-ingress',
418
+ * rules: [{
419
+ * host: 'example.com',
420
+ * paths: [{
421
+ * path: '/',
422
+ * pathType: 'Prefix',
423
+ * backend: { service: { name: 'web-service', port: { number: 80 } } }
424
+ * }]
425
+ * }]
426
+ * });
427
+ * ```
428
+ *
429
+ * @since 1.0.0
430
+ */
287
431
  addIngress(spec) {
288
432
  const ing = new ApiObject(this.chart, spec.name, {
289
433
  apiVersion: Rutter.NETWORKING_API_VERSION,
@@ -444,12 +588,31 @@ export class Rutter {
444
588
  }
445
589
  // Warn about common misconfigurations
446
590
  if (storageClassName?.includes('azure-disk') && accessModes.includes('ReadWriteMany')) {
447
- console.warn(`PVC ${spec.name}: Azure Disk does not support ReadWriteMany, consider Azure Files`);
591
+ console.warn(SecurityUtils.sanitizeLogMessage(`PVC ${spec.name}: Azure Disk does not support ReadWriteMany, consider Azure Files`));
448
592
  }
449
593
  if (storageClassName?.includes('ebs') && accessModes.includes('ReadWriteMany')) {
450
- console.warn(`PVC ${spec.name}: EBS does not support ReadWriteMany, consider EFS`);
594
+ console.warn(SecurityUtils.sanitizeLogMessage(`PVC ${spec.name}: EBS does not support ReadWriteMany, consider EFS`));
451
595
  }
452
596
  }
597
+ /**
598
+ * Adds a Kubernetes ConfigMap to the chart
599
+ *
600
+ * @param {ConfigMapSpec} spec - ConfigMap specification
601
+ * @returns {ApiObject} The created ConfigMap object
602
+ *
603
+ * @example
604
+ * ```typescript
605
+ * rutter.addConfigMap({
606
+ * name: 'app-config',
607
+ * data: {
608
+ * 'config.yaml': 'key: value',
609
+ * 'app.properties': 'debug=true'
610
+ * }
611
+ * });
612
+ * ```
613
+ *
614
+ * @since 1.0.0
615
+ */
453
616
  addConfigMap(spec) {
454
617
  const cm = new ApiObject(this.chart, spec.name, {
455
618
  apiVersion: 'v1',
@@ -466,6 +629,25 @@ export class Rutter {
466
629
  this.capture(cm, `${spec.name}-configmap`);
467
630
  return cm;
468
631
  }
632
+ /**
633
+ * Adds a Kubernetes Secret to the chart
634
+ *
635
+ * @param {SecretSpec} spec - Secret specification
636
+ * @returns {ApiObject} The created Secret object
637
+ *
638
+ * @example
639
+ * ```typescript
640
+ * rutter.addSecret({
641
+ * name: 'app-secrets',
642
+ * stringData: {
643
+ * 'username': 'admin',
644
+ * 'password': 'secret123'
645
+ * }
646
+ * });
647
+ * ```
648
+ *
649
+ * @since 1.0.0
650
+ */
469
651
  addSecret(spec) {
470
652
  const sec = new ApiObject(this.chart, spec.name, {
471
653
  apiVersion: 'v1',
@@ -483,6 +665,23 @@ export class Rutter {
483
665
  this.capture(sec, `${spec.name}-secret`);
484
666
  return sec;
485
667
  }
668
+ /**
669
+ * Adds a Kubernetes ServiceAccount to the chart
670
+ *
671
+ * @param {ServiceAccountSpec} spec - ServiceAccount specification
672
+ * @returns {ApiObject} The created ServiceAccount object
673
+ *
674
+ * @example
675
+ * ```typescript
676
+ * rutter.addServiceAccount({
677
+ * name: 'app-sa',
678
+ * awsRoleArn: 'arn:aws:iam::123456789012:role/MyRole',
679
+ * automountServiceAccountToken: true
680
+ * });
681
+ * ```
682
+ *
683
+ * @since 1.0.0
684
+ */
486
685
  addServiceAccount(spec) {
487
686
  const annotations = this.buildServiceAccountAnnotations(spec);
488
687
  const sa = new ApiObject(this.chart, spec.name, {
@@ -539,6 +738,28 @@ export class Rutter {
539
738
  annotations['iam.gke.io/gcp-service-account'] = spec.gcpServiceAccountEmail;
540
739
  }
541
740
  }
741
+ /**
742
+ * Adds a Kubernetes HorizontalPodAutoscaler to the chart
743
+ *
744
+ * @param {HorizontalPodAutoscalerSpec} spec - HPA specification
745
+ * @returns {ApiObject} The created HPA object
746
+ *
747
+ * @example
748
+ * ```typescript
749
+ * rutter.addHorizontalPodAutoscaler({
750
+ * name: 'web-hpa',
751
+ * scaleTargetRef: {
752
+ * apiVersion: 'apps/v1',
753
+ * kind: 'Deployment',
754
+ * name: 'web-app'
755
+ * },
756
+ * minReplicas: 2,
757
+ * maxReplicas: 10
758
+ * });
759
+ * ```
760
+ *
761
+ * @since 1.0.0
762
+ */
542
763
  addHorizontalPodAutoscaler(spec) {
543
764
  // Default metrics if none provided (CPU utilization at 80%)
544
765
  const defaultMetrics = [
@@ -705,8 +926,12 @@ export class Rutter {
705
926
  if (spec.subPath) {
706
927
  volumeAttributes['subPath'] = spec.subPath;
707
928
  }
708
- if (Object.keys(volumeAttributes).length > 0) {
709
- csiSpec['volumeAttributes'] = volumeAttributes;
929
+ // Only add volumeAttributes if it has properties (more efficient than Object.keys().length)
930
+ for (const key in volumeAttributes) {
931
+ if (Object.prototype.hasOwnProperty.call(volumeAttributes, key)) {
932
+ csiSpec['volumeAttributes'] = volumeAttributes;
933
+ break;
934
+ }
710
935
  }
711
936
  const pv = new ApiObject(this.chart, spec.name, {
712
937
  apiVersion: 'v1',
@@ -2017,13 +2242,513 @@ export class Rutter {
2017
2242
  return 'resource';
2018
2243
  }
2019
2244
  /**
2020
- * Add a raw CRD manifest to the chart (written under crds/).
2245
+ * Adds a raw CRD manifest to the chart
2246
+ *
2247
+ * @param {string} yaml - YAML content of the CRD
2248
+ * @param {string} [id='crd'] - Asset identifier
2249
+ *
2250
+ * @example
2251
+ * ```typescript
2252
+ * const crdYaml = `
2253
+ * apiVersion: apiextensions.k8s.io/v1
2254
+ * kind: CustomResourceDefinition
2255
+ * metadata:
2256
+ * name: myresources.example.com
2257
+ * `;
2258
+ * rutter.addCrd(crdYaml, 'myresource-crd');
2259
+ * ```
2260
+ *
2261
+ * @since 1.0.0
2021
2262
  */
2022
2263
  addCrd(yaml, id = 'crd') {
2023
2264
  this.assets.push({ id, yaml, target: 'crds' });
2024
2265
  }
2025
2266
  /**
2026
- * Synthesize the cdk8s app and write a Helm chart to outDir.
2267
+ * Add Karpenter NodePool for intelligent node provisioning.
2268
+ * Creates a NodePool resource that defines compute requirements and lifecycle policies.
2269
+ *
2270
+ * @param spec - Karpenter NodePool specification
2271
+ * @returns The created NodePool ApiObject
2272
+ *
2273
+ * @example
2274
+ * ```typescript
2275
+ * rutter.addKarpenterNodePool({
2276
+ * name: 'general-purpose',
2277
+ * requirements: [
2278
+ * { key: 'eks.amazonaws.com/instance-category', operator: 'In', values: ['c', 'm', 'r'] },
2279
+ * { key: 'kubernetes.io/arch', operator: 'In', values: ['amd64'] }
2280
+ * ],
2281
+ * limits: { cpu: '1000', memory: '1000Gi' },
2282
+ * nodeClassRef: { name: 'default' }
2283
+ * });
2284
+ * ```
2285
+ *
2286
+ * @since 2.3.0
2287
+ */
2288
+ addKarpenterNodePool(spec) {
2289
+ // Validate NodePool specification
2290
+ this.validateKarpenterNodePoolSpec(spec);
2291
+ const nodePool = new ApiObject(this.chart, spec.name, {
2292
+ apiVersion: 'karpenter.sh/v1',
2293
+ kind: 'NodePool',
2294
+ metadata: {
2295
+ name: spec.name,
2296
+ ...(spec.labels ? { labels: spec.labels } : {}),
2297
+ ...(spec.annotations ? { annotations: spec.annotations } : {}),
2298
+ },
2299
+ spec: {
2300
+ template: {
2301
+ metadata: {
2302
+ ...(spec.labels ? { labels: spec.labels } : {}),
2303
+ },
2304
+ spec: {
2305
+ nodeClassRef: {
2306
+ group: spec.nodeClassRef.group ?? 'eks.amazonaws.com',
2307
+ kind: spec.nodeClassRef.kind ?? 'NodeClass',
2308
+ name: spec.nodeClassRef.name,
2309
+ },
2310
+ ...(spec.requirements ? { requirements: spec.requirements } : {}),
2311
+ ...(spec.taints ? { taints: spec.taints } : {}),
2312
+ },
2313
+ },
2314
+ ...(spec.limits ? { limits: spec.limits } : {}),
2315
+ ...(spec.disruption ? { disruption: spec.disruption } : {}),
2316
+ },
2317
+ });
2318
+ this.capture(nodePool, `${spec.name}-nodepool`);
2319
+ return nodePool;
2320
+ }
2321
+ /**
2322
+ * Add Karpenter EC2 NodeClass for AWS-specific node configuration.
2323
+ * Creates a NodeClass resource that defines EC2 instance settings and networking.
2324
+ *
2325
+ * @param spec - Karpenter EC2 NodeClass specification
2326
+ * @returns The created NodeClass ApiObject
2327
+ *
2328
+ * @example
2329
+ * ```typescript
2330
+ * rutter.addKarpenterEC2NodeClass({
2331
+ * name: 'default',
2332
+ * amiFamily: 'AL2023',
2333
+ * subnetSelectorTerms: [{ tags: { 'karpenter.sh/discovery': 'my-cluster' } }],
2334
+ * securityGroupSelectorTerms: [{ tags: { 'karpenter.sh/discovery': 'my-cluster' } }],
2335
+ * role: 'KarpenterNodeInstanceProfile'
2336
+ * });
2337
+ * ```
2338
+ *
2339
+ * @since 2.3.0
2340
+ */
2341
+ addKarpenterEC2NodeClass(spec) {
2342
+ // Validate EC2 NodeClass specification
2343
+ this.validateKarpenterEC2NodeClassSpec(spec);
2344
+ const nodeClass = new ApiObject(this.chart, spec.name, {
2345
+ apiVersion: 'eks.amazonaws.com/v1',
2346
+ kind: 'NodeClass',
2347
+ metadata: {
2348
+ name: spec.name,
2349
+ ...(spec.labels ? { labels: spec.labels } : {}),
2350
+ ...(spec.annotations ? { annotations: spec.annotations } : {}),
2351
+ },
2352
+ spec: {
2353
+ ...(spec.amiFamily ? { amiFamily: spec.amiFamily } : {}),
2354
+ ...(spec.instanceStorePolicy ? { instanceStorePolicy: spec.instanceStorePolicy } : {}),
2355
+ ...(spec.userData ? { userData: spec.userData } : {}),
2356
+ ...(spec.subnetSelectorTerms ? { subnetSelectorTerms: spec.subnetSelectorTerms } : {}),
2357
+ ...(spec.securityGroupSelectorTerms
2358
+ ? { securityGroupSelectorTerms: spec.securityGroupSelectorTerms }
2359
+ : {}),
2360
+ ...(spec.role ? { role: spec.role } : {}),
2361
+ ...(spec.instanceProfile ? { instanceProfile: spec.instanceProfile } : {}),
2362
+ ...(spec.metadataOptions ? { metadataOptions: spec.metadataOptions } : {}),
2363
+ ...(spec.blockDeviceMappings ? { blockDeviceMappings: spec.blockDeviceMappings } : {}),
2364
+ ...(spec.tags ? { tags: spec.tags } : {}),
2365
+ },
2366
+ });
2367
+ this.capture(nodeClass, `${spec.name}-nodeclass`);
2368
+ return nodeClass;
2369
+ }
2370
+ /**
2371
+ * Validate Karpenter NodePool specification
2372
+ */
2373
+ validateKarpenterNodePoolSpec(spec) {
2374
+ this.validateNodeClassRef(spec);
2375
+ this.validateNodePoolRequirements(spec);
2376
+ this.validateNodePoolLimits(spec);
2377
+ this.validateNodePoolDisruption(spec);
2378
+ }
2379
+ validateNodeClassRef(spec) {
2380
+ if (!spec.nodeClassRef.name) {
2381
+ throw new Error(`Karpenter NodePool ${spec.name}: nodeClassRef.name is required`);
2382
+ }
2383
+ }
2384
+ validateNodePoolRequirements(spec) {
2385
+ if (!spec.requirements)
2386
+ return;
2387
+ const operatorsRequiringValues = ['In', 'NotIn', 'Gt', 'Lt'];
2388
+ for (const req of spec.requirements) {
2389
+ if (operatorsRequiringValues.includes(req.operator) && !req.values?.length) {
2390
+ throw new Error(`Karpenter NodePool ${spec.name}: requirement with operator '${req.operator}' must have values`);
2391
+ }
2392
+ }
2393
+ }
2394
+ validateNodePoolLimits(spec) {
2395
+ if (!spec.limits)
2396
+ return;
2397
+ if (spec.limits.cpu && !this.isValidResourceQuantity(spec.limits.cpu)) {
2398
+ throw new Error(`Karpenter NodePool ${spec.name}: invalid CPU limit format`);
2399
+ }
2400
+ if (spec.limits.memory && !this.isValidResourceQuantity(spec.limits.memory)) {
2401
+ throw new Error(`Karpenter NodePool ${spec.name}: invalid memory limit format`);
2402
+ }
2403
+ }
2404
+ validateNodePoolDisruption(spec) {
2405
+ if (!spec.disruption)
2406
+ return;
2407
+ if (spec.disruption.consolidateAfter &&
2408
+ !this.isValidDuration(spec.disruption.consolidateAfter)) {
2409
+ throw new Error(`Karpenter NodePool ${spec.name}: invalid consolidateAfter duration format`);
2410
+ }
2411
+ if (spec.disruption.expireAfter && !this.isValidDuration(spec.disruption.expireAfter)) {
2412
+ throw new Error(`Karpenter NodePool ${spec.name}: invalid expireAfter duration format`);
2413
+ }
2414
+ }
2415
+ /**
2416
+ * Validate Karpenter EC2 NodeClass specification
2417
+ */
2418
+ validateKarpenterEC2NodeClassSpec(spec) {
2419
+ this.validateNodeClassSelectors(spec);
2420
+ this.validateNodeClassBlockDevices(spec);
2421
+ this.validateNodeClassMetadataOptions(spec);
2422
+ }
2423
+ validateNodeClassSelectors(spec) {
2424
+ if (!spec.subnetSelectorTerms?.length && !spec.securityGroupSelectorTerms?.length) {
2425
+ console.warn(`Karpenter EC2 NodeClass ${spec.name}: Consider specifying subnet and security group selectors for better control`);
2426
+ }
2427
+ }
2428
+ validateNodeClassBlockDevices(spec) {
2429
+ if (!spec.blockDeviceMappings)
2430
+ return;
2431
+ for (const mapping of spec.blockDeviceMappings) {
2432
+ if (mapping.ebs?.volumeSize && !this.isValidStorageSize(mapping.ebs.volumeSize)) {
2433
+ throw new Error(`Karpenter EC2 NodeClass ${spec.name}: invalid volume size format in block device mapping`);
2434
+ }
2435
+ if (mapping.ebs?.iops !== undefined && mapping.ebs.iops < 100) {
2436
+ throw new Error(`Karpenter EC2 NodeClass ${spec.name}: IOPS must be at least 100 for block device mapping`);
2437
+ }
2438
+ }
2439
+ }
2440
+ validateNodeClassMetadataOptions(spec) {
2441
+ if (spec.metadataOptions?.httpPutResponseHopLimit === undefined)
2442
+ return;
2443
+ const hopLimit = spec.metadataOptions.httpPutResponseHopLimit;
2444
+ if (hopLimit < 1 || hopLimit > 64) {
2445
+ throw new Error(`Karpenter EC2 NodeClass ${spec.name}: httpPutResponseHopLimit must be between 1 and 64`);
2446
+ }
2447
+ }
2448
+ /**
2449
+ * Validate Kubernetes resource quantity format (e.g., "100m", "1Gi")
2450
+ */
2451
+ isValidResourceQuantity(quantity) {
2452
+ // Safe validation without regex to prevent ReDoS
2453
+ const validUnits = ['m', 'k', 'M', 'G', 'T', 'P', 'E', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei'];
2454
+ // Find the unit suffix
2455
+ let numberPart = quantity;
2456
+ let hasValidUnit = false;
2457
+ for (const unit of validUnits) {
2458
+ if (quantity.endsWith(unit)) {
2459
+ numberPart = quantity.slice(0, -unit.length);
2460
+ hasValidUnit = true;
2461
+ break;
2462
+ }
2463
+ }
2464
+ // If no unit found, that's also valid (plain number)
2465
+ if (!hasValidUnit && quantity.length > 0) {
2466
+ numberPart = quantity;
2467
+ }
2468
+ // Validate the number part
2469
+ const num = parseFloat(numberPart);
2470
+ return !isNaN(num) && num >= 0 && numberPart === String(num);
2471
+ }
2472
+ /**
2473
+ * Validate duration format (e.g., "30s", "5m", "1h")
2474
+ */
2475
+ isValidDuration(duration) {
2476
+ // Safe validation without regex to prevent ReDoS
2477
+ const validUnits = ['ns', 'us', 'µs', 'ms', 's', 'm', 'h'];
2478
+ // Find the unit suffix
2479
+ let numberPart = duration;
2480
+ let hasValidUnit = false;
2481
+ for (const unit of validUnits) {
2482
+ if (duration.endsWith(unit)) {
2483
+ numberPart = duration.slice(0, -unit.length);
2484
+ hasValidUnit = true;
2485
+ break;
2486
+ }
2487
+ }
2488
+ // Duration must have a unit
2489
+ if (!hasValidUnit)
2490
+ return false;
2491
+ // Validate the number part
2492
+ const num = parseFloat(numberPart);
2493
+ return !isNaN(num) && num >= 0 && numberPart === String(num);
2494
+ }
2495
+ /**
2496
+ * Validate storage size format (e.g., "20", "100Gi")
2497
+ */
2498
+ isValidStorageSize(size) {
2499
+ // Safe validation without regex to prevent ReDoS
2500
+ const validUnits = ['Gi', 'G', 'Ti', 'T'];
2501
+ // Find the unit suffix
2502
+ let numberPart = size;
2503
+ let hasValidUnit = false;
2504
+ for (const unit of validUnits) {
2505
+ if (size.endsWith(unit)) {
2506
+ numberPart = size.slice(0, -unit.length);
2507
+ hasValidUnit = true;
2508
+ break;
2509
+ }
2510
+ }
2511
+ // If no unit found, that's also valid (plain number)
2512
+ if (!hasValidUnit && size.length > 0) {
2513
+ numberPart = size;
2514
+ }
2515
+ // Validate the number part
2516
+ const num = parseFloat(numberPart);
2517
+ return !isNaN(num) && num >= 0 && numberPart === String(num);
2518
+ }
2519
+ /**
2520
+ * Add Karpenter NodeClaim for individual node provisioning.
2521
+ * Creates a NodeClaim resource that represents a request for a single node.
2522
+ *
2523
+ * @param spec - Karpenter NodeClaim specification
2524
+ * @returns The created NodeClaim ApiObject
2525
+ *
2526
+ * @example
2527
+ * ```typescript
2528
+ * rutter.addKarpenterNodeClaim({
2529
+ * name: 'high-memory-node',
2530
+ * requirements: [
2531
+ * { key: 'eks.amazonaws.com/instance-category', operator: 'In', values: ['r'] },
2532
+ * { key: 'eks.amazonaws.com/instance-cpu', operator: 'In', values: ['16', '32'] }
2533
+ * ],
2534
+ * nodeClassRef: { name: 'memory-optimized' },
2535
+ * expireAfter: '24h'
2536
+ * });
2537
+ * ```
2538
+ *
2539
+ * @since 2.3.0
2540
+ */
2541
+ addKarpenterNodeClaim(spec) {
2542
+ // Validate NodeClaim specification
2543
+ this.validateKarpenterNodeClaimSpec(spec);
2544
+ const nodeClaim = new ApiObject(this.chart, spec.name, {
2545
+ apiVersion: 'karpenter.sh/v1',
2546
+ kind: 'NodeClaim',
2547
+ metadata: {
2548
+ name: spec.name,
2549
+ ...(spec.labels ? { labels: spec.labels } : {}),
2550
+ ...(spec.annotations ? { annotations: spec.annotations } : {}),
2551
+ },
2552
+ spec: {
2553
+ nodeClassRef: {
2554
+ group: spec.nodeClassRef.group ?? 'eks.amazonaws.com',
2555
+ kind: spec.nodeClassRef.kind ?? 'NodeClass',
2556
+ name: spec.nodeClassRef.name,
2557
+ },
2558
+ ...(spec.requirements ? { requirements: spec.requirements } : {}),
2559
+ ...(spec.taints ? { taints: spec.taints } : {}),
2560
+ ...(spec.startupTaints ? { startupTaints: spec.startupTaints } : {}),
2561
+ ...(spec.expireAfter ? { expireAfter: spec.expireAfter } : {}),
2562
+ ...(spec.terminationGracePeriod
2563
+ ? { terminationGracePeriod: spec.terminationGracePeriod }
2564
+ : {}),
2565
+ },
2566
+ });
2567
+ this.capture(nodeClaim, `${spec.name}-nodeclaim`);
2568
+ return nodeClaim;
2569
+ }
2570
+ /**
2571
+ * Add advanced Karpenter scheduling configuration.
2572
+ * Creates scheduling constraints for fine-grained pod placement control.
2573
+ *
2574
+ * @param spec - Karpenter scheduling specification
2575
+ * @returns Configuration object for use in pod specs
2576
+ *
2577
+ * @example
2578
+ * ```typescript
2579
+ * const schedulingConfig = rutter.addKarpenterScheduling({
2580
+ * name: 'zone-spread-scheduling',
2581
+ * topologySpreadConstraints: [{
2582
+ * maxSkew: 1,
2583
+ * topologyKey: 'topology.kubernetes.io/zone',
2584
+ * whenUnsatisfiable: 'DoNotSchedule',
2585
+ * labelSelector: { matchLabels: { app: 'web' } }
2586
+ * }],
2587
+ * nodeAffinity: {
2588
+ * requiredDuringSchedulingIgnoredDuringExecution: {
2589
+ * nodeSelectorTerms: [{
2590
+ * matchExpressions: [{
2591
+ * key: 'eks.amazonaws.com/instance-category',
2592
+ * operator: 'In',
2593
+ * values: ['c', 'm']
2594
+ * }]
2595
+ * }]
2596
+ * }
2597
+ * }
2598
+ * });
2599
+ * ```
2600
+ *
2601
+ * @since 2.3.0
2602
+ */
2603
+ addKarpenterScheduling(spec) {
2604
+ // Validate scheduling specification
2605
+ this.validateKarpenterSchedulingSpec(spec);
2606
+ // Return scheduling configuration for use in pod specs
2607
+ return {
2608
+ ...(spec.nodeSelector ? { nodeSelector: spec.nodeSelector } : {}),
2609
+ ...(spec.nodeAffinity ? { affinity: { nodeAffinity: spec.nodeAffinity } } : {}),
2610
+ ...(spec.topologySpreadConstraints
2611
+ ? { topologySpreadConstraints: spec.topologySpreadConstraints }
2612
+ : {}),
2613
+ ...(spec.tolerations ? { tolerations: spec.tolerations } : {}),
2614
+ ...(spec.priorityClassName ? { priorityClassName: spec.priorityClassName } : {}),
2615
+ ...(spec.schedulerName ? { schedulerName: spec.schedulerName } : {}),
2616
+ };
2617
+ }
2618
+ /**
2619
+ * Create advanced disruption configuration for NodePools.
2620
+ * Provides fine-grained control over when and how nodes are disrupted.
2621
+ *
2622
+ * @param spec - Advanced disruption specification
2623
+ * @returns Disruption configuration object
2624
+ *
2625
+ * @example
2626
+ * ```typescript
2627
+ * const disruptionConfig = rutter.createKarpenterDisruption({
2628
+ * consolidationPolicy: 'WhenEmptyOrUnderutilized',
2629
+ * consolidateAfter: '30s',
2630
+ * expireAfter: '2160h', // 90 days
2631
+ * budgets: [{
2632
+ * schedule: '0 9 * * mon-fri', // Business hours
2633
+ * duration: '8h',
2634
+ * nodes: '0', // No disruption during business hours
2635
+ * reasons: ['Underutilized', 'Empty']
2636
+ * }]
2637
+ * });
2638
+ * ```
2639
+ *
2640
+ * @since 2.3.0
2641
+ */
2642
+ createKarpenterDisruption(spec) {
2643
+ // Validate disruption specification
2644
+ this.validateKarpenterDisruptionSpec(spec);
2645
+ return {
2646
+ ...(spec.consolidationPolicy ? { consolidationPolicy: spec.consolidationPolicy } : {}),
2647
+ ...(spec.consolidateAfter ? { consolidateAfter: spec.consolidateAfter } : {}),
2648
+ ...(spec.expireAfter ? { expireAfter: spec.expireAfter } : {}),
2649
+ ...(spec.budgets ? { budgets: spec.budgets } : {}),
2650
+ };
2651
+ }
2652
+ /**
2653
+ * Validate Karpenter NodeClaim specification
2654
+ */
2655
+ validateKarpenterNodeClaimSpec(spec) {
2656
+ // Validate NodeClass reference
2657
+ if (!spec.nodeClassRef.name) {
2658
+ throw new Error(`Karpenter NodeClaim ${spec.name}: nodeClassRef.name is required`);
2659
+ }
2660
+ // Validate expiration format
2661
+ if (spec.expireAfter && !this.isValidDuration(spec.expireAfter)) {
2662
+ throw new Error(`Karpenter NodeClaim ${spec.name}: invalid expireAfter duration format`);
2663
+ }
2664
+ // Validate termination grace period
2665
+ if (spec.terminationGracePeriod && !this.isValidDuration(spec.terminationGracePeriod)) {
2666
+ throw new Error(`Karpenter NodeClaim ${spec.name}: invalid terminationGracePeriod duration format`);
2667
+ }
2668
+ // Validate requirements
2669
+ if (spec.requirements) {
2670
+ for (const req of spec.requirements) {
2671
+ if (['In', 'NotIn', 'Gt', 'Lt'].includes(req.operator) && !req.values?.length) {
2672
+ throw new Error(`Karpenter NodeClaim ${spec.name}: requirement with operator '${req.operator}' must have values`);
2673
+ }
2674
+ }
2675
+ }
2676
+ }
2677
+ /**
2678
+ * Validate Karpenter scheduling specification
2679
+ */
2680
+ validateKarpenterSchedulingSpec(spec) {
2681
+ this.validateTopologySpreadConstraints(spec);
2682
+ this.validateSchedulingTolerations(spec);
2683
+ }
2684
+ validateTopologySpreadConstraints(spec) {
2685
+ if (!spec.topologySpreadConstraints)
2686
+ return;
2687
+ for (const constraint of spec.topologySpreadConstraints) {
2688
+ if (constraint.maxSkew < 1) {
2689
+ throw new Error(`Karpenter Scheduling ${spec.name}: maxSkew must be at least 1 in topology spread constraint`);
2690
+ }
2691
+ if (!constraint.topologyKey) {
2692
+ throw new Error(`Karpenter Scheduling ${spec.name}: topologyKey is required in topology spread constraint`);
2693
+ }
2694
+ }
2695
+ }
2696
+ validateSchedulingTolerations(spec) {
2697
+ if (!spec.tolerations)
2698
+ return;
2699
+ for (const toleration of spec.tolerations) {
2700
+ if (toleration.operator === 'Equal' && !toleration.value) {
2701
+ throw new Error(`Karpenter Scheduling ${spec.name}: value is required when operator is 'Equal' in toleration`);
2702
+ }
2703
+ }
2704
+ }
2705
+ /**
2706
+ * Validate Karpenter disruption specification
2707
+ */
2708
+ validateKarpenterDisruptionSpec(spec) {
2709
+ this.validateDisruptionDurations(spec);
2710
+ this.validateDisruptionBudgets(spec);
2711
+ }
2712
+ validateDisruptionDurations(spec) {
2713
+ if (spec.consolidateAfter && !this.isValidDuration(spec.consolidateAfter)) {
2714
+ throw new Error('Invalid consolidateAfter duration format');
2715
+ }
2716
+ if (spec.expireAfter && !this.isValidDuration(spec.expireAfter)) {
2717
+ throw new Error('Invalid expireAfter duration format');
2718
+ }
2719
+ }
2720
+ validateDisruptionBudgets(spec) {
2721
+ if (!spec.budgets)
2722
+ return;
2723
+ for (const budget of spec.budgets) {
2724
+ if (budget.schedule && budget.duration) {
2725
+ // Basic cron validation - should have 5 parts
2726
+ const cronParts = budget.schedule.split(' ');
2727
+ if (cronParts.length !== 5) {
2728
+ throw new Error('Budget schedule must be a valid cron expression with 5 parts');
2729
+ }
2730
+ }
2731
+ if (budget.duration && !this.isValidDuration(budget.duration)) {
2732
+ throw new Error('Invalid budget duration format');
2733
+ }
2734
+ }
2735
+ }
2736
+ /**
2737
+ * Synthesizes the cdk8s app and writes a complete Helm chart
2738
+ *
2739
+ * Generates all Kubernetes manifests, applies Helm templating,
2740
+ * and writes the complete chart structure to the output directory.
2741
+ *
2742
+ * @param {string} outDir - Output directory for the Helm chart
2743
+ * @throws {Error} If synthesis or writing fails
2744
+ *
2745
+ * @example
2746
+ * ```typescript
2747
+ * rutter.write('./charts/my-app');
2748
+ * // Creates: ./charts/my-app/Chart.yaml, values.yaml, templates/, etc.
2749
+ * ```
2750
+ *
2751
+ * @since 1.0.0
2027
2752
  */
2028
2753
  write(outDir) {
2029
2754
  // Use cdk8s Testing.synth to obtain manifest objects