@kustodian/cli 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,299 @@
1
+ import * as path from 'node:path';
2
+ import { success } from '@kustodian/core';
3
+ import { file_exists, write_file, write_yaml_file } from '@kustodian/loader';
4
+
5
+ import { define_command } from '../command.js';
6
+
7
+ /**
8
+ * Init command - initializes a new Kustodian project.
9
+ */
10
+ export const init_command = define_command({
11
+ name: 'init',
12
+ description: 'Initialize a new Kustodian project',
13
+ arguments: [
14
+ {
15
+ name: 'name',
16
+ description: 'Project name (creates directory)',
17
+ required: true,
18
+ },
19
+ ],
20
+ options: [
21
+ {
22
+ name: 'force',
23
+ short: 'f',
24
+ description: 'Overwrite existing files',
25
+ type: 'boolean',
26
+ default_value: false,
27
+ },
28
+ ],
29
+ handler: async (ctx) => {
30
+ const project_name = ctx.args[0];
31
+ const force = ctx.options['force'] as boolean;
32
+
33
+ if (!project_name) {
34
+ console.error('Error: Project name is required');
35
+ return {
36
+ success: false as const,
37
+ error: { code: 'INVALID_ARGS', message: 'Project name is required' },
38
+ };
39
+ }
40
+
41
+ const project_dir = path.resolve(project_name);
42
+
43
+ // Check if directory exists
44
+ if (!force && (await file_exists(project_dir))) {
45
+ console.error(`Error: Directory '${project_name}' already exists. Use --force to overwrite.`);
46
+ return {
47
+ success: false as const,
48
+ error: { code: 'ALREADY_EXISTS', message: `Directory '${project_name}' already exists` },
49
+ };
50
+ }
51
+
52
+ console.log(`Creating project: ${project_name}`);
53
+
54
+ // Create kustodian.yaml
55
+ const kustodian_config = {
56
+ apiVersion: 'kustodian.io/v1',
57
+ kind: 'Project',
58
+ metadata: {
59
+ name: project_name,
60
+ },
61
+ spec: {
62
+ flux_namespace: 'flux-system',
63
+ oci_repository: 'flux-system',
64
+ },
65
+ };
66
+
67
+ let result = await write_yaml_file(path.join(project_dir, 'kustodian.yaml'), kustodian_config);
68
+ if (!result.success) {
69
+ console.error(`Error creating kustodian.yaml: ${result.error.message}`);
70
+ return result;
71
+ }
72
+ console.log(' Created kustodian.yaml');
73
+
74
+ // Create example template
75
+ const example_template = {
76
+ apiVersion: 'kustodian.io/v1',
77
+ kind: 'Template',
78
+ metadata: {
79
+ name: 'example',
80
+ },
81
+ spec: {
82
+ kustomizations: [
83
+ {
84
+ name: 'app',
85
+ path: './app',
86
+ namespace: {
87
+ default: 'example',
88
+ },
89
+ substitutions: [
90
+ {
91
+ name: 'replicas',
92
+ default: '1',
93
+ },
94
+ ],
95
+ },
96
+ ],
97
+ },
98
+ };
99
+
100
+ result = await write_yaml_file(
101
+ path.join(project_dir, 'templates', 'example', 'template.yaml'),
102
+ example_template,
103
+ );
104
+ if (!result.success) {
105
+ console.error(`Error creating template: ${result.error.message}`);
106
+ return result;
107
+ }
108
+ console.log(' Created templates/example/template.yaml');
109
+
110
+ // Create example kustomization.yaml
111
+ const example_kustomization = `apiVersion: kustomize.config.k8s.io/v1beta1
112
+ kind: Kustomization
113
+ resources:
114
+ - deployment.yaml
115
+ `;
116
+
117
+ result = await write_file(
118
+ path.join(project_dir, 'templates', 'example', 'app', 'kustomization.yaml'),
119
+ example_kustomization,
120
+ );
121
+ if (!result.success) {
122
+ console.error(`Error creating kustomization.yaml: ${result.error.message}`);
123
+ return result;
124
+ }
125
+ console.log(' Created templates/example/app/kustomization.yaml');
126
+
127
+ // Create example deployment
128
+ const example_deployment = `apiVersion: apps/v1
129
+ kind: Deployment
130
+ metadata:
131
+ name: example
132
+ spec:
133
+ replicas: \${replicas}
134
+ selector:
135
+ matchLabels:
136
+ app: example
137
+ template:
138
+ metadata:
139
+ labels:
140
+ app: example
141
+ spec:
142
+ containers:
143
+ - name: example
144
+ image: nginx:latest
145
+ ports:
146
+ - containerPort: 80
147
+ `;
148
+
149
+ result = await write_file(
150
+ path.join(project_dir, 'templates', 'example', 'app', 'deployment.yaml'),
151
+ example_deployment,
152
+ );
153
+ if (!result.success) {
154
+ console.error(`Error creating deployment.yaml: ${result.error.message}`);
155
+ return result;
156
+ }
157
+ console.log(' Created templates/example/app/deployment.yaml');
158
+
159
+ // Create example cluster
160
+ const example_cluster = {
161
+ apiVersion: 'kustodian.io/v1',
162
+ kind: 'Cluster',
163
+ metadata: {
164
+ name: 'local',
165
+ },
166
+ spec: {
167
+ domain: 'local.example.com',
168
+ oci: {
169
+ registry: 'ghcr.io',
170
+ repository: `your-org/${project_name}`,
171
+ tag_strategy: 'git-sha',
172
+ secret_ref: 'ghcr-auth',
173
+ },
174
+ templates: [
175
+ {
176
+ name: 'example',
177
+ enabled: true,
178
+ values: {
179
+ replicas: '2',
180
+ },
181
+ },
182
+ ],
183
+ },
184
+ };
185
+
186
+ result = await write_yaml_file(
187
+ path.join(project_dir, 'clusters', 'local', 'cluster.yaml'),
188
+ example_cluster,
189
+ );
190
+ if (!result.success) {
191
+ console.error(`Error creating cluster: ${result.error.message}`);
192
+ return result;
193
+ }
194
+ console.log(' Created clusters/local/cluster.yaml');
195
+
196
+ // Create .gitignore
197
+ const gitignore = `# Output
198
+ output/
199
+
200
+ # Dependencies
201
+ node_modules/
202
+
203
+ # Build artifacts
204
+ dist/
205
+ *.tsbuildinfo
206
+
207
+ # OS files
208
+ .DS_Store
209
+ `;
210
+
211
+ result = await write_file(path.join(project_dir, '.gitignore'), gitignore);
212
+ if (!result.success) {
213
+ console.error(`Error creating .gitignore: ${result.error.message}`);
214
+ return result;
215
+ }
216
+ console.log(' Created .gitignore');
217
+
218
+ // Create GitHub Actions workflow
219
+ const workflow_yaml = `name: Deploy
220
+
221
+ on:
222
+ push:
223
+ branches: [main]
224
+ pull_request:
225
+ branches: [main]
226
+
227
+ jobs:
228
+ deploy:
229
+ runs-on: ubuntu-latest
230
+ permissions:
231
+ contents: read
232
+ packages: write
233
+
234
+ steps:
235
+ - name: Checkout
236
+ uses: actions/checkout@v4
237
+
238
+ - name: Setup Flux CLI
239
+ uses: fluxcd/flux2/action@main
240
+
241
+ - name: Login to GitHub Container Registry
242
+ uses: docker/login-action@v3
243
+ with:
244
+ registry: ghcr.io
245
+ username: \${{ github.actor }}
246
+ password: \${{ secrets.GITHUB_TOKEN }}
247
+
248
+ - name: Install kustodian
249
+ run: |
250
+ # TODO: Replace with actual installation method
251
+ # npm install -g @kustodian/cli
252
+ echo "Install kustodian CLI here"
253
+
254
+ - name: Validate configuration
255
+ run: |
256
+ # TODO: Run validation once kustodian is installed
257
+ echo "Validation step"
258
+
259
+ - name: Push artifacts (on main branch)
260
+ if: github.ref == 'refs/heads/main'
261
+ run: |
262
+ # TODO: Replace with actual push command once kustodian is installed
263
+ # for cluster in clusters/*/cluster.yaml; do
264
+ # cluster_name=$(basename $(dirname $cluster))
265
+ # echo "Pushing $cluster_name..."
266
+ # kustodian push --cluster $cluster_name
267
+ # done
268
+ echo "Push step (configure after installing kustodian)"
269
+ `;
270
+
271
+ result = await write_file(
272
+ path.join(project_dir, '.github', 'workflows', 'deploy.yaml'),
273
+ workflow_yaml,
274
+ );
275
+ if (!result.success) {
276
+ console.error(`Error creating workflow: ${result.error.message}`);
277
+ return result;
278
+ }
279
+ console.log(' Created .github/workflows/deploy.yaml');
280
+
281
+ console.log(`\n✓ Project '${project_name}' created successfully`);
282
+ console.log('\nNext steps:');
283
+ console.log(` 1. cd ${project_name}`);
284
+ console.log(
285
+ ' 2. Initialize git repository: git init && git add . && git commit -m "Initial commit"',
286
+ );
287
+ console.log(' 3. Update clusters/local/cluster.yaml with your OCI registry details');
288
+ console.log(' 4. Create registry authentication secret in your cluster:');
289
+ console.log(' kubectl create secret docker-registry ghcr-auth \\');
290
+ console.log(' --docker-server=ghcr.io \\');
291
+ console.log(' --docker-username=<your-username> \\');
292
+ console.log(' --docker-password=<your-token>');
293
+ console.log(' 5. Generate and push: kustodian push --cluster local --dry-run');
294
+ console.log(' 6. Review output, then: kustodian push --cluster local');
295
+ console.log(' 7. Apply Flux resources: kubectl apply -f output/local/');
296
+
297
+ return success(undefined);
298
+ },
299
+ });
@@ -0,0 +1,274 @@
1
+ import * as path from 'node:path';
2
+
3
+ import { failure, is_success, success } from '@kustodian/core';
4
+ import { read_yaml_file, write_yaml_file } from '@kustodian/loader';
5
+ import { find_project_root, load_project } from '@kustodian/loader';
6
+ import {
7
+ check_version_update,
8
+ create_client_for_image,
9
+ filter_semver_tags,
10
+ parse_image_reference,
11
+ } from '@kustodian/registry';
12
+ import { type VersionSubstitutionType, is_version_substitution } from '@kustodian/schema';
13
+
14
+ import { define_command } from '../command.js';
15
+
16
+ /**
17
+ * Update result for a single substitution.
18
+ */
19
+ interface UpdateResultType {
20
+ cluster: string;
21
+ template: string;
22
+ substitution: string;
23
+ image: string;
24
+ current: string;
25
+ latest: string;
26
+ constraint?: string | undefined;
27
+ updated: boolean;
28
+ }
29
+
30
+ /**
31
+ * Update command - checks and updates version substitutions.
32
+ */
33
+ export const update_command = define_command({
34
+ name: 'update',
35
+ description: 'Check and update image version substitutions',
36
+ options: [
37
+ {
38
+ name: 'cluster',
39
+ short: 'c',
40
+ description: 'Cluster to update values for',
41
+ type: 'string',
42
+ required: true,
43
+ },
44
+ {
45
+ name: 'project',
46
+ short: 'p',
47
+ description: 'Path to project root',
48
+ type: 'string',
49
+ },
50
+ {
51
+ name: 'dry-run',
52
+ short: 'd',
53
+ description: 'Show what would be updated without making changes',
54
+ type: 'boolean',
55
+ default_value: false,
56
+ },
57
+ {
58
+ name: 'json',
59
+ description: 'Output results as JSON',
60
+ type: 'boolean',
61
+ default_value: false,
62
+ },
63
+ {
64
+ name: 'substitution',
65
+ short: 's',
66
+ description: 'Only update specific substitution(s)',
67
+ type: 'string',
68
+ },
69
+ ],
70
+ handler: async (ctx) => {
71
+ const cluster_name = ctx.options['cluster'] as string;
72
+ const project_path = (ctx.options['project'] as string) || process.cwd();
73
+ const dry_run = ctx.options['dry-run'] as boolean;
74
+ const json_output = ctx.options['json'] as boolean;
75
+ const substitution_filter = ctx.options['substitution'] as string | undefined;
76
+
77
+ // Load project
78
+ const root_result = await find_project_root(project_path);
79
+ if (!is_success(root_result)) {
80
+ return root_result;
81
+ }
82
+
83
+ const project_result = await load_project(root_result.value);
84
+ if (!is_success(project_result)) {
85
+ return project_result;
86
+ }
87
+
88
+ const project = project_result.value;
89
+ const loaded_cluster = project.clusters.find((c) => c.cluster.metadata.name === cluster_name);
90
+
91
+ if (!loaded_cluster) {
92
+ console.error(`Cluster '${cluster_name}' not found`);
93
+ return failure({
94
+ code: 'NOT_FOUND',
95
+ message: `Cluster '${cluster_name}' not found`,
96
+ });
97
+ }
98
+
99
+ // Collect all version substitutions from templates enabled in this cluster
100
+ const version_subs: Array<{
101
+ template_name: string;
102
+ kustomization_name: string;
103
+ substitution: VersionSubstitutionType;
104
+ current_value: string | undefined;
105
+ }> = [];
106
+
107
+ for (const loaded_template of project.templates) {
108
+ const template = loaded_template.template;
109
+ const template_config = loaded_cluster.cluster.spec.templates?.find(
110
+ (t) => t.name === template.metadata.name,
111
+ );
112
+
113
+ // Skip disabled templates
114
+ if (template_config?.enabled === false) {
115
+ continue;
116
+ }
117
+
118
+ for (const kustomization of template.spec.kustomizations) {
119
+ for (const sub of kustomization.substitutions ?? []) {
120
+ if (is_version_substitution(sub)) {
121
+ if (substitution_filter && sub.name !== substitution_filter) {
122
+ continue;
123
+ }
124
+
125
+ version_subs.push({
126
+ template_name: template.metadata.name,
127
+ kustomization_name: kustomization.name,
128
+ substitution: sub,
129
+ current_value: template_config?.values?.[sub.name] ?? sub.default,
130
+ });
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ if (version_subs.length === 0) {
137
+ if (!json_output) {
138
+ console.log('No version substitutions found.');
139
+ } else {
140
+ console.log('[]');
141
+ }
142
+ return success(undefined);
143
+ }
144
+
145
+ if (!json_output) {
146
+ console.log(`Found ${version_subs.length} version substitution(s) to check\n`);
147
+ }
148
+
149
+ // Check each version substitution
150
+ const results: UpdateResultType[] = [];
151
+ const updates_to_apply: Map<string, Record<string, string>> = new Map();
152
+
153
+ for (const { template_name, substitution, current_value } of version_subs) {
154
+ const image_ref = parse_image_reference(substitution.registry.image);
155
+ const client = create_client_for_image(image_ref);
156
+
157
+ if (!json_output) {
158
+ console.log(`Checking ${substitution.name} (${substitution.registry.image})...`);
159
+ }
160
+
161
+ const tags_result = await client.list_tags(image_ref);
162
+ if (!is_success(tags_result)) {
163
+ if (!json_output) {
164
+ console.error(` Failed to fetch tags: ${tags_result.error.message}`);
165
+ }
166
+ continue;
167
+ }
168
+
169
+ const versions = filter_semver_tags(tags_result.value, {
170
+ exclude_prerelease: substitution.exclude_prerelease ?? true,
171
+ });
172
+
173
+ if (versions.length === 0) {
174
+ if (!json_output) {
175
+ console.log(' No valid semver tags found');
176
+ }
177
+ continue;
178
+ }
179
+
180
+ const current = current_value ?? substitution.default ?? '0.0.0';
181
+ const check = check_version_update(current, versions, substitution.constraint);
182
+
183
+ const result: UpdateResultType = {
184
+ cluster: cluster_name,
185
+ template: template_name,
186
+ substitution: substitution.name,
187
+ image: substitution.registry.image,
188
+ current: check.current_version,
189
+ latest: check.latest_version,
190
+ constraint: substitution.constraint,
191
+ updated: false,
192
+ };
193
+
194
+ if (check.has_update) {
195
+ result.updated = !dry_run;
196
+
197
+ if (!dry_run) {
198
+ // Queue update for this template
199
+ const existing = updates_to_apply.get(template_name) ?? {};
200
+ existing[substitution.name] = check.latest_version;
201
+ updates_to_apply.set(template_name, existing);
202
+ }
203
+
204
+ if (!json_output) {
205
+ const action = dry_run ? 'available' : 'will update';
206
+ console.log(` ${current} -> ${check.latest_version} (${action})`);
207
+ }
208
+ } else if (!json_output) {
209
+ console.log(` ${current} (up to date)`);
210
+ }
211
+
212
+ results.push(result);
213
+ }
214
+
215
+ // Apply updates to cluster.yaml
216
+ if (!dry_run && updates_to_apply.size > 0) {
217
+ const cluster_path = path.join(loaded_cluster.path, 'cluster.yaml');
218
+ const cluster_yaml_result = await read_yaml_file<Record<string, unknown>>(cluster_path);
219
+
220
+ if (!is_success(cluster_yaml_result)) {
221
+ return cluster_yaml_result;
222
+ }
223
+
224
+ const cluster_data = cluster_yaml_result.value;
225
+ const spec = cluster_data['spec'] as Record<string, unknown> | undefined;
226
+ const templates = (spec?.['templates'] as Array<Record<string, unknown>> | undefined) ?? [];
227
+
228
+ for (const [template_name, values] of updates_to_apply) {
229
+ const template_idx = templates.findIndex((t) => t['name'] === template_name);
230
+
231
+ if (template_idx >= 0) {
232
+ const template = templates[template_idx];
233
+ const existing_values = (template?.['values'] as Record<string, string>) ?? {};
234
+ if (template) {
235
+ template['values'] = { ...existing_values, ...values };
236
+ }
237
+ } else {
238
+ // Add new template config with values
239
+ templates.push({
240
+ name: template_name,
241
+ values,
242
+ });
243
+ }
244
+ }
245
+
246
+ // Ensure spec.templates exists
247
+ if (spec) {
248
+ spec['templates'] = templates;
249
+ }
250
+
251
+ const write_result = await write_yaml_file(cluster_path, cluster_data);
252
+ if (!is_success(write_result)) {
253
+ return write_result;
254
+ }
255
+
256
+ if (!json_output) {
257
+ console.log(`\nUpdated ${cluster_path}`);
258
+ }
259
+ }
260
+
261
+ // Output results
262
+ if (json_output) {
263
+ console.log(JSON.stringify(results, null, 2));
264
+ } else {
265
+ const update_count = results.filter(
266
+ (r) => r.updated || (dry_run && r.current !== r.latest),
267
+ ).length;
268
+ const action = dry_run ? 'available' : 'applied';
269
+ console.log(`\n${update_count} update(s) ${action}`);
270
+ }
271
+
272
+ return success(undefined);
273
+ },
274
+ });
@@ -0,0 +1,112 @@
1
+ import { failure, success } from '@kustodian/core';
2
+ import { validate_dependency_graph } from '@kustodian/generator';
3
+ import { find_project_root, load_project } from '@kustodian/loader';
4
+
5
+ import { define_command } from '../command.js';
6
+
7
+ /**
8
+ * Validate command - validates cluster and template configurations.
9
+ */
10
+ export const validate_command = define_command({
11
+ name: 'validate',
12
+ description: 'Validate cluster and template configurations',
13
+ options: [
14
+ {
15
+ name: 'cluster',
16
+ short: 'c',
17
+ description: 'Validate a specific cluster only',
18
+ type: 'string',
19
+ },
20
+ {
21
+ name: 'project',
22
+ short: 'p',
23
+ description: 'Path to project root (defaults to current directory)',
24
+ type: 'string',
25
+ },
26
+ ],
27
+ handler: async (ctx) => {
28
+ const project_path = (ctx.options['project'] as string) || process.cwd();
29
+ const cluster_filter = ctx.options['cluster'] as string | undefined;
30
+
31
+ // Find project root
32
+ console.log('Finding project root...');
33
+ const root_result = await find_project_root(project_path);
34
+ if (!root_result.success) {
35
+ console.error(`Error: ${root_result.error.message}`);
36
+ console.error(
37
+ 'Make sure you are in a Kustodian project directory with a kustodian.yaml file.',
38
+ );
39
+ return root_result;
40
+ }
41
+
42
+ const project_root = root_result.value;
43
+ console.log(`Project root: ${project_root}`);
44
+
45
+ // Load project
46
+ console.log('Loading project...');
47
+ const project_result = await load_project(project_root);
48
+ if (!project_result.success) {
49
+ console.error(`Validation failed: ${project_result.error.message}`);
50
+ return project_result;
51
+ }
52
+
53
+ const project = project_result.value;
54
+
55
+ // Report templates
56
+ console.log(`\nTemplates: ${project.templates.length} found`);
57
+ for (const t of project.templates) {
58
+ console.log(` ✓ ${t.template.metadata.name}`);
59
+ }
60
+
61
+ // Report clusters
62
+ const clusters = cluster_filter
63
+ ? project.clusters.filter((c) => c.cluster.metadata.name === cluster_filter)
64
+ : project.clusters;
65
+
66
+ if (cluster_filter && clusters.length === 0) {
67
+ console.error(`\nError: Cluster '${cluster_filter}' not found`);
68
+ return {
69
+ success: false as const,
70
+ error: { code: 'NOT_FOUND', message: `Cluster '${cluster_filter}' not found` },
71
+ };
72
+ }
73
+
74
+ console.log(`\nClusters: ${clusters.length} found`);
75
+ for (const c of clusters) {
76
+ console.log(` ✓ ${c.cluster.metadata.name}`);
77
+ if (c.cluster.spec.templates) {
78
+ for (const t of c.cluster.spec.templates) {
79
+ const status = t.enabled === false ? '(disabled)' : '';
80
+ console.log(` - ${t.name} ${status}`);
81
+ }
82
+ }
83
+ }
84
+
85
+ // Validate dependency graph
86
+ console.log('\nValidating dependency graph...');
87
+ const templates = project.templates.map((t) => t.template);
88
+ const graph_result = validate_dependency_graph(templates);
89
+
90
+ if (!graph_result.valid) {
91
+ console.error('\nDependency validation errors:');
92
+ for (const error of graph_result.errors) {
93
+ console.error(` ✗ ${error.message}`);
94
+ }
95
+ return failure({
96
+ code: 'DEPENDENCY_VALIDATION_ERROR',
97
+ message: 'Dependency validation failed',
98
+ });
99
+ }
100
+
101
+ // Show deployment order if there are dependencies
102
+ if (graph_result.topological_order && graph_result.topological_order.length > 0) {
103
+ console.log(`\nDeployment order (${graph_result.topological_order.length} kustomizations):`);
104
+ graph_result.topological_order.forEach((id, index) => {
105
+ console.log(` ${index + 1}. ${id}`);
106
+ });
107
+ }
108
+
109
+ console.log('\n✓ All configurations are valid');
110
+ return success(undefined);
111
+ },
112
+ });