@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.
@@ -0,0 +1,121 @@
1
+ import type { KustomizationType, TemplateType } from '@kustodian/schema';
2
+
3
+ import type { ResolvedTemplateType } from './types.js';
4
+
5
+ /**
6
+ * System namespaces that should not be generated.
7
+ */
8
+ export const SYSTEM_NAMESPACES = new Set([
9
+ 'default',
10
+ 'flux-system',
11
+ 'kube-system',
12
+ 'kube-public',
13
+ 'kube-node-lease',
14
+ ]);
15
+
16
+ /**
17
+ * Namespace resource type.
18
+ */
19
+ export interface NamespaceResourceType {
20
+ apiVersion: 'v1';
21
+ kind: 'Namespace';
22
+ metadata: {
23
+ name: string;
24
+ labels?: Record<string, string>;
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Extracts the namespace from a kustomization.
30
+ * Returns undefined if no namespace is configured.
31
+ */
32
+ export function get_kustomization_namespace(kustomization: KustomizationType): string | undefined {
33
+ return kustomization.namespace?.default;
34
+ }
35
+
36
+ /**
37
+ * Extracts all namespaces from a template.
38
+ */
39
+ export function get_template_namespaces(template: TemplateType): string[] {
40
+ const namespaces = new Set<string>();
41
+
42
+ for (const kustomization of template.spec.kustomizations) {
43
+ const namespace = get_kustomization_namespace(kustomization);
44
+ if (namespace) {
45
+ namespaces.add(namespace);
46
+ }
47
+ }
48
+
49
+ return Array.from(namespaces);
50
+ }
51
+
52
+ /**
53
+ * Extracts all namespaces from resolved templates.
54
+ * Filters out disabled templates.
55
+ */
56
+ export function collect_namespaces(templates: ResolvedTemplateType[]): string[] {
57
+ const namespaces = new Set<string>();
58
+
59
+ for (const resolved of templates) {
60
+ if (!resolved.enabled) {
61
+ continue;
62
+ }
63
+
64
+ for (const namespace of get_template_namespaces(resolved.template)) {
65
+ namespaces.add(namespace);
66
+ }
67
+ }
68
+
69
+ return Array.from(namespaces).sort();
70
+ }
71
+
72
+ /**
73
+ * Filters out system namespaces from a list.
74
+ */
75
+ export function filter_system_namespaces(namespaces: string[]): string[] {
76
+ return namespaces.filter((ns) => !is_system_namespace(ns));
77
+ }
78
+
79
+ /**
80
+ * Checks if a namespace is a system namespace.
81
+ */
82
+ export function is_system_namespace(namespace: string): boolean {
83
+ if (SYSTEM_NAMESPACES.has(namespace)) {
84
+ return true;
85
+ }
86
+
87
+ // Also filter kube-* namespaces
88
+ return namespace.startsWith('kube-');
89
+ }
90
+
91
+ /**
92
+ * Creates a namespace resource.
93
+ */
94
+ export function create_namespace_resource(
95
+ name: string,
96
+ labels?: Record<string, string>,
97
+ ): NamespaceResourceType {
98
+ const metadata: NamespaceResourceType['metadata'] = { name };
99
+ if (labels !== undefined) {
100
+ metadata.labels = labels;
101
+ }
102
+ return {
103
+ apiVersion: 'v1',
104
+ kind: 'Namespace',
105
+ metadata,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Generates namespace resources for all namespaces in templates.
111
+ * Filters out system namespaces.
112
+ */
113
+ export function generate_namespace_resources(
114
+ templates: ResolvedTemplateType[],
115
+ labels?: Record<string, string>,
116
+ ): NamespaceResourceType[] {
117
+ const namespaces = collect_namespaces(templates);
118
+ const filtered = filter_system_namespaces(namespaces);
119
+
120
+ return filtered.map((name) => create_namespace_resource(name, labels));
121
+ }
package/src/output.ts ADDED
@@ -0,0 +1,208 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+
4
+ import { Errors, type ResultType, failure, success } from '@kustodian/core';
5
+ import type { KustodianErrorType } from '@kustodian/core';
6
+ import YAML from 'yaml';
7
+
8
+ import type { FluxKustomizationType, GenerationResultType } from './types.js';
9
+
10
+ /**
11
+ * Output format options.
12
+ */
13
+ export type OutputFormatType = 'yaml' | 'json';
14
+
15
+ /**
16
+ * Options for writing output.
17
+ */
18
+ export interface WriteOptionsType {
19
+ format?: OutputFormatType;
20
+ create_dirs?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Serializes a resource to YAML or JSON.
25
+ */
26
+ export function serialize_resource<T>(resource: T, format: OutputFormatType = 'yaml'): string {
27
+ if (format === 'json') {
28
+ return JSON.stringify(resource, null, 2);
29
+ }
30
+
31
+ return YAML.stringify(resource, {
32
+ indent: 2,
33
+ lineWidth: 0,
34
+ singleQuote: false,
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Serializes multiple resources to a single YAML document with separators.
40
+ */
41
+ export function serialize_resources<T>(resources: T[], format: OutputFormatType = 'yaml'): string {
42
+ if (format === 'json') {
43
+ return JSON.stringify(resources, null, 2);
44
+ }
45
+
46
+ return resources.map((r) => serialize_resource(r, format)).join('---\n');
47
+ }
48
+
49
+ /**
50
+ * Ensures a directory exists, creating it if necessary.
51
+ */
52
+ export async function ensure_directory(
53
+ dir_path: string,
54
+ ): Promise<ResultType<void, KustodianErrorType>> {
55
+ try {
56
+ await fs.mkdir(dir_path, { recursive: true });
57
+ return success(undefined);
58
+ } catch (error) {
59
+ return failure(Errors.file_write_error(dir_path, error));
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Writes content to a file.
65
+ */
66
+ export async function write_file(
67
+ file_path: string,
68
+ content: string,
69
+ options: WriteOptionsType = {},
70
+ ): Promise<ResultType<void, KustodianErrorType>> {
71
+ const { create_dirs = true } = options;
72
+
73
+ if (create_dirs) {
74
+ const dir = path.dirname(file_path);
75
+ const dir_result = await ensure_directory(dir);
76
+ if (!dir_result.success) {
77
+ return dir_result;
78
+ }
79
+ }
80
+
81
+ try {
82
+ await fs.writeFile(file_path, content, 'utf-8');
83
+ return success(undefined);
84
+ } catch (error) {
85
+ return failure(Errors.file_write_error(file_path, error));
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Writes a Flux Kustomization resource to a file.
91
+ */
92
+ export async function write_flux_kustomization(
93
+ kustomization: FluxKustomizationType,
94
+ output_dir: string,
95
+ options: WriteOptionsType = {},
96
+ ): Promise<ResultType<string, KustodianErrorType>> {
97
+ const format = options.format ?? 'yaml';
98
+ const ext = format === 'json' ? 'json' : 'yaml';
99
+ const file_path = path.join(output_dir, `${kustomization.metadata.name}.${ext}`);
100
+
101
+ const content = serialize_resource(kustomization, format);
102
+ const result = await write_file(file_path, content, options);
103
+
104
+ if (!result.success) {
105
+ return result;
106
+ }
107
+
108
+ return success(file_path);
109
+ }
110
+
111
+ /**
112
+ * Writes all generated kustomizations to a structured output directory.
113
+ *
114
+ * Output structure:
115
+ * ```
116
+ * {output_dir}/
117
+ * ├── flux-system/
118
+ * │ ├── kustomization.yaml # Root kustomization referencing all templates
119
+ * │ └── oci-repository.yaml # OCI source (if configured)
120
+ * └── templates/
121
+ * ├── {template-name}/
122
+ * │ └── {kustomization-name}.yaml
123
+ * └── ...
124
+ * ```
125
+ */
126
+ export async function write_generation_result(
127
+ result: GenerationResultType,
128
+ options: WriteOptionsType = {},
129
+ ): Promise<ResultType<string[], KustodianErrorType>> {
130
+ const format = options.format ?? 'yaml';
131
+ const ext = format === 'json' ? 'json' : 'yaml';
132
+ const written_files: string[] = [];
133
+
134
+ const flux_system_dir = path.join(result.output_dir, 'flux-system');
135
+ const templates_dir = path.join(result.output_dir, 'templates');
136
+
137
+ // Write OCIRepository to flux-system directory if present
138
+ if (result.oci_repository) {
139
+ const oci_path = path.join(flux_system_dir, `oci-repository.${ext}`);
140
+ const oci_content = serialize_resource(result.oci_repository, format);
141
+ const oci_result = await write_file(oci_path, oci_content, options);
142
+
143
+ if (!oci_result.success) {
144
+ return oci_result;
145
+ }
146
+
147
+ written_files.push(oci_path);
148
+ }
149
+
150
+ // Write each kustomization to templates/{template-name}/{kustomization-name}.yaml
151
+ for (const generated of result.kustomizations) {
152
+ const template_dir = path.join(templates_dir, generated.template);
153
+ const file_path = path.join(template_dir, `${generated.flux_kustomization.metadata.name}.${ext}`);
154
+
155
+ const content = serialize_resource(generated.flux_kustomization, format);
156
+ const file_result = await write_file(file_path, content, options);
157
+
158
+ if (!file_result.success) {
159
+ return file_result;
160
+ }
161
+
162
+ written_files.push(file_path);
163
+ }
164
+
165
+ // Write root kustomization.yaml in flux-system directory
166
+ const kustomization_path = path.join(flux_system_dir, 'kustomization.yaml');
167
+ const resources: string[] = [];
168
+
169
+ // Add OCI repository reference if present
170
+ if (result.oci_repository) {
171
+ resources.push(`oci-repository.${ext}`);
172
+ }
173
+
174
+ // Add references to all template kustomizations with relative paths
175
+ resources.push(
176
+ ...result.kustomizations.map((k) => {
177
+ return `../templates/${k.template}/${k.flux_kustomization.metadata.name}.${ext}`;
178
+ }),
179
+ );
180
+
181
+ // Sort resources for deterministic output
182
+ resources.sort();
183
+
184
+ const kustomization_content = serialize_resource(
185
+ {
186
+ apiVersion: 'kustomize.config.k8s.io/v1beta1',
187
+ kind: 'Kustomization',
188
+ resources,
189
+ },
190
+ 'yaml',
191
+ );
192
+
193
+ const kustomization_result = await write_file(kustomization_path, kustomization_content, options);
194
+ if (!kustomization_result.success) {
195
+ return kustomization_result;
196
+ }
197
+
198
+ written_files.push(kustomization_path);
199
+
200
+ return success(written_files);
201
+ }
202
+
203
+ /**
204
+ * Gets the file extension for a format.
205
+ */
206
+ export function get_extension(format: OutputFormatType): string {
207
+ return format === 'json' ? 'json' : 'yaml';
208
+ }
@@ -0,0 +1,140 @@
1
+ import type { KustomizationType } from '@kustodian/schema';
2
+
3
+ /**
4
+ * Pattern for matching substitution variables: ${variable_name}
5
+ */
6
+ export const SUBSTITUTION_PATTERN = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
7
+
8
+ /**
9
+ * Result of validating substitution values.
10
+ */
11
+ export interface SubstitutionValidationResultType {
12
+ valid: boolean;
13
+ missing: string[];
14
+ unused: string[];
15
+ }
16
+
17
+ /**
18
+ * Extracts variable names from a string containing ${var} patterns.
19
+ */
20
+ export function extract_variables(text: string): string[] {
21
+ const matches = text.matchAll(SUBSTITUTION_PATTERN);
22
+ const variables = new Set<string>();
23
+ for (const match of matches) {
24
+ const variable_name = match[1];
25
+ if (variable_name !== undefined) {
26
+ variables.add(variable_name);
27
+ }
28
+ }
29
+ return Array.from(variables);
30
+ }
31
+
32
+ /**
33
+ * Applies substitution values to a string containing ${var} patterns.
34
+ */
35
+ export function substitute_string(text: string, values: Record<string, string>): string {
36
+ return text.replace(SUBSTITUTION_PATTERN, (match, variable_name) => {
37
+ return values[variable_name] ?? match;
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Applies substitution values to an object recursively.
43
+ * Only processes string values within the object.
44
+ */
45
+ export function substitute_object<T>(obj: T, values: Record<string, string>): T {
46
+ if (typeof obj === 'string') {
47
+ return substitute_string(obj, values) as T;
48
+ }
49
+
50
+ if (Array.isArray(obj)) {
51
+ return obj.map((item) => substitute_object(item, values)) as T;
52
+ }
53
+
54
+ if (obj !== null && typeof obj === 'object') {
55
+ const result: Record<string, unknown> = {};
56
+ for (const [key, value] of Object.entries(obj)) {
57
+ result[key] = substitute_object(value, values);
58
+ }
59
+ return result as T;
60
+ }
61
+
62
+ return obj;
63
+ }
64
+
65
+ /**
66
+ * Collects substitution values from a kustomization definition and cluster values.
67
+ * Returns a record of variable name to value.
68
+ */
69
+ export function collect_substitution_values(
70
+ kustomization: KustomizationType,
71
+ cluster_values: Record<string, string> = {},
72
+ ): Record<string, string> {
73
+ const values: Record<string, string> = {};
74
+
75
+ for (const sub of kustomization.substitutions ?? []) {
76
+ // Cluster value takes precedence over default
77
+ const value = cluster_values[sub.name] ?? sub.default;
78
+ if (value !== undefined) {
79
+ values[sub.name] = value;
80
+ }
81
+ }
82
+
83
+ return values;
84
+ }
85
+
86
+ /**
87
+ * Gets all defined substitution names from a kustomization.
88
+ */
89
+ export function get_defined_substitutions(kustomization: KustomizationType): string[] {
90
+ return (kustomization.substitutions ?? []).map((sub) => sub.name);
91
+ }
92
+
93
+ /**
94
+ * Gets all required substitution names (those without defaults) from a kustomization.
95
+ */
96
+ export function get_required_substitutions(kustomization: KustomizationType): string[] {
97
+ return (kustomization.substitutions ?? [])
98
+ .filter((sub) => sub.default === undefined)
99
+ .map((sub) => sub.name);
100
+ }
101
+
102
+ /**
103
+ * Validates that all required substitutions have values.
104
+ */
105
+ export function validate_substitutions(
106
+ kustomization: KustomizationType,
107
+ cluster_values: Record<string, string> = {},
108
+ ): SubstitutionValidationResultType {
109
+ const required = get_required_substitutions(kustomization);
110
+ const defined = get_defined_substitutions(kustomization);
111
+ const provided = Object.keys(cluster_values);
112
+
113
+ // Find missing required values
114
+ const missing = required.filter((name) => cluster_values[name] === undefined);
115
+
116
+ // Find unused provided values
117
+ const unused = provided.filter((name) => !defined.includes(name));
118
+
119
+ return {
120
+ valid: missing.length === 0,
121
+ missing,
122
+ unused,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Generates the postBuild.substitute object for a Flux Kustomization.
128
+ * Only includes values that have actual values (not undefined).
129
+ */
130
+ export function generate_flux_substitutions(
131
+ values: Record<string, string>,
132
+ ): Record<string, string> | undefined {
133
+ const entries = Object.entries(values).filter(([, value]) => value !== undefined);
134
+
135
+ if (entries.length === 0) {
136
+ return undefined;
137
+ }
138
+
139
+ return Object.fromEntries(entries);
140
+ }
package/src/types.ts ADDED
@@ -0,0 +1,120 @@
1
+ import type { ClusterType, KustomizationType, TemplateType } from '@kustodian/schema';
2
+
3
+ /**
4
+ * Options for the generation process.
5
+ */
6
+ export interface GenerateOptionsType {
7
+ dry_run?: boolean;
8
+ output_dir?: string;
9
+ skip_validation?: boolean;
10
+ }
11
+
12
+ /**
13
+ * A resolved template with its values.
14
+ */
15
+ export interface ResolvedTemplateType {
16
+ template: TemplateType;
17
+ values: Record<string, string>;
18
+ enabled: boolean;
19
+ }
20
+
21
+ /**
22
+ * A resolved kustomization with substitution values applied.
23
+ */
24
+ export interface ResolvedKustomizationType {
25
+ template: TemplateType;
26
+ kustomization: KustomizationType;
27
+ values: Record<string, string>;
28
+ namespace: string;
29
+ }
30
+
31
+ /**
32
+ * Generation context for a cluster.
33
+ */
34
+ export interface GenerationContextType {
35
+ cluster: ClusterType;
36
+ templates: ResolvedTemplateType[];
37
+ output_dir: string;
38
+ }
39
+
40
+ /**
41
+ * Result of a single kustomization generation.
42
+ */
43
+ export interface GeneratedKustomizationType {
44
+ name: string;
45
+ template: string;
46
+ path: string;
47
+ flux_kustomization: FluxKustomizationType;
48
+ }
49
+
50
+ /**
51
+ * Result of the full generation process.
52
+ */
53
+ export interface GenerationResultType {
54
+ cluster: string;
55
+ output_dir: string;
56
+ kustomizations: GeneratedKustomizationType[];
57
+ oci_repository?: FluxOCIRepositoryType;
58
+ }
59
+
60
+ /**
61
+ * Flux OCIRepository resource type.
62
+ */
63
+ export interface FluxOCIRepositoryType {
64
+ apiVersion: 'source.toolkit.fluxcd.io/v1';
65
+ kind: 'OCIRepository';
66
+ metadata: {
67
+ name: string;
68
+ namespace: string;
69
+ };
70
+ spec: {
71
+ interval: string;
72
+ url: string;
73
+ ref: {
74
+ tag?: string;
75
+ digest?: string;
76
+ semver?: string;
77
+ };
78
+ provider?: 'aws' | 'azure' | 'gcp' | 'generic';
79
+ secretRef?: {
80
+ name: string;
81
+ };
82
+ insecure?: boolean;
83
+ timeout?: string;
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Flux Kustomization resource type.
89
+ */
90
+ export interface FluxKustomizationType {
91
+ apiVersion: 'kustomize.toolkit.fluxcd.io/v1';
92
+ kind: 'Kustomization';
93
+ metadata: {
94
+ name: string;
95
+ namespace: string;
96
+ };
97
+ spec: {
98
+ interval: string;
99
+ targetNamespace?: string;
100
+ path: string;
101
+ prune: boolean;
102
+ wait: boolean;
103
+ timeout?: string;
104
+ retryInterval?: string;
105
+ sourceRef: {
106
+ kind: 'GitRepository' | 'OCIRepository';
107
+ name: string;
108
+ };
109
+ dependsOn?: Array<{ name: string }>;
110
+ postBuild?: {
111
+ substitute?: Record<string, string>;
112
+ };
113
+ healthChecks?: Array<{
114
+ apiVersion: string;
115
+ kind: string;
116
+ name: string;
117
+ namespace: string;
118
+ }>;
119
+ };
120
+ }
@@ -0,0 +1,111 @@
1
+ import type { CycleDetectionResultType, CycleErrorType, GraphNodeType } from './types.js';
2
+
3
+ /**
4
+ * Visit state for DFS traversal.
5
+ */
6
+ enum VisitState {
7
+ /** Node has not been visited yet */
8
+ Unvisited = 0,
9
+ /** Node is currently being visited (in the DFS stack) */
10
+ Visiting = 1,
11
+ /** Node has been fully processed */
12
+ Visited = 2,
13
+ }
14
+
15
+ /**
16
+ * Formats a cycle into a human-readable message.
17
+ *
18
+ * @param cycle - Array of node IDs forming the cycle
19
+ * @returns Formatted cycle message
20
+ */
21
+ function format_cycle_message(cycle: string[]): string {
22
+ const cycle_str = cycle.join(' → ');
23
+ return `Dependency cycle detected: ${cycle_str}`;
24
+ }
25
+
26
+ /**
27
+ * Detects cycles in the dependency graph using depth-first search (DFS).
28
+ *
29
+ * Uses a three-color algorithm:
30
+ * - Unvisited (white): Node hasn't been processed
31
+ * - Visiting (gray): Node is currently in the DFS stack
32
+ * - Visited (black): Node and all its descendants have been processed
33
+ *
34
+ * If we encounter a Visiting node during DFS, we've found a cycle.
35
+ *
36
+ * Also performs topological sorting if no cycles are found.
37
+ *
38
+ * @param nodes - Map of graph nodes
39
+ * @returns Cycles found and topological order (if no cycles)
40
+ */
41
+ export function detect_cycles(nodes: Map<string, GraphNodeType>): CycleDetectionResultType {
42
+ const state = new Map<string, VisitState>();
43
+ const cycles: CycleErrorType[] = [];
44
+ const topological_order: string[] = [];
45
+
46
+ // Initialize all nodes as unvisited
47
+ for (const id of nodes.keys()) {
48
+ state.set(id, VisitState.Unvisited);
49
+ }
50
+
51
+ /**
52
+ * DFS traversal function.
53
+ *
54
+ * @param node_id - Current node being visited
55
+ * @param path - Current path from DFS root to this node
56
+ */
57
+ function dfs(node_id: string, path: string[]): void {
58
+ state.set(node_id, VisitState.Visiting);
59
+ path.push(node_id);
60
+
61
+ const node = nodes.get(node_id);
62
+ if (node) {
63
+ for (const dep_id of node.dependencies) {
64
+ const dep_state = state.get(dep_id);
65
+
66
+ if (dep_state === VisitState.Visiting) {
67
+ // Found a cycle - extract it from the path
68
+ const cycle_start_index = path.indexOf(dep_id);
69
+ const cycle = [...path.slice(cycle_start_index), dep_id];
70
+ cycles.push({
71
+ type: 'cycle',
72
+ cycle,
73
+ message: format_cycle_message(cycle),
74
+ });
75
+ } else if (dep_state === VisitState.Unvisited) {
76
+ dfs(dep_id, path);
77
+ }
78
+ // If Visited, skip - already fully processed
79
+ }
80
+ }
81
+
82
+ state.set(node_id, VisitState.Visited);
83
+ path.pop();
84
+
85
+ // Add to end for post-order (dependencies come before dependents)
86
+ topological_order.push(node_id);
87
+ }
88
+
89
+ // Visit all nodes (handles disconnected components)
90
+ for (const node_id of nodes.keys()) {
91
+ if (state.get(node_id) === VisitState.Unvisited) {
92
+ dfs(node_id, []);
93
+ }
94
+ }
95
+
96
+ return {
97
+ cycles,
98
+ topological_order: cycles.length === 0 ? topological_order : null,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Checks if a graph has cycles.
104
+ *
105
+ * @param nodes - Map of graph nodes
106
+ * @returns True if the graph has at least one cycle
107
+ */
108
+ export function has_cycles(nodes: Map<string, GraphNodeType>): boolean {
109
+ const result = detect_cycles(nodes);
110
+ return result.cycles.length > 0;
111
+ }