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.
- package/CHANGELOG.md +7 -0
- package/README.md +323 -695
- package/dist/cli.js +275 -15
- package/dist/index.d.ts +5 -1
- package/dist/index.js +12 -0
- package/dist/lib/helm.d.ts +471 -0
- package/dist/lib/helm.js +483 -0
- package/dist/lib/helmChartWriter.d.ts +157 -0
- package/dist/lib/helmChartWriter.js +171 -1
- package/dist/lib/policy/configurationLoader.d.ts +132 -0
- package/dist/lib/policy/configurationLoader.js +132 -0
- package/dist/lib/policy/errorContextGenerator.d.ts +89 -0
- package/dist/lib/policy/errorContextGenerator.js +99 -2
- package/dist/lib/policy/errors.d.ts +35 -0
- package/dist/lib/policy/errors.js +36 -0
- package/dist/lib/policy/index.d.ts +9 -0
- package/dist/lib/policy/index.js +16 -0
- package/dist/lib/policy/parallelExecutor.d.ts +90 -0
- package/dist/lib/policy/parallelExecutor.js +86 -3
- package/dist/lib/policy/pluginLoader.d.ts +92 -0
- package/dist/lib/policy/pluginLoader.js +92 -1
- package/dist/lib/policy/pluginRegistry.d.ts +54 -0
- package/dist/lib/policy/pluginRegistry.js +56 -0
- package/dist/lib/policy/policyEngine.d.ts +137 -0
- package/dist/lib/policy/policyEngine.js +191 -5
- package/dist/lib/policy/resultAggregator.d.ts +46 -0
- package/dist/lib/policy/resultAggregator.js +69 -1
- package/dist/lib/policy/resultFormatter.d.ts +88 -0
- package/dist/lib/policy/resultFormatter.js +101 -0
- package/dist/lib/policy/types.d.ts +136 -0
- package/dist/lib/policy/types.js +8 -0
- package/dist/lib/policy/validationCache.d.ts +146 -0
- package/dist/lib/policy/validationCache.js +142 -6
- package/dist/lib/resources/baseResourceProvider.d.ts +45 -0
- package/dist/lib/resources/baseResourceProvider.js +48 -1
- package/dist/lib/resources/cloud/aws/awsResources.d.ts +192 -0
- package/dist/lib/resources/cloud/aws/awsResources.js +163 -1
- package/dist/lib/resources/cloud/aws/karpenterResources.d.ts +131 -0
- package/dist/lib/resources/cloud/aws/karpenterResources.js +77 -0
- package/dist/lib/rutter.d.ts +381 -3
- package/dist/lib/rutter.js +439 -28
- package/dist/lib/security.d.ts +123 -0
- package/dist/lib/security.js +162 -4
- package/dist/lib/templates/flexible-subchart.d.ts +52 -0
- package/dist/lib/templates/flexible-subchart.js +70 -0
- package/dist/lib/templates/umbrella-chart.d.ts +27 -0
- package/dist/lib/templates/umbrella-chart.js +89 -0
- package/dist/lib/types.d.ts +26 -0
- package/dist/lib/umbrella.d.ts +23 -0
- package/dist/lib/umbrella.js +23 -0
- package/dist/lib/umbrellaRutter.d.ts +75 -0
- package/dist/lib/umbrellaRutter.js +82 -2
- package/dist/lib/utils/envVarsLoader.d.ts +49 -0
- package/dist/lib/utils/envVarsLoader.js +53 -0
- package/dist/lib/utils/helmConstructSerializer.d.ts +17 -0
- package/dist/lib/utils/helmConstructSerializer.js +22 -0
- package/dist/lib/utils/helmControlStructures.d.ts +194 -0
- package/dist/lib/utils/helmControlStructures.js +180 -0
- package/dist/lib/utils/helmHelpers/envHelpers.d.ts +13 -0
- package/dist/lib/utils/helmHelpers/envHelpers.js +13 -0
- package/dist/lib/utils/helmHelpers/gitopsHelpers.d.ts +13 -0
- package/dist/lib/utils/helmHelpers/gitopsHelpers.js +13 -0
- package/dist/lib/utils/helmHelpers/index.d.ts +74 -0
- package/dist/lib/utils/helmHelpers/index.js +85 -1
- package/dist/lib/utils/helmHelpers/observabilityHelpers.d.ts +13 -0
- package/dist/lib/utils/helmHelpers/observabilityHelpers.js +13 -0
- package/dist/lib/utils/helmHelpers/types.d.ts +23 -0
- package/dist/lib/utils/helmHelpers/types.js +4 -0
- package/dist/lib/utils/helmHelpers/validationHelpers.d.ts +13 -0
- package/dist/lib/utils/helmHelpers/validationHelpers.js +13 -0
- package/dist/lib/utils/helmHelpers.d.ts +62 -0
- package/dist/lib/utils/helmHelpers.js +77 -0
- package/dist/lib/utils/helmYamlSerializer.d.ts +77 -0
- package/dist/lib/utils/helmYamlSerializer.js +398 -21
- package/dist/lib/utils/logger.d.ts +153 -0
- package/dist/lib/utils/logger.js +170 -2
- package/dist/lib/utils/valuesRef.d.ts +181 -50
- package/dist/lib/utils/valuesRef.js +168 -170
- package/dist/lib/validation/inputValidator.d.ts +45 -0
- package/dist/lib/validation/inputValidator.js +67 -2
- package/dist/types/index.d.ts +34 -0
- package/dist/types/index.js +3 -0
- package/package.json +31 -38
package/dist/lib/rutter.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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
|
|
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: {
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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';
|