timonel 3.1.1 → 3.1.2

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +323 -695
  3. package/dist/cli.js +275 -15
  4. package/dist/index.d.ts +5 -1
  5. package/dist/index.js +12 -0
  6. package/dist/lib/helm.d.ts +471 -0
  7. package/dist/lib/helm.js +483 -0
  8. package/dist/lib/helmChartWriter.d.ts +157 -0
  9. package/dist/lib/helmChartWriter.js +171 -1
  10. package/dist/lib/policy/configurationLoader.d.ts +132 -0
  11. package/dist/lib/policy/configurationLoader.js +132 -0
  12. package/dist/lib/policy/errorContextGenerator.d.ts +89 -0
  13. package/dist/lib/policy/errorContextGenerator.js +99 -2
  14. package/dist/lib/policy/errors.d.ts +35 -0
  15. package/dist/lib/policy/errors.js +36 -0
  16. package/dist/lib/policy/index.d.ts +9 -0
  17. package/dist/lib/policy/index.js +16 -0
  18. package/dist/lib/policy/parallelExecutor.d.ts +90 -0
  19. package/dist/lib/policy/parallelExecutor.js +86 -3
  20. package/dist/lib/policy/pluginLoader.d.ts +92 -0
  21. package/dist/lib/policy/pluginLoader.js +92 -1
  22. package/dist/lib/policy/pluginRegistry.d.ts +54 -0
  23. package/dist/lib/policy/pluginRegistry.js +56 -0
  24. package/dist/lib/policy/policyEngine.d.ts +137 -0
  25. package/dist/lib/policy/policyEngine.js +191 -5
  26. package/dist/lib/policy/resultAggregator.d.ts +46 -0
  27. package/dist/lib/policy/resultAggregator.js +69 -1
  28. package/dist/lib/policy/resultFormatter.d.ts +88 -0
  29. package/dist/lib/policy/resultFormatter.js +101 -0
  30. package/dist/lib/policy/types.d.ts +136 -0
  31. package/dist/lib/policy/types.js +8 -0
  32. package/dist/lib/policy/validationCache.d.ts +146 -0
  33. package/dist/lib/policy/validationCache.js +142 -6
  34. package/dist/lib/resources/baseResourceProvider.d.ts +45 -0
  35. package/dist/lib/resources/baseResourceProvider.js +48 -1
  36. package/dist/lib/resources/cloud/aws/awsResources.d.ts +192 -0
  37. package/dist/lib/resources/cloud/aws/awsResources.js +163 -1
  38. package/dist/lib/resources/cloud/aws/karpenterResources.d.ts +131 -0
  39. package/dist/lib/resources/cloud/aws/karpenterResources.js +77 -0
  40. package/dist/lib/rutter.d.ts +381 -3
  41. package/dist/lib/rutter.js +439 -28
  42. package/dist/lib/security.d.ts +123 -0
  43. package/dist/lib/security.js +162 -4
  44. package/dist/lib/templates/flexible-subchart.d.ts +52 -0
  45. package/dist/lib/templates/flexible-subchart.js +70 -0
  46. package/dist/lib/templates/umbrella-chart.d.ts +27 -0
  47. package/dist/lib/templates/umbrella-chart.js +89 -0
  48. package/dist/lib/types.d.ts +26 -0
  49. package/dist/lib/umbrella.d.ts +23 -0
  50. package/dist/lib/umbrella.js +23 -0
  51. package/dist/lib/umbrellaRutter.d.ts +75 -0
  52. package/dist/lib/umbrellaRutter.js +82 -2
  53. package/dist/lib/utils/envVarsLoader.d.ts +49 -0
  54. package/dist/lib/utils/envVarsLoader.js +53 -0
  55. package/dist/lib/utils/helmConstructSerializer.d.ts +17 -0
  56. package/dist/lib/utils/helmConstructSerializer.js +22 -0
  57. package/dist/lib/utils/helmControlStructures.d.ts +194 -0
  58. package/dist/lib/utils/helmControlStructures.js +180 -0
  59. package/dist/lib/utils/helmHelpers/envHelpers.d.ts +13 -0
  60. package/dist/lib/utils/helmHelpers/envHelpers.js +13 -0
  61. package/dist/lib/utils/helmHelpers/gitopsHelpers.d.ts +13 -0
  62. package/dist/lib/utils/helmHelpers/gitopsHelpers.js +13 -0
  63. package/dist/lib/utils/helmHelpers/index.d.ts +74 -0
  64. package/dist/lib/utils/helmHelpers/index.js +85 -1
  65. package/dist/lib/utils/helmHelpers/observabilityHelpers.d.ts +13 -0
  66. package/dist/lib/utils/helmHelpers/observabilityHelpers.js +13 -0
  67. package/dist/lib/utils/helmHelpers/types.d.ts +23 -0
  68. package/dist/lib/utils/helmHelpers/types.js +4 -0
  69. package/dist/lib/utils/helmHelpers/validationHelpers.d.ts +13 -0
  70. package/dist/lib/utils/helmHelpers/validationHelpers.js +13 -0
  71. package/dist/lib/utils/helmHelpers.d.ts +62 -0
  72. package/dist/lib/utils/helmHelpers.js +77 -0
  73. package/dist/lib/utils/helmYamlSerializer.d.ts +77 -0
  74. package/dist/lib/utils/helmYamlSerializer.js +398 -21
  75. package/dist/lib/utils/logger.d.ts +153 -0
  76. package/dist/lib/utils/logger.js +170 -2
  77. package/dist/lib/utils/valuesRef.d.ts +181 -50
  78. package/dist/lib/utils/valuesRef.js +168 -170
  79. package/dist/lib/validation/inputValidator.d.ts +45 -0
  80. package/dist/lib/validation/inputValidator.js +67 -2
  81. package/dist/types/index.d.ts +34 -0
  82. package/dist/types/index.js +3 -0
  83. package/package.json +31 -38
@@ -8,9 +8,20 @@ import { KarpenterResources } from './resources/cloud/aws/karpenterResources.js'
8
8
  import { isHelmExpression, isHelmConstruct } from './utils/helmControlStructures.js';
9
9
  import { dumpHelmAwareYaml, preprocessHelmConstructs } from './utils/helmYamlSerializer.js';
10
10
  import { generateHelpersTemplate } from './utils/helmHelpers.js';
11
+ /**
12
+ * Constants for error messages
13
+ */
11
14
  const UNKNOWN_ERROR_MESSAGE = 'Unknown error';
15
+ /**
16
+ * Rutter class with modular architecture
17
+ * Provides a clean API while delegating to specialized resource providers
18
+ * Maintains full backward compatibility with the original Rutter class
19
+ *
20
+ * @since 2.8.0+
21
+ */
12
22
  export class Rutter {
13
23
  constructor(props) {
24
+ // CDK8s infrastructure
14
25
  this.assets = [];
15
26
  this.defaultValues = props.defaultValues ?? {};
16
27
  this.envValues = props.envValues ?? {};
@@ -23,54 +34,256 @@ export class Rutter {
23
34
  namespace: props.namespace,
24
35
  operation: 'chart_initialization',
25
36
  });
26
- this.app = new App();
27
- this.chart = new Chart(this.app, props.meta.name, {
37
+ // Compose with a caller-owned construct tree when one is supplied. Otherwise
38
+ // Timonel owns an internal App solely as the root for this chart.
39
+ const scope = props.scope ?? new App();
40
+ this.chart = new Chart(scope, props.meta.name, {
28
41
  ...props.chartProps,
29
42
  ...(props.namespace ? { namespace: props.namespace } : {}),
30
43
  });
31
44
  this.awsResources = new AWSResources(this.chart);
32
45
  this.karpenterResources = new KarpenterResources(this.chart);
33
- const originalToSynthArray = this.toSynthArray.bind(this);
34
- this['toSynthArray'] = (..._args) => {
35
- if (!this.props.policyEngine) {
36
- return this.toSynthArraySync();
37
- }
38
- return originalToSynthArray();
39
- };
40
46
  }
47
+ // AWS Resources
48
+ /**
49
+ * Creates an AWS EBS StorageClass
50
+ * @param spec - EBS StorageClass specification
51
+ * @returns Created StorageClass ApiObject
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * rutter.addAWSEBSStorageClass({
56
+ * name: 'fast-ebs',
57
+ * volumeType: 'gp3',
58
+ * encrypted: true,
59
+ * allowVolumeExpansion: true
60
+ * });
61
+ * ```
62
+ *
63
+ * @since 2.8.0+
64
+ */
41
65
  addAWSEBSStorageClass(spec) {
42
66
  return this.awsResources.addEBSStorageClass(spec);
43
67
  }
68
+ /**
69
+ * Creates an AWS EFS StorageClass
70
+ * @param spec - EFS StorageClass specification
71
+ * @returns Created StorageClass ApiObject
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * rutter.addAWSEFSStorageClass({
76
+ * name: 'efs-storage',
77
+ * fileSystemId: 'fs-12345678',
78
+ * directoryPerms: '0755'
79
+ * });
80
+ * ```
81
+ *
82
+ * @since 2.8.0+
83
+ */
44
84
  addAWSEFSStorageClass(spec) {
45
85
  return this.awsResources.addEFSStorageClass(spec);
46
86
  }
87
+ /**
88
+ * Creates an AWS IRSA ServiceAccount
89
+ * @param spec - IRSA ServiceAccount specification
90
+ * @returns Created cdk8s-plus ServiceAccount
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * rutter.addAWSIRSAServiceAccount({
95
+ * name: 'my-service-account',
96
+ * roleArn: 'arn:aws:iam::ACCOUNT_ID:role/MyRole'
97
+ * });
98
+ * ```
99
+ *
100
+ * @since 2.8.0+
101
+ */
47
102
  addAWSIRSAServiceAccount(spec) {
48
103
  return this.awsResources.addIRSAServiceAccount(spec);
49
104
  }
105
+ /**
106
+ * Creates an AWS ALB Ingress
107
+ * @param spec - ALB Ingress specification
108
+ * @returns Created cdk8s-plus Ingress
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * rutter.addAWSALBIngress({
113
+ * name: 'web-ingress',
114
+ * rules: [{
115
+ * paths: [{
116
+ * path: '/',
117
+ * pathType: 'Prefix',
118
+ * backend: { service: { name: 'web-service', port: { number: 80 } } }
119
+ * }]
120
+ * }],
121
+ * scheme: 'internet-facing'
122
+ * });
123
+ * ```
124
+ *
125
+ * @since 2.8.0+
126
+ */
50
127
  addAWSALBIngress(spec) {
51
128
  return this.awsResources.addALBIngress(spec);
52
129
  }
130
+ // AWS Karpenter Resources
131
+ /**
132
+ * Creates a Karpenter NodePool resource
133
+ * @param spec - NodePool specification
134
+ * @returns Created NodePool ApiObject
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * rutter.addKarpenterNodePool({
139
+ * name: 'default-nodepool',
140
+ * template: {
141
+ * spec: {
142
+ * nodeClassRef: {
143
+ * apiVersion: 'karpenter.k8s.aws/v1beta1',
144
+ * kind: 'EC2NodeClass',
145
+ * name: 'default'
146
+ * }
147
+ * }
148
+ * }
149
+ * });
150
+ * ```
151
+ *
152
+ * @since 2.8.0+
153
+ */
53
154
  addKarpenterNodePool(spec) {
54
155
  return this.karpenterResources.addKarpenterNodePool(spec);
55
156
  }
157
+ /**
158
+ * Creates a Karpenter NodeClaim resource
159
+ * @param spec - NodeClaim specification
160
+ * @returns Created NodeClaim ApiObject
161
+ *
162
+ * @example
163
+ * ```typescript
164
+ * rutter.addKarpenterNodeClaim({
165
+ * name: 'my-node-claim',
166
+ * nodeClassRef: {
167
+ * apiVersion: 'karpenter.k8s.aws/v1beta1',
168
+ * kind: 'EC2NodeClass',
169
+ * name: 'default'
170
+ * }
171
+ * });
172
+ * ```
173
+ *
174
+ * @since 2.8.0+
175
+ */
56
176
  addKarpenterNodeClaim(spec) {
57
177
  return this.karpenterResources.addKarpenterNodeClaim(spec);
58
178
  }
179
+ /**
180
+ * Creates a Karpenter EC2NodeClass resource
181
+ * @param spec - EC2NodeClass specification
182
+ * @returns Created EC2NodeClass ApiObject
183
+ *
184
+ * @example
185
+ * ```typescript
186
+ * rutter.addKarpenterEC2NodeClass({
187
+ * name: 'default',
188
+ * amiFamily: 'AL2',
189
+ * subnetSelectorTerms: [{ tags: { 'karpenter.sh/discovery': 'my-cluster' } }],
190
+ * securityGroupSelectorTerms: [{ tags: { 'karpenter.sh/discovery': 'my-cluster' } }]
191
+ * });
192
+ * ```
193
+ *
194
+ * @since 2.8.0+
195
+ */
59
196
  addKarpenterEC2NodeClass(spec) {
60
197
  return this.karpenterResources.addKarpenterEC2NodeClass(spec);
61
198
  }
199
+ /**
200
+ * Creates a Karpenter NodePool with optimized disruption settings for cost efficiency
201
+ * @param spec - NodePool specification with disruption optimization
202
+ * @returns Created NodePool ApiObject
203
+ *
204
+ * @example
205
+ * ```typescript
206
+ * rutter.addKarpenterNodePoolWithDisruption({
207
+ * name: 'cost-optimized',
208
+ * nodeClassRef: {
209
+ * apiVersion: 'karpenter.k8s.aws/v1beta1',
210
+ * kind: 'EC2NodeClass',
211
+ * name: 'default'
212
+ * },
213
+ * consolidationPolicy: 'WhenEmptyOrUnderutilized',
214
+ * consolidateAfter: '30s',
215
+ * expireAfter: '24h',
216
+ * disruptionBudgets: [
217
+ * { nodes: '20%' },
218
+ * { nodes: '0', schedule: '0 9 * * 1-5', duration: '8h' }
219
+ * ]
220
+ * });
221
+ * ```
222
+ *
223
+ * @since 2.8.0+
224
+ */
62
225
  addKarpenterNodePoolWithDisruption(spec) {
63
226
  return this.karpenterResources.addKarpenterNodePoolWithDisruption(spec);
64
227
  }
228
+ /**
229
+ * Creates a Karpenter NodePool with scheduling constraints and priorities
230
+ * @param spec - NodePool specification with scheduling optimization
231
+ * @returns Created NodePool ApiObject
232
+ *
233
+ * @example
234
+ * ```typescript
235
+ * rutter.addKarpenterNodePoolWithScheduling({
236
+ * name: 'gpu-workloads',
237
+ * nodeClassRef: {
238
+ * apiVersion: 'karpenter.k8s.aws/v1beta1',
239
+ * kind: 'EC2NodeClass',
240
+ * name: 'gpu-nodeclass'
241
+ * },
242
+ * requirements: [
243
+ * { key: 'node.kubernetes.io/instance-type', operator: 'In', values: ['g4dn.xlarge', 'g4dn.2xlarge'] }
244
+ * ],
245
+ * taints: [
246
+ * { key: 'nvidia.com/gpu', effect: 'NoSchedule' }
247
+ * ],
248
+ * terminationGracePeriod: '60s',
249
+ * weight: 100
250
+ * });
251
+ * ```
252
+ *
253
+ * @since 2.8.0+
254
+ */
65
255
  addKarpenterNodePoolWithScheduling(spec) {
66
256
  return this.karpenterResources.addKarpenterNodePoolWithScheduling(spec);
67
257
  }
258
+ // AWS ECR ServiceAccount
259
+ /**
260
+ * Creates a ServiceAccount with ECR access annotations
261
+ * @param spec - ECR ServiceAccount specification
262
+ * @returns Created cdk8s-plus ServiceAccount
263
+ *
264
+ * @example
265
+ * ```typescript
266
+ * rutter.addAWSECRServiceAccount({
267
+ * name: 'ecr-service-account',
268
+ * roleArn: 'arn:aws:iam::ACCOUNT_ID:role/ECRAccessRole'
269
+ * });
270
+ * ```
271
+ *
272
+ * @since 2.8.0+
273
+ */
68
274
  addAWSECRServiceAccount(spec) {
69
275
  return this.awsResources.addECRServiceAccount(spec);
70
276
  }
277
+ /**
278
+ * Implements the object and deprecated raw-YAML overloads.
279
+ * @param yamlOrObject - Kubernetes manifest object or raw YAML compatibility input
280
+ * @param id - Unique cdk8s construct identifier
281
+ * @returns The created cdk8s ApiObject
282
+ */
71
283
  addManifest(yamlOrObject, id) {
72
284
  let manifestObject;
73
285
  if (typeof yamlOrObject === 'string') {
286
+ // Parse YAML string to object
74
287
  try {
75
288
  manifestObject = parse(yamlOrObject);
76
289
  }
@@ -82,53 +295,145 @@ export class Rutter {
82
295
  }
83
296
  }
84
297
  else if (typeof yamlOrObject === 'object' && yamlOrObject !== null) {
298
+ // Use object directly
85
299
  manifestObject = yamlOrObject;
86
300
  }
87
301
  else {
88
302
  throw new Error('addManifest() requires either a YAML string or an object');
89
303
  }
304
+ // Validate basic Kubernetes manifest structure
90
305
  this.validateManifestStructure(manifestObject);
306
+ // IMPORTANT: Pre-process HelmConstructs BEFORE creating ApiObject
307
+ // This ensures that field-level conditionals are detected and transformed
308
+ // into __fieldConditionalTemplate_* fields that can be preserved during cdk8s serialization
91
309
  const preprocessedManifest = preprocessHelmConstructs(manifestObject);
310
+ // Create CDK8S ApiObject from the manifest
311
+ // ApiObjectProps has an index signature [key: string]: any, which allows
312
+ // passing additional fields like data, stringData, spec, etc.
313
+ // We validate that required fields exist, then safely construct the props
92
314
  const apiObjectProps = {
93
315
  apiVersion: preprocessedManifest.apiVersion,
94
316
  kind: preprocessedManifest.kind,
95
317
  metadata: preprocessedManifest.metadata,
96
- ...preprocessedManifest,
318
+ ...preprocessedManifest, // Spread remaining fields (spec, data, stringData, etc.)
97
319
  };
98
320
  return new ApiObject(this.chart, id, apiObjectProps);
99
321
  }
322
+ /**
323
+ * Adds a template manifest to the chart with custom YAML content that preserves Helm expressions.
324
+ * This method allows you to provide raw YAML with Helm templates without serialization issues.
325
+ *
326
+ * @param yamlTemplate - The YAML template string with Helm expressions
327
+ * @param id - Unique identifier for the manifest
328
+ * @returns A compatibility placeholder that is excluded from synthesized output
329
+ * @deprecated Raw YAML templates bypass TypeScript resource typing. Prefer typed
330
+ * cdk8s/cdk8s-plus constructs through `getChart()` and ValuesRef for Helm values.
331
+ * This method is planned for removal in the next major release.
332
+ */
100
333
  addTemplateManifest(yamlTemplate, id) {
334
+ // Store the template as an asset that will be processed during write
101
335
  const templateAsset = {
102
336
  id,
103
337
  yaml: yamlTemplate,
104
338
  target: 'templates',
105
339
  };
106
340
  this.assets.push(templateAsset);
107
- return new ApiObject(this.chart, id, {
341
+ // Preserve the historical return type without leaking the compatibility
342
+ // placeholder into generated charts.
343
+ return new ApiObject(this.chart, `${id}-placeholder`, {
108
344
  apiVersion: 'v1',
109
345
  kind: 'ConfigMap',
110
- metadata: { name: id },
346
+ metadata: {
347
+ name: id,
348
+ annotations: {
349
+ 'timonel.sh/placeholder': 'true',
350
+ 'timonel.sh/deprecated-raw-template': 'true',
351
+ },
352
+ },
111
353
  });
112
354
  }
355
+ /**
356
+ * Adds a Kubernetes manifest wrapped in a Helm conditional using programmatic template generation
357
+ *
358
+ * This method creates a manifest that will only be rendered if the specified
359
+ * condition evaluates to true. The condition is checked against .Values in Helm.
360
+ *
361
+ * **Key improvements in v2.9.2:**
362
+ * - Uses programmatic Helm template generation instead of JavaScript interpolation
363
+ * - Eliminates Handlebars dependency for conditional manifests
364
+ * - Leverages the include() function from helm.ts for proper Helm syntax
365
+ * - Provides better type safety and eliminates double interpolation issues
366
+ * - Maintains backward compatibility with existing condition syntax
367
+ *
368
+ * @param manifestObject - JavaScript object representing the Kubernetes manifest
369
+ * @param condition - Helm condition path (e.g., 'enabled', 'feature.enabled')
370
+ * Can also be a full Helm expression like '{{ .Values.enabled }}'
371
+ * @param id - Unique identifier for the manifest
372
+ * @returns ApiObject instance for CDK8S compatibility
373
+ *
374
+ * @example
375
+ * ```typescript
376
+ * // Simple boolean condition
377
+ * rutter.addConditionalManifest(
378
+ * {
379
+ * apiVersion: 'v1',
380
+ * kind: 'Namespace',
381
+ * metadata: { name: 'my-namespace' }
382
+ * },
383
+ * 'createNamespace',
384
+ * 'namespace'
385
+ * );
386
+ *
387
+ * // Nested condition with complex logic
388
+ * rutter.addConditionalManifest(
389
+ * {
390
+ * apiVersion: 'v1',
391
+ * kind: 'ConfigMap',
392
+ * metadata: { name: 'app-config' },
393
+ * data: {
394
+ * config: '{{ .Values.config }}',
395
+ * environment: '{{ .Values.environment }}'
396
+ * }
397
+ * },
398
+ * 'features.configMap.enabled',
399
+ * 'app-config'
400
+ * );
401
+ * ```
402
+ *
403
+ * @throws {Error} When condition is invalid or manifest structure is malformed
404
+ * @since 2.8.4
405
+ * @since 2.9.2 Enhanced with programmatic Helm template generation for better reliability
406
+ */
113
407
  addConditionalManifest(manifestObject, condition, id) {
408
+ // Validate condition path
114
409
  if (!condition || typeof condition !== 'string') {
115
410
  throw new Error('Condition must be a non-empty string');
116
411
  }
412
+ // Validate basic Kubernetes manifest structure
117
413
  this.validateManifestStructure(manifestObject);
414
+ // Handle condition - if it's already a Helm expression, use it directly
415
+ // Otherwise, treat it as a path and wrap it with .Values.
118
416
  let helmCondition;
119
417
  if (condition.startsWith('{{') && condition.endsWith('}}')) {
418
+ // Already a Helm expression, extract the inner part and use as-is
120
419
  helmCondition = condition.slice(2, -2).trim();
121
420
  }
122
421
  else {
422
+ // Plain path, wrap with .Values.
123
423
  helmCondition = `.Values.${condition}`;
124
424
  }
125
425
  try {
426
+ // Convert the manifest to YAML with Helm-aware serialization
126
427
  const yamlContent = dumpHelmAwareYaml(manifestObject, {
428
+ // Preserve formatting and minimize line wrapping
127
429
  lineWidth: 0,
128
430
  });
431
+ // Create programmatic Helm conditional template using proper syntax
432
+ // This eliminates JavaScript interpolation and uses native Helm templating
129
433
  const conditionalYaml = `{{- if ${helmCondition} }}
130
434
  ${yamlContent.trim()}
131
435
  {{- end }}`;
436
+ // Store the manifest as a conditional asset that will be processed during write
132
437
  const conditionalAsset = {
133
438
  id,
134
439
  yaml: conditionalYaml,
@@ -142,6 +447,8 @@ ${yamlContent.trim()}
142
447
  wrappedError.cause = cause;
143
448
  throw wrappedError;
144
449
  }
450
+ // Create a placeholder CDK8S ApiObject for consistency, but mark it as conditional
451
+ // so it doesn't get processed as a separate asset
145
452
  return new ApiObject(this.chart, `${id}-placeholder`, {
146
453
  apiVersion: manifestObject['apiVersion'],
147
454
  kind: manifestObject['kind'],
@@ -150,12 +457,18 @@ ${yamlContent.trim()}
150
457
  annotations: {
151
458
  ...(manifestObject['metadata']?.['annotations'] || {}),
152
459
  'timonel.sh/conditional': condition,
153
- 'timonel.sh/placeholder': 'true',
460
+ 'timonel.sh/placeholder': 'true', // Mark as placeholder to exclude from synthesis
154
461
  },
155
462
  },
156
463
  spec: manifestObject['spec'],
157
464
  });
158
465
  }
466
+ /**
467
+ * Validates the structure of a Kubernetes manifest object
468
+ * @param manifest - The manifest object to validate
469
+ * @private
470
+ * @since 2.8.0+
471
+ */
159
472
  validateManifestStructure(manifest) {
160
473
  if (!manifest.apiVersion) {
161
474
  this.logger.error('Validation failed: Missing apiVersion', {
@@ -186,36 +499,90 @@ ${yamlContent.trim()}
186
499
  });
187
500
  throw new Error('Manifest metadata must have a name');
188
501
  }
502
+ // Validate metadata.name - must be string, HelmExpression, or HelmConstruct
189
503
  const name = manifest['metadata']?.['name'];
190
504
  if (typeof name !== 'string' && !isHelmExpression(name) && !isHelmConstruct(name)) {
191
505
  const actualType = Array.isArray(name) ? 'array' : name === null ? 'null' : typeof name;
192
506
  throw new Error(`Manifest metadata.name must be a string, HelmExpression, or HelmConstruct. Got ${actualType}`);
193
507
  }
194
508
  }
509
+ /**
510
+ * Returns stable logical identifiers for all synthesized ApiObjects, including
511
+ * those nested inside cdk8s-plus resources.
512
+ */
513
+ getSynthesizedApiObjectIds() {
514
+ return this.chart.node
515
+ .findAll()
516
+ .filter((construct) => construct instanceof ApiObject && !construct.node.id.endsWith('-placeholder'))
517
+ .map((apiObject) => {
518
+ const owner = apiObject.node.scope;
519
+ if (apiObject.node.id === 'Resource' && owner && owner !== this.chart) {
520
+ return owner.node.id;
521
+ }
522
+ return apiObject.node.id;
523
+ });
524
+ }
525
+ /**
526
+ * Returns the underlying cdk8s Chart so consumers can attach fully typed
527
+ * cdk8s/cdk8s-plus constructs directly to Timonel's synthesis tree.
528
+ *
529
+ * @returns The cdk8s Chart owned by this Rutter instance
530
+ */
531
+ getChart() {
532
+ return this.chart;
533
+ }
534
+ /**
535
+ * Gets chart metadata
536
+ * @returns Chart metadata
537
+ *
538
+ * @since 2.8.0+
539
+ */
195
540
  getMeta() {
196
541
  return { ...this.meta };
197
542
  }
543
+ /**
544
+ * Gets default values
545
+ * @returns Default values object
546
+ *
547
+ * @since 1.0.0
548
+ */
198
549
  getDefaultValues() {
199
550
  return { ...this.defaultValues };
200
551
  }
552
+ /**
553
+ * Gets environment-specific values
554
+ * @returns Environment values object
555
+ *
556
+ * @since 1.0.0
557
+ */
201
558
  getEnvValues() {
202
559
  return { ...this.envValues };
203
560
  }
561
+ /**
562
+ * Gets all assets (CRDs, etc.)
563
+ * @returns Array of assets
564
+ *
565
+ * @since 1.0.0
566
+ */
204
567
  getAssets() {
205
568
  return [...this.assets];
206
569
  }
570
+ /**
571
+ * Converts the chart to SynthAsset array for HelmChartWriter
572
+ * @returns Array of synthesized assets
573
+ *
574
+ * @since 2.8.0+
575
+ */
207
576
  async toSynthArray() {
208
577
  const timer = this.logger.time('chart_synthesis');
209
578
  this.logger.debug('Starting chart synthesis', {
210
579
  chartName: this.meta.name,
211
580
  operation: 'synthesis_start',
212
581
  });
213
- const apiObjectIds = [];
214
- for (const child of this.chart.node.children) {
215
- if (child instanceof ApiObject && !child.node.id.endsWith('-placeholder')) {
216
- apiObjectIds.push(child.node.id);
217
- }
218
- }
582
+ // Get ApiObject IDs before synthesis, including ApiObjects nested inside
583
+ // cdk8s-plus constructs while excluding compatibility placeholders.
584
+ const apiObjectIds = this.getSynthesizedApiObjectIds();
585
+ // Use cdk8s Testing.synth to obtain manifest objects, but filter out placeholders
219
586
  const allManifestObjs = Testing.synth(this.chart);
220
587
  const manifestObjs = allManifestObjs.filter((obj) => {
221
588
  if (obj && typeof obj === 'object') {
@@ -231,6 +598,7 @@ ${yamlContent.trim()}
231
598
  apiObjectCount: apiObjectIds.length,
232
599
  operation: 'manifest_processing',
233
600
  });
601
+ // Optional policy validation BEFORE enrichment to allow policies to control label injection
234
602
  if (this.props.policyEngine) {
235
603
  this.logger.debug('Starting policy validation before enrichment', {
236
604
  chartName: this.meta.name,
@@ -248,6 +616,7 @@ ${yamlContent.trim()}
248
616
  });
249
617
  throw new Error(`Policy validation failed: ${errorMessage}`);
250
618
  }
619
+ // Log warnings but continue
251
620
  if (validationResult.warnings.length > 0) {
252
621
  this.logger.warn('Policy validation warnings', {
253
622
  chartName: this.meta.name,
@@ -276,7 +645,12 @@ ${yamlContent.trim()}
276
645
  throw error;
277
646
  }
278
647
  }
648
+ // Enforce common labels best-practice on all rendered objects
649
+ // IMPORTANT: Also pre-process HelmConstructs AFTER Testing.synth serializes the objects
650
+ // This ensures that field-level conditionals are detected and transformed
651
+ // even though cdk8s may have removed the original HelmConstruct structure
279
652
  const enriched = manifestObjs.map((obj) => {
653
+ // First, pre-process HelmConstructs to detect field-level conditionals
280
654
  const preprocessed = preprocessHelmConstructs(obj);
281
655
  if (preprocessed && typeof preprocessed === 'object') {
282
656
  const o = preprocessed;
@@ -291,8 +665,10 @@ ${yamlContent.trim()}
291
665
  'app.kubernetes.io/managed-by': '{{ .Release.Service }}',
292
666
  'app.kubernetes.io/part-of': '{{ .Chart.Name }}',
293
667
  };
668
+ // Apply defaults only if not already set
294
669
  for (const [key, value] of Object.entries(defaults)) {
295
670
  if (!(key in labels)) {
671
+ // eslint-disable-next-line security/detect-object-injection -- Safe: controlled label assignment
296
672
  labels[key] = value;
297
673
  }
298
674
  }
@@ -301,6 +677,7 @@ ${yamlContent.trim()}
301
677
  });
302
678
  const synthAssets = [];
303
679
  if (this.props.singleManifestFile) {
680
+ // Combine all resources into single manifest
304
681
  const combinedYaml = enriched
305
682
  .map((obj) => dumpHelmAwareYaml(obj).trim())
306
683
  .filter(Boolean)
@@ -309,15 +686,19 @@ ${yamlContent.trim()}
309
686
  synthAssets.push({ id: manifestId, yaml: combinedYaml, target: 'templates' });
310
687
  }
311
688
  else {
689
+ // Create separate files for each resource (default behavior)
312
690
  enriched.forEach((obj, index) => {
691
+ // eslint-disable-next-line security/detect-object-injection
313
692
  const apiObjectId = apiObjectIds[index];
314
693
  const manifestId = apiObjectId || `manifest-${index + 1}`;
315
694
  const yaml = dumpHelmAwareYaml(obj).trim();
316
695
  if (yaml) {
696
+ // Use descriptive name from ApiObject ID if available, otherwise fallback to generic name
317
697
  synthAssets.push({ id: manifestId, yaml, target: 'templates' });
318
698
  }
319
699
  });
320
700
  }
701
+ // Add any additional assets (CRDs, etc.)
321
702
  this.assets.forEach((asset) => {
322
703
  synthAssets.push({
323
704
  id: asset.id,
@@ -331,10 +712,18 @@ ${yamlContent.trim()}
331
712
  additionalAssets: this.assets.length,
332
713
  operation: 'synthesis_complete',
333
714
  });
334
- timer();
715
+ timer(); // Complete timing measurement
335
716
  return synthAssets;
336
717
  }
718
+ /**
719
+ * Synchronous version of toSynthArray for backward compatibility
720
+ * @returns Array of synthesized assets
721
+ * @deprecated Use toSynthArray() instead for proper async handling
722
+ * @since 2.8.0+
723
+ */
337
724
  toSynthArraySync() {
725
+ // For backward compatibility, we need to handle the case where no policy engine is used
726
+ // In this case, we can run synchronously
338
727
  if (this.props.policyEngine) {
339
728
  throw new Error('toSynthArraySync() cannot be used with policy engine. Use toSynthArray() instead.');
340
729
  }
@@ -343,12 +732,10 @@ ${yamlContent.trim()}
343
732
  chartName: this.meta.name,
344
733
  operation: 'synthesis_start_sync',
345
734
  });
346
- const apiObjectIds = [];
347
- for (const child of this.chart.node.children) {
348
- if (child instanceof ApiObject && !child.node.id.endsWith('-placeholder')) {
349
- apiObjectIds.push(child.node.id);
350
- }
351
- }
735
+ // Get ApiObject IDs before synthesis, including ApiObjects nested inside
736
+ // cdk8s-plus constructs while excluding compatibility placeholders.
737
+ const apiObjectIds = this.getSynthesizedApiObjectIds();
738
+ // Use cdk8s Testing.synth to obtain manifest objects, but filter out placeholders
352
739
  const allManifestObjs = Testing.synth(this.chart);
353
740
  const manifestObjs = allManifestObjs.filter((obj) => {
354
741
  if (obj && typeof obj === 'object') {
@@ -364,7 +751,9 @@ ${yamlContent.trim()}
364
751
  apiObjectCount: apiObjectIds.length,
365
752
  operation: 'manifest_processing_sync',
366
753
  });
754
+ // Enforce common labels best-practice on all rendered objects
367
755
  const enriched = manifestObjs.map((obj) => {
756
+ // First, pre-process HelmConstructs to detect field-level conditionals
368
757
  const preprocessed = preprocessHelmConstructs(obj);
369
758
  if (preprocessed && typeof preprocessed === 'object') {
370
759
  const o = preprocessed;
@@ -379,8 +768,10 @@ ${yamlContent.trim()}
379
768
  'app.kubernetes.io/managed-by': '{{ .Release.Service }}',
380
769
  'app.kubernetes.io/part-of': '{{ .Chart.Name }}',
381
770
  };
771
+ // Apply defaults only if not already set
382
772
  for (const [key, value] of Object.entries(defaults)) {
383
773
  if (!(key in labels)) {
774
+ // eslint-disable-next-line security/detect-object-injection -- Safe: controlled label assignment
384
775
  labels[key] = value;
385
776
  }
386
777
  }
@@ -389,6 +780,7 @@ ${yamlContent.trim()}
389
780
  });
390
781
  const synthAssets = [];
391
782
  if (this.props.singleManifestFile) {
783
+ // Combine all resources into single manifest
392
784
  const combinedYaml = enriched
393
785
  .map((obj) => dumpHelmAwareYaml(obj).trim())
394
786
  .filter(Boolean)
@@ -397,15 +789,19 @@ ${yamlContent.trim()}
397
789
  synthAssets.push({ id: manifestId, yaml: combinedYaml, target: 'templates' });
398
790
  }
399
791
  else {
792
+ // Create separate files for each resource (default behavior)
400
793
  enriched.forEach((obj, index) => {
794
+ // eslint-disable-next-line security/detect-object-injection
401
795
  const apiObjectId = apiObjectIds[index];
402
796
  const manifestId = apiObjectId || `manifest-${index + 1}`;
403
797
  const yaml = dumpHelmAwareYaml(obj).trim();
404
798
  if (yaml) {
799
+ // Use descriptive name from ApiObject ID if available, otherwise fallback to generic name
405
800
  synthAssets.push({ id: manifestId, yaml, target: 'templates' });
406
801
  }
407
802
  });
408
803
  }
804
+ // Add any additional assets (CRDs, etc.)
409
805
  this.assets.forEach((asset) => {
410
806
  synthAssets.push({
411
807
  id: asset.id,
@@ -419,9 +815,15 @@ ${yamlContent.trim()}
419
815
  additionalAssets: this.assets.length,
420
816
  operation: 'synthesis_complete_sync',
421
817
  });
422
- timer();
818
+ timer(); // Complete timing measurement
423
819
  return synthAssets;
424
820
  }
821
+ /**
822
+ * Formats policy validation errors into a readable error message
823
+ * @param result - Policy validation result
824
+ * @returns Formatted error message
825
+ * @private
826
+ */
425
827
  formatPolicyErrors(result) {
426
828
  const errorMessages = [];
427
829
  if (result.violations && result.violations.length > 0) {
@@ -446,6 +848,12 @@ ${yamlContent.trim()}
446
848
  }
447
849
  return errorMessages.join('\n');
448
850
  }
851
+ /**
852
+ * Writes the Helm chart to the specified output directory
853
+ * @param outDir - Output directory path
854
+ *
855
+ * @since 1.0.0
856
+ */
449
857
  async write(outDir) {
450
858
  const timer = this.logger.time('chart_write');
451
859
  this.logger.info('Starting chart write operation', {
@@ -453,6 +861,7 @@ ${yamlContent.trim()}
453
861
  outputDirectory: outDir,
454
862
  operation: 'chart_write_start',
455
863
  });
864
+ // Generate helpers template
456
865
  let helpersContent;
457
866
  if (this.props.helpersTpl) {
458
867
  if (typeof this.props.helpersTpl === 'string') {
@@ -470,6 +879,7 @@ ${helper.template}
470
879
  }
471
880
  }
472
881
  else {
882
+ // Auto-generate standard helpers
473
883
  helpersContent = generateHelpersTemplate(this.props.cloudProvider);
474
884
  }
475
885
  const synthAssets = await this.toSynthArray();
@@ -492,7 +902,8 @@ ${helper.template}
492
902
  outputDirectory: outDir,
493
903
  operation: 'chart_write_complete',
494
904
  });
495
- timer();
905
+ timer(); // Complete timing measurement
496
906
  }
497
907
  }
908
+ // Constants for labels
498
909
  Rutter.HELPER_NAME = 'chart.name';