@kustodian/generator 1.0.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.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@kustodian/generator",
3
+ "version": "1.0.0",
4
+ "description": "Template processing and Flux generation for Kustodian",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "import": "./src/index.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "scripts": {
18
+ "test": "bun test",
19
+ "test:watch": "bun test --watch",
20
+ "typecheck": "bun run tsc --noEmit"
21
+ },
22
+ "keywords": [
23
+ "kustodian",
24
+ "generator",
25
+ "flux"
26
+ ],
27
+ "author": "Luca Silverentand <luca@onezero.company>",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/lucasilverentand/kustodian.git",
32
+ "directory": "packages/generator"
33
+ },
34
+ "publishConfig": {
35
+ "registry": "https://npm.pkg.github.com"
36
+ },
37
+ "dependencies": {
38
+ "@kustodian/core": "workspace:*",
39
+ "@kustodian/loader": "workspace:*",
40
+ "@kustodian/plugins": "workspace:*",
41
+ "@kustodian/schema": "workspace:*",
42
+ "yaml": "^2.8.2"
43
+ },
44
+ "devDependencies": {}
45
+ }
@@ -0,0 +1,71 @@
1
+ import type {
2
+ DopplerSubstitutionType,
3
+ OnePasswordSubstitutionType,
4
+ SubstitutionType,
5
+ TemplateType,
6
+ } from '@kustodian/schema';
7
+ import {
8
+ is_doppler_substitution,
9
+ is_onepassword_substitution,
10
+ } from '@kustodian/schema';
11
+
12
+ /**
13
+ * Extracts 1Password substitutions from templates.
14
+ */
15
+ export function extract_onepassword_substitutions(
16
+ templates: TemplateType[],
17
+ ): OnePasswordSubstitutionType[] {
18
+ const substitutions: OnePasswordSubstitutionType[] = [];
19
+
20
+ for (const template of templates) {
21
+ for (const kustomization of template.spec.kustomizations) {
22
+ for (const sub of kustomization.substitutions ?? []) {
23
+ if (is_onepassword_substitution(sub)) {
24
+ substitutions.push(sub);
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ return substitutions;
31
+ }
32
+
33
+ /**
34
+ * Extracts Doppler substitutions from templates.
35
+ */
36
+ export function extract_doppler_substitutions(
37
+ templates: TemplateType[],
38
+ ): DopplerSubstitutionType[] {
39
+ const substitutions: DopplerSubstitutionType[] = [];
40
+
41
+ for (const template of templates) {
42
+ for (const kustomization of template.spec.kustomizations) {
43
+ for (const sub of kustomization.substitutions ?? []) {
44
+ if (is_doppler_substitution(sub)) {
45
+ substitutions.push(sub);
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ return substitutions;
52
+ }
53
+
54
+ /**
55
+ * Extracts all external substitutions (1Password and Doppler) from templates.
56
+ */
57
+ export function extract_external_substitutions(
58
+ templates: TemplateType[],
59
+ ): { onepassword: OnePasswordSubstitutionType[]; doppler: DopplerSubstitutionType[] } {
60
+ return {
61
+ onepassword: extract_onepassword_substitutions(templates),
62
+ doppler: extract_doppler_substitutions(templates),
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Checks if a substitution requires external resolution.
68
+ */
69
+ export function is_external_substitution(sub: SubstitutionType): boolean {
70
+ return is_onepassword_substitution(sub) || is_doppler_substitution(sub);
71
+ }
package/src/flux.ts ADDED
@@ -0,0 +1,233 @@
1
+ import type {
2
+ ClusterType,
3
+ KustomizationType,
4
+ OciConfigType,
5
+ TemplateType,
6
+ } from '@kustodian/schema';
7
+
8
+ import type {
9
+ FluxKustomizationType,
10
+ FluxOCIRepositoryType,
11
+ ResolvedKustomizationType,
12
+ } from './types.js';
13
+ import { is_parse_error, parse_dependency_ref } from './validation/reference.js';
14
+
15
+ /**
16
+ * Default interval for Flux reconciliation.
17
+ */
18
+ export const DEFAULT_INTERVAL = '10m';
19
+
20
+ /**
21
+ * Default timeout for Flux reconciliation.
22
+ */
23
+ export const DEFAULT_TIMEOUT = '5m';
24
+
25
+ /**
26
+ * Generates a Flux Kustomization name from template and kustomization.
27
+ */
28
+ export function generate_flux_name(template_name: string, kustomization_name: string): string {
29
+ return `${template_name}-${kustomization_name}`;
30
+ }
31
+
32
+ /**
33
+ * Generates the path for a Flux Kustomization.
34
+ */
35
+ export function generate_flux_path(
36
+ template_name: string,
37
+ kustomization_path: string,
38
+ base_path = './templates',
39
+ ): string {
40
+ // Normalize the path
41
+ const normalized = kustomization_path.replace(/^\.\//, '');
42
+ return `${base_path}/${template_name}/${normalized}`;
43
+ }
44
+
45
+ /**
46
+ * Generates the dependency references for a Flux Kustomization.
47
+ *
48
+ * Supports both within-template and cross-template references:
49
+ * - Within-template: `database` → uses current template name
50
+ * - Cross-template: `secrets/doppler` → uses explicit template name
51
+ */
52
+ export function generate_depends_on(
53
+ template_name: string,
54
+ depends_on: string[] | undefined,
55
+ ): Array<{ name: string }> | undefined {
56
+ if (!depends_on || depends_on.length === 0) {
57
+ return undefined;
58
+ }
59
+
60
+ return depends_on.map((dep) => {
61
+ const parsed = parse_dependency_ref(dep);
62
+ if (is_parse_error(parsed)) {
63
+ // Invalid references are caught during validation, fall back to current behavior
64
+ return { name: generate_flux_name(template_name, dep) };
65
+ }
66
+ const effective_template = parsed.template ?? template_name;
67
+ return { name: generate_flux_name(effective_template, parsed.kustomization) };
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Generates health checks for a Flux Kustomization.
73
+ */
74
+ export function generate_health_checks(
75
+ kustomization: KustomizationType,
76
+ namespace: string,
77
+ ): FluxKustomizationType['spec']['healthChecks'] {
78
+ if (!kustomization.health_checks || kustomization.health_checks.length === 0) {
79
+ return undefined;
80
+ }
81
+
82
+ return kustomization.health_checks.map((check) => ({
83
+ apiVersion: 'apps/v1',
84
+ kind: check.kind,
85
+ name: check.name,
86
+ namespace: check.namespace ?? namespace,
87
+ }));
88
+ }
89
+
90
+ /**
91
+ * Generates a Flux OCIRepository resource.
92
+ */
93
+ export function generate_flux_oci_repository(
94
+ cluster: ClusterType,
95
+ oci_config: OciConfigType,
96
+ repository_name: string,
97
+ flux_namespace: string,
98
+ ): FluxOCIRepositoryType {
99
+ const url = `oci://${oci_config.registry}/${oci_config.repository}`;
100
+
101
+ const ref: FluxOCIRepositoryType['spec']['ref'] = {};
102
+ switch (oci_config.tag_strategy) {
103
+ case 'cluster':
104
+ ref.tag = cluster.metadata.name;
105
+ break;
106
+ case 'manual':
107
+ ref.tag = oci_config.tag || 'latest';
108
+ break;
109
+ default:
110
+ ref.tag = 'latest'; // CI will update this
111
+ }
112
+
113
+ const spec: FluxOCIRepositoryType['spec'] = {
114
+ interval: DEFAULT_INTERVAL,
115
+ url,
116
+ ref,
117
+ provider: oci_config.provider || 'generic',
118
+ };
119
+
120
+ if (oci_config.secret_ref) {
121
+ spec.secretRef = { name: oci_config.secret_ref };
122
+ }
123
+
124
+ if (oci_config.insecure) {
125
+ spec.insecure = true;
126
+ }
127
+
128
+ return {
129
+ apiVersion: 'source.toolkit.fluxcd.io/v1',
130
+ kind: 'OCIRepository',
131
+ metadata: {
132
+ name: repository_name,
133
+ namespace: flux_namespace,
134
+ },
135
+ spec,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Generates a Flux Kustomization resource.
141
+ */
142
+ export function generate_flux_kustomization(
143
+ resolved: ResolvedKustomizationType,
144
+ source_repository_name = 'flux-system',
145
+ source_kind: 'GitRepository' | 'OCIRepository' = 'GitRepository',
146
+ ): FluxKustomizationType {
147
+ const { template, kustomization, values, namespace } = resolved;
148
+ const name = generate_flux_name(template.metadata.name, kustomization.name);
149
+ const path = generate_flux_path(template.metadata.name, kustomization.path);
150
+
151
+ const spec: FluxKustomizationType['spec'] = {
152
+ interval: DEFAULT_INTERVAL,
153
+ targetNamespace: namespace,
154
+ path,
155
+ prune: kustomization.prune ?? true,
156
+ wait: kustomization.wait ?? true,
157
+ sourceRef: {
158
+ kind: source_kind,
159
+ name: source_repository_name,
160
+ },
161
+ };
162
+
163
+ // Add timeout if specified
164
+ if (kustomization.timeout) {
165
+ spec.timeout = kustomization.timeout;
166
+ } else {
167
+ spec.timeout = DEFAULT_TIMEOUT;
168
+ }
169
+
170
+ // Add retry interval if specified
171
+ if (kustomization.retry_interval) {
172
+ spec.retryInterval = kustomization.retry_interval;
173
+ }
174
+
175
+ // Add dependencies
176
+ const depends_on = generate_depends_on(template.metadata.name, kustomization.depends_on);
177
+ if (depends_on) {
178
+ spec.dependsOn = depends_on;
179
+ }
180
+
181
+ // Add substitutions if there are values
182
+ if (Object.keys(values).length > 0) {
183
+ spec.postBuild = {
184
+ substitute: values,
185
+ };
186
+ }
187
+
188
+ // Add health checks
189
+ const health_checks = generate_health_checks(kustomization, namespace);
190
+ if (health_checks) {
191
+ spec.healthChecks = health_checks;
192
+ }
193
+
194
+ return {
195
+ apiVersion: 'kustomize.toolkit.fluxcd.io/v1',
196
+ kind: 'Kustomization',
197
+ metadata: {
198
+ name,
199
+ namespace: 'flux-system',
200
+ },
201
+ spec,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Resolves a kustomization with values from the cluster config.
207
+ */
208
+ export function resolve_kustomization(
209
+ template: TemplateType,
210
+ kustomization: KustomizationType,
211
+ cluster_values: Record<string, string> = {},
212
+ ): ResolvedKustomizationType {
213
+ // Collect values with defaults
214
+ const values: Record<string, string> = {};
215
+
216
+ for (const sub of kustomization.substitutions ?? []) {
217
+ // Check cluster-provided value first, then fall back to default
218
+ const value = cluster_values[sub.name] ?? sub.default;
219
+ if (value !== undefined) {
220
+ values[sub.name] = value;
221
+ }
222
+ }
223
+
224
+ // Determine namespace
225
+ const namespace = kustomization.namespace?.default ?? 'default';
226
+
227
+ return {
228
+ template,
229
+ kustomization,
230
+ values,
231
+ namespace,
232
+ };
233
+ }
@@ -0,0 +1,311 @@
1
+ import { type ResultType, success } from '@kustodian/core';
2
+ import type { KustodianErrorType } from '@kustodian/core';
3
+ import type {
4
+ GeneratedResourceType,
5
+ LegacyPluginRegistryType,
6
+ PluginContextType,
7
+ } from '@kustodian/plugins';
8
+ import type { ClusterType, TemplateConfigType, TemplateType } from '@kustodian/schema';
9
+
10
+ import {
11
+ DEFAULT_INTERVAL,
12
+ DEFAULT_TIMEOUT,
13
+ generate_depends_on,
14
+ generate_flux_kustomization,
15
+ generate_flux_name,
16
+ generate_flux_oci_repository,
17
+ generate_health_checks,
18
+ resolve_kustomization,
19
+ } from './flux.js';
20
+ import { generate_namespace_resources } from './namespace.js';
21
+ import { serialize_resource, serialize_resources, write_generation_result } from './output.js';
22
+ import type {
23
+ FluxKustomizationType,
24
+ GenerateOptionsType,
25
+ GeneratedKustomizationType,
26
+ GenerationResultType,
27
+ ResolvedKustomizationType,
28
+ ResolvedTemplateType,
29
+ } from './types.js';
30
+ import { validate_dependencies } from './validation/index.js';
31
+
32
+ /**
33
+ * Options for the Generator.
34
+ */
35
+ export interface GeneratorOptionsType {
36
+ flux_namespace?: string;
37
+ git_repository_name?: string;
38
+ base_path?: string;
39
+ }
40
+
41
+ /**
42
+ * Generator hook phases.
43
+ */
44
+ export type GeneratorHookPhaseType =
45
+ | 'before_generate'
46
+ | 'after_resolve_template'
47
+ | 'after_generate_kustomization'
48
+ | 'after_generate';
49
+
50
+ /**
51
+ * Hook handler function type.
52
+ */
53
+ export type GeneratorHookHandlerType = (
54
+ phase: GeneratorHookPhaseType,
55
+ context: GeneratorHookContextType,
56
+ ) => void | Promise<void>;
57
+
58
+ /**
59
+ * Context passed to generator hooks.
60
+ */
61
+ export interface GeneratorHookContextType {
62
+ cluster: ClusterType;
63
+ templates?: ResolvedTemplateType[];
64
+ kustomization?: ResolvedKustomizationType;
65
+ flux_kustomization?: FluxKustomizationType;
66
+ result?: GenerationResultType;
67
+ }
68
+
69
+ /**
70
+ * Generator class for processing templates into Flux resources.
71
+ */
72
+ export interface GeneratorType {
73
+ /**
74
+ * Registers a hook handler.
75
+ */
76
+ on_hook(handler: GeneratorHookHandlerType): void;
77
+
78
+ /**
79
+ * Resolves templates for a cluster.
80
+ */
81
+ resolve_templates(cluster: ClusterType, templates: TemplateType[]): ResolvedTemplateType[];
82
+
83
+ /**
84
+ * Generates Flux resources for a cluster.
85
+ */
86
+ generate(
87
+ cluster: ClusterType,
88
+ templates: TemplateType[],
89
+ options?: GenerateOptionsType,
90
+ ): Promise<ResultType<GenerationResultType, KustodianErrorType>>;
91
+
92
+ /**
93
+ * Generates resources from plugins.
94
+ */
95
+ generate_plugin_resources(
96
+ cluster: ClusterType,
97
+ templates: ResolvedTemplateType[],
98
+ ): ResultType<GeneratedResourceType[], KustodianErrorType>;
99
+
100
+ /**
101
+ * Writes generation result to disk.
102
+ */
103
+ write(result: GenerationResultType): Promise<ResultType<string[], KustodianErrorType>>;
104
+ }
105
+
106
+ /**
107
+ * Creates a new Generator instance.
108
+ */
109
+ export function create_generator(
110
+ options: GeneratorOptionsType = {},
111
+ registry?: LegacyPluginRegistryType,
112
+ ): GeneratorType {
113
+ const flux_namespace = options.flux_namespace ?? 'flux-system';
114
+ const git_repository_name = options.git_repository_name ?? 'flux-system';
115
+ // base_path is available for future template path customization
116
+ const _base_path = options.base_path ?? './templates';
117
+ void _base_path; // Suppress unused variable warning
118
+
119
+ const hooks: GeneratorHookHandlerType[] = [];
120
+
121
+ async function run_hooks(
122
+ phase: GeneratorHookPhaseType,
123
+ context: GeneratorHookContextType,
124
+ ): Promise<void> {
125
+ for (const handler of hooks) {
126
+ await handler(phase, context);
127
+ }
128
+ }
129
+
130
+ function get_template_values(
131
+ cluster: ClusterType,
132
+ template_name: string,
133
+ ): Record<string, string> {
134
+ const template_config = cluster.spec.templates?.find(
135
+ (t: TemplateConfigType) => t.name === template_name,
136
+ );
137
+ return template_config?.values ?? {};
138
+ }
139
+
140
+ function is_template_enabled(cluster: ClusterType, template_name: string): boolean {
141
+ const template_config = cluster.spec.templates?.find(
142
+ (t: TemplateConfigType) => t.name === template_name,
143
+ );
144
+ // Default to enabled if not specified
145
+ return template_config?.enabled ?? true;
146
+ }
147
+
148
+ return {
149
+ on_hook(handler) {
150
+ hooks.push(handler);
151
+ },
152
+
153
+ resolve_templates(cluster, templates) {
154
+ return templates.map((template) => {
155
+ const values = get_template_values(cluster, template.metadata.name);
156
+ const enabled = is_template_enabled(cluster, template.metadata.name);
157
+
158
+ return {
159
+ template,
160
+ values,
161
+ enabled,
162
+ };
163
+ });
164
+ },
165
+
166
+ async generate(cluster, templates, generate_options = {}) {
167
+ const output_dir = generate_options.output_dir ?? './output';
168
+ const skip_validation = generate_options.skip_validation ?? false;
169
+
170
+ // Validate dependency graph before generation (unless skipped)
171
+ if (!skip_validation) {
172
+ const validation_result = validate_dependencies(templates);
173
+ if (!validation_result.success) {
174
+ return validation_result;
175
+ }
176
+ }
177
+
178
+ // Detect source kind and repository name
179
+ const source_kind = cluster.spec.oci ? 'OCIRepository' : 'GitRepository';
180
+ const source_repository_name = git_repository_name;
181
+
182
+ // Run before_generate hook
183
+ await run_hooks('before_generate', { cluster });
184
+
185
+ // Resolve templates
186
+ const resolved_templates = this.resolve_templates(cluster, templates);
187
+
188
+ // Run after_resolve_template hook for each
189
+ for (const resolved of resolved_templates) {
190
+ await run_hooks('after_resolve_template', {
191
+ cluster,
192
+ templates: [resolved],
193
+ });
194
+ }
195
+
196
+ // Generate kustomizations
197
+ const generated_kustomizations: GeneratedKustomizationType[] = [];
198
+
199
+ for (const resolved of resolved_templates) {
200
+ if (!resolved.enabled) {
201
+ continue;
202
+ }
203
+
204
+ for (const kustomization of resolved.template.spec.kustomizations) {
205
+ const resolved_kustomization = resolve_kustomization(
206
+ resolved.template,
207
+ kustomization,
208
+ resolved.values,
209
+ );
210
+
211
+ // Generate Flux resource with configurable namespace
212
+ const flux_kustomization = generate_flux_kustomization(
213
+ resolved_kustomization,
214
+ source_repository_name,
215
+ source_kind,
216
+ );
217
+
218
+ // Override namespace to configured value
219
+ flux_kustomization.metadata.namespace = flux_namespace;
220
+
221
+ // Run after_generate_kustomization hook
222
+ await run_hooks('after_generate_kustomization', {
223
+ cluster,
224
+ kustomization: resolved_kustomization,
225
+ flux_kustomization,
226
+ });
227
+
228
+ generated_kustomizations.push({
229
+ name: flux_kustomization.metadata.name,
230
+ template: resolved.template.metadata.name,
231
+ path: flux_kustomization.spec.path,
232
+ flux_kustomization,
233
+ });
234
+ }
235
+ }
236
+
237
+ const result: GenerationResultType = {
238
+ cluster: cluster.metadata.name,
239
+ output_dir,
240
+ kustomizations: generated_kustomizations,
241
+ };
242
+
243
+ // Generate OCIRepository if using OCI mode
244
+ if (cluster.spec.oci) {
245
+ result.oci_repository = generate_flux_oci_repository(
246
+ cluster,
247
+ cluster.spec.oci,
248
+ source_repository_name,
249
+ flux_namespace,
250
+ );
251
+ }
252
+
253
+ // Run after_generate hook
254
+ await run_hooks('after_generate', {
255
+ cluster,
256
+ templates: resolved_templates,
257
+ result,
258
+ });
259
+
260
+ return success(result);
261
+ },
262
+
263
+ generate_plugin_resources(cluster, templates) {
264
+ if (!registry) {
265
+ return success([]);
266
+ }
267
+
268
+ const all_resources: GeneratedResourceType[] = [];
269
+
270
+ // Generate resources from plugins
271
+ for (const generator of registry.get_resource_generators()) {
272
+ for (const resolved of templates) {
273
+ if (!resolved.enabled) {
274
+ continue;
275
+ }
276
+
277
+ const ctx: PluginContextType = {
278
+ cluster,
279
+ template: resolved.template,
280
+ };
281
+
282
+ const result = generator.generate(ctx);
283
+ if (!result.success) {
284
+ return result;
285
+ }
286
+
287
+ all_resources.push(...result.value);
288
+ }
289
+ }
290
+
291
+ return success(all_resources);
292
+ },
293
+
294
+ async write(result) {
295
+ return write_generation_result(result);
296
+ },
297
+ };
298
+ }
299
+
300
+ // Re-export commonly used utilities
301
+ export { serialize_resource, serialize_resources };
302
+ export { generate_namespace_resources };
303
+ export {
304
+ DEFAULT_INTERVAL,
305
+ DEFAULT_TIMEOUT,
306
+ generate_depends_on,
307
+ generate_flux_kustomization,
308
+ generate_flux_name,
309
+ generate_health_checks,
310
+ resolve_kustomization,
311
+ };
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './types.js';
2
+ export * from './flux.js';
3
+ export * from './substitution.js';
4
+ export * from './namespace.js';
5
+ export * from './output.js';
6
+ export * from './generator.js';
7
+ export * from './validation/index.js';
8
+ export * from './external-substitutions.js';