@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 +45 -0
- package/src/external-substitutions.ts +71 -0
- package/src/flux.ts +233 -0
- package/src/generator.ts +311 -0
- package/src/index.ts +8 -0
- package/src/namespace.ts +121 -0
- package/src/output.ts +208 -0
- package/src/substitution.ts +140 -0
- package/src/types.ts +120 -0
- package/src/validation/cycle-detection.ts +111 -0
- package/src/validation/graph.ts +125 -0
- package/src/validation/index.ts +92 -0
- package/src/validation/reference.ts +126 -0
- package/src/validation/types.ts +107 -0
package/src/namespace.ts
ADDED
|
@@ -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
|
+
}
|