@kustodian/cli 1.0.0 → 1.2.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/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # @kustodian/cli
2
+
3
+ Command-line interface for Kustodian - a GitOps templating framework for Kubernetes with Flux CD.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add -g @kustodian/cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ kustodian <command> [options]
15
+ ```
16
+
17
+ ## Commands
18
+
19
+ ### `init <name>`
20
+
21
+ Initialize a new Kustodian project with example templates and cluster configuration.
22
+
23
+ ```bash
24
+ kustodian init my-project
25
+ ```
26
+
27
+ Options:
28
+ - `--force, -f` - Overwrite existing files
29
+
30
+ ### `validate`
31
+
32
+ Validate cluster and template configurations, including dependency graph validation.
33
+
34
+ ```bash
35
+ kustodian validate
36
+ kustodian validate --cluster production
37
+ ```
38
+
39
+ Options:
40
+ - `--cluster, -c <name>` - Validate a specific cluster only
41
+ - `--project, -p <path>` - Path to project root (defaults to current directory)
42
+
43
+ ### `apply`
44
+
45
+ Apply full cluster configuration: bootstrap nodes, install Flux CD, and deploy templates.
46
+
47
+ ```bash
48
+ kustodian apply --cluster production
49
+ kustodian apply --cluster local --dry-run
50
+ ```
51
+
52
+ Options:
53
+ - `--cluster, -c <name>` - Cluster name to apply (required)
54
+ - `--provider, -P <name>` - Cluster provider for bootstrap (default: k0s)
55
+ - `--project, -p <path>` - Path to project root
56
+ - `--dry-run, -d` - Preview changes without applying
57
+ - `--skip-bootstrap` - Skip cluster bootstrap (use existing cluster)
58
+ - `--skip-flux` - Skip Flux CD installation
59
+ - `--skip-templates` - Skip template deployment
60
+
61
+ ### `update`
62
+
63
+ Check and update image version substitutions from container registries.
64
+
65
+ ```bash
66
+ kustodian update --cluster production
67
+ kustodian update --cluster production --dry-run
68
+ ```
69
+
70
+ Options:
71
+ - `--cluster, -c <name>` - Cluster to update values for (required)
72
+ - `--project, -p <path>` - Path to project root
73
+ - `--dry-run, -d` - Show what would be updated without making changes
74
+ - `--json` - Output results as JSON
75
+ - `--substitution, -s <name>` - Only update specific substitution(s)
76
+
77
+ ## Global Options
78
+
79
+ - `--help, -h` - Show help
80
+ - `--version, -v` - Show version
81
+
82
+ ## Links
83
+
84
+ - [Repository](https://github.com/lucasilverentand/kustodian)
85
+
86
+ ## License
87
+
88
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kustodian/cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "CLI framework with DI and middleware for Kustodian",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -37,14 +37,16 @@
37
37
  "registry": "https://npm.pkg.github.com"
38
38
  },
39
39
  "dependencies": {
40
- "@kustodian/core": "workspace:*",
41
- "@kustodian/generator": "workspace:*",
42
- "@kustodian/loader": "workspace:*",
43
- "@kustodian/nodes": "workspace:*",
44
- "@kustodian/plugin-k0s": "workspace:*",
45
- "@kustodian/plugins": "workspace:*",
46
- "@kustodian/registry": "workspace:*",
47
- "@kustodian/schema": "workspace:*",
48
- "ora": "^9.0.0"
40
+ "@kustodian/core": "1.1.0",
41
+ "@kustodian/generator": "1.2.0",
42
+ "@kustodian/loader": "1.1.0",
43
+ "@kustodian/nodes": "1.1.0",
44
+ "@kustodian/plugin-k0s": "1.1.0",
45
+ "@kustodian/plugins": "1.0.1",
46
+ "@kustodian/registry": "1.1.0",
47
+ "@kustodian/schema": "1.3.0",
48
+ "@kustodian/sources": "1.3.0",
49
+ "ora": "^9.0.0",
50
+ "yaml": "^2.8.2"
49
51
  }
50
52
  }
package/src/bin.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { apply_command } from './commands/apply.js';
4
4
  import { init_command } from './commands/init.js';
5
+ import { sources_command } from './commands/sources.js';
5
6
  import { update_command } from './commands/update.js';
6
7
  import { validate_command } from './commands/validate.js';
7
8
  import { create_container } from './container.js';
@@ -29,7 +30,8 @@ async function main() {
29
30
  console.log(
30
31
  ' apply Apply full cluster configuration (generates, pushes OCI, deploys)',
31
32
  );
32
- console.log(' update Check and update image version substitutions\n');
33
+ console.log(' update Check and update image version substitutions');
34
+ console.log(' sources Manage template sources (fetch, list, cache)\n');
33
35
  console.log('Options:');
34
36
  console.log(' --help, -h Show help');
35
37
  console.log(' --version, -v Show version\n');
@@ -52,6 +54,7 @@ async function main() {
52
54
  cli.command(validate_command);
53
55
  cli.command(apply_command);
54
56
  cli.command(update_command);
57
+ cli.command(sources_command);
55
58
 
56
59
  // Create container
57
60
  const container = create_container();
@@ -1,6 +1,7 @@
1
1
  import { exec } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { is_success, success } from '@kustodian/core';
4
+ import { validate_template_requirements } from '@kustodian/generator';
4
5
  import { find_project_root, load_project } from '@kustodian/loader';
5
6
  import type { NodeListType } from '@kustodian/nodes';
6
7
  import type { ClusterType } from '@kustodian/schema';
@@ -93,7 +94,7 @@ export const apply_command = define_command({
93
94
  console.log('\n[1/3] Loading project configuration...');
94
95
 
95
96
  const root_result = await find_project_root(project_path);
96
- if (!root_result.success) {
97
+ if (!is_success(root_result)) {
97
98
  console.error(` ✗ Error: ${root_result.error.message}`);
98
99
  return root_result;
99
100
  }
@@ -102,7 +103,7 @@ export const apply_command = define_command({
102
103
  console.log(` → Project root: ${project_root}`);
103
104
 
104
105
  const project_result = await load_project(project_root);
105
- if (!project_result.success) {
106
+ if (!is_success(project_result)) {
106
107
  console.error(` ✗ Error: ${project_result.error.message}`);
107
108
  return project_result;
108
109
  }
@@ -278,6 +279,38 @@ export const apply_command = define_command({
278
279
  if (!skip_templates) {
279
280
  console.log('\n[4/4] Deploying templates...');
280
281
 
282
+ // Validate template requirements
283
+ console.log(' → Validating template requirements...');
284
+ const enabled_template_refs =
285
+ loaded_cluster.cluster.spec.templates?.filter((t) => t.enabled !== false) || [];
286
+
287
+ if (enabled_template_refs.length > 0) {
288
+ const enabled_templates = project.templates
289
+ .filter((t) => enabled_template_refs.some((ref) => ref.name === t.template.metadata.name))
290
+ .map((t) => t.template);
291
+
292
+ const requirements_result = validate_template_requirements(
293
+ enabled_templates,
294
+ loaded_cluster.nodes,
295
+ );
296
+
297
+ if (!requirements_result.valid) {
298
+ console.error(' ✗ Template requirement validation failed:');
299
+ for (const error of requirements_result.errors) {
300
+ console.error(` - ${error.template}: ${error.message}`);
301
+ }
302
+ return {
303
+ success: false as const,
304
+ error: {
305
+ code: 'REQUIREMENT_VALIDATION_ERROR',
306
+ message: 'Template requirements not met',
307
+ },
308
+ };
309
+ }
310
+
311
+ console.log(' ✓ All template requirements satisfied');
312
+ }
313
+
281
314
  if (loaded_cluster.cluster.spec.oci) {
282
315
  // OCI Mode - generate in memory and apply directly
283
316
  console.log(' → Cluster uses OCI deployment');
@@ -296,7 +329,7 @@ export const apply_command = define_command({
296
329
  {},
297
330
  );
298
331
 
299
- if (!gen_result.success) {
332
+ if (!is_success(gen_result)) {
300
333
  console.error(` ✗ Generation failed: ${gen_result.error.message}`);
301
334
  return gen_result;
302
335
  }
@@ -248,7 +248,7 @@ jobs:
248
248
  - name: Install kustodian
249
249
  run: |
250
250
  # TODO: Replace with actual installation method
251
- # npm install -g @kustodian/cli
251
+ # bun install -g @kustodian/cli
252
252
  echo "Install kustodian CLI here"
253
253
 
254
254
  - name: Validate configuration
@@ -0,0 +1,439 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { failure, success } from '@kustodian/core';
4
+ import { find_project_root } from '@kustodian/loader';
5
+ import type { TemplateSourceType } from '@kustodian/schema';
6
+ import {
7
+ DEFAULT_CACHE_DIR,
8
+ create_cache_manager,
9
+ get_fetcher_for_source,
10
+ load_templates_from_sources,
11
+ } from '@kustodian/sources';
12
+ import { parse } from 'yaml';
13
+
14
+ import { define_command } from '../command.js';
15
+
16
+ /**
17
+ * Project configuration with template sources.
18
+ */
19
+ interface ProjectConfigType {
20
+ spec?: {
21
+ template_sources?: TemplateSourceType[];
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Reads template sources from project config.
27
+ */
28
+ async function get_template_sources(project_root: string): Promise<TemplateSourceType[]> {
29
+ const config_path = path.join(project_root, 'kustodian.yaml');
30
+ try {
31
+ const content = await fs.readFile(config_path, 'utf-8');
32
+ const config = parse(content) as ProjectConfigType;
33
+ return config?.spec?.template_sources ?? [];
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Formats bytes to human-readable size.
41
+ */
42
+ function format_bytes(bytes: number): string {
43
+ if (bytes < 1024) return `${bytes} B`;
44
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
45
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
46
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
47
+ }
48
+
49
+ /**
50
+ * Sources command - manage template sources.
51
+ */
52
+ export const sources_command = define_command({
53
+ name: 'sources',
54
+ description: 'Manage template sources',
55
+ subcommands: [
56
+ // sources fetch
57
+ {
58
+ name: 'fetch',
59
+ description: 'Fetch or update template sources',
60
+ options: [
61
+ {
62
+ name: 'force',
63
+ short: 'f',
64
+ description: 'Force refresh all sources (ignore cache)',
65
+ type: 'boolean',
66
+ },
67
+ {
68
+ name: 'source',
69
+ short: 's',
70
+ description: 'Fetch a specific source only',
71
+ type: 'string',
72
+ },
73
+ {
74
+ name: 'project',
75
+ short: 'p',
76
+ description: 'Path to project root',
77
+ type: 'string',
78
+ },
79
+ ],
80
+ handler: async (ctx) => {
81
+ const project_path = (ctx.options['project'] as string) || process.cwd();
82
+ const force_refresh = ctx.options['force'] as boolean;
83
+ const source_filter = ctx.options['source'] as string | undefined;
84
+
85
+ // Find project root
86
+ const root_result = await find_project_root(project_path);
87
+ if (!root_result.success) {
88
+ console.error(`Error: ${root_result.error.message}`);
89
+ return root_result;
90
+ }
91
+
92
+ const project_root = root_result.value;
93
+
94
+ // Get template sources from project config
95
+ let sources = await get_template_sources(project_root);
96
+
97
+ if (sources.length === 0) {
98
+ console.log('No template sources configured in kustodian.yaml');
99
+ return success(undefined);
100
+ }
101
+
102
+ // Filter if specific source requested
103
+ if (source_filter) {
104
+ sources = sources.filter((s) => s.name === source_filter);
105
+ if (sources.length === 0) {
106
+ console.error(`Source '${source_filter}' not found in configuration`);
107
+ return failure({ code: 'NOT_FOUND', message: `Source '${source_filter}' not found` });
108
+ }
109
+ }
110
+
111
+ console.log(`Fetching ${sources.length} template source(s)...`);
112
+ if (force_refresh) {
113
+ console.log('(force refresh enabled)\n');
114
+ } else {
115
+ console.log('');
116
+ }
117
+
118
+ const cache_dir = path.join(project_root, DEFAULT_CACHE_DIR);
119
+ const result = await load_templates_from_sources(sources, {
120
+ cache_dir,
121
+ force_refresh,
122
+ });
123
+
124
+ if (!result.success) {
125
+ console.error(`\nError: ${result.error.message}`);
126
+ return result;
127
+ }
128
+
129
+ // Report results
130
+ for (const resolved of result.value.resolved) {
131
+ const status = resolved.fetch_result.from_cache ? '(cached)' : '(fetched)';
132
+ console.log(` ✓ ${resolved.source.name} @ ${resolved.fetch_result.version} ${status}`);
133
+ }
134
+
135
+ console.log(`\n✓ Loaded ${result.value.templates.length} template(s) from sources`);
136
+ return success(undefined);
137
+ },
138
+ },
139
+
140
+ // sources list
141
+ {
142
+ name: 'list',
143
+ description: 'List configured template sources',
144
+ options: [
145
+ {
146
+ name: 'cached',
147
+ short: 'c',
148
+ description: 'Show cached sources only',
149
+ type: 'boolean',
150
+ },
151
+ {
152
+ name: 'project',
153
+ short: 'p',
154
+ description: 'Path to project root',
155
+ type: 'string',
156
+ },
157
+ ],
158
+ handler: async (ctx) => {
159
+ const project_path = (ctx.options['project'] as string) || process.cwd();
160
+ const show_cached = ctx.options['cached'] as boolean;
161
+
162
+ // Find project root
163
+ const root_result = await find_project_root(project_path);
164
+ if (!root_result.success) {
165
+ console.error(`Error: ${root_result.error.message}`);
166
+ return root_result;
167
+ }
168
+
169
+ const project_root = root_result.value;
170
+ const cache_dir = path.join(project_root, DEFAULT_CACHE_DIR);
171
+
172
+ if (show_cached) {
173
+ // Show cached sources
174
+ const cache = create_cache_manager(cache_dir);
175
+ const entries_result = await cache.list();
176
+
177
+ if (!entries_result.success) {
178
+ console.error(`Error: ${entries_result.error.message}`);
179
+ return entries_result;
180
+ }
181
+
182
+ const entries = entries_result.value;
183
+ if (entries.length === 0) {
184
+ console.log('No cached sources');
185
+ return success(undefined);
186
+ }
187
+
188
+ console.log('Cached sources:\n');
189
+ for (const entry of entries) {
190
+ const expired = entry.expires_at && entry.expires_at < new Date() ? ' (expired)' : '';
191
+ const mutable = entry.expires_at ? '(mutable)' : '(immutable)';
192
+ console.log(` ${entry.source_name} @ ${entry.version}`);
193
+ console.log(` Type: ${entry.source_type} ${mutable}${expired}`);
194
+ console.log(` Fetched: ${entry.fetched_at.toISOString()}`);
195
+ console.log('');
196
+ }
197
+ } else {
198
+ // Show configured sources
199
+ const sources = await get_template_sources(project_root);
200
+
201
+ if (sources.length === 0) {
202
+ console.log('No template sources configured');
203
+ return success(undefined);
204
+ }
205
+
206
+ console.log('Configured sources:\n');
207
+ for (const source of sources) {
208
+ const type = source.git ? 'git' : source.http ? 'http' : source.oci ? 'oci' : 'unknown';
209
+ console.log(` ${source.name} (${type})`);
210
+
211
+ if (source.git) {
212
+ const ref = source.git.ref.tag ?? source.git.ref.branch ?? source.git.ref.commit;
213
+ console.log(` URL: ${source.git.url}`);
214
+ console.log(` Ref: ${ref}`);
215
+ if (source.git.path) console.log(` Path: ${source.git.path}`);
216
+ }
217
+ if (source.http) {
218
+ console.log(` URL: ${source.http.url}`);
219
+ if (source.http.checksum) console.log(` Checksum: ${source.http.checksum}`);
220
+ }
221
+ if (source.oci) {
222
+ const ref = source.oci.digest ?? source.oci.tag;
223
+ console.log(` Registry: ${source.oci.registry}/${source.oci.repository}`);
224
+ console.log(` Tag: ${ref}`);
225
+ }
226
+ if (source.ttl) console.log(` TTL: ${source.ttl}`);
227
+ console.log('');
228
+ }
229
+ }
230
+
231
+ return success(undefined);
232
+ },
233
+ },
234
+
235
+ // sources cache
236
+ {
237
+ name: 'cache',
238
+ description: 'Manage template cache',
239
+ subcommands: [
240
+ // sources cache info
241
+ {
242
+ name: 'info',
243
+ description: 'Show cache statistics',
244
+ options: [
245
+ {
246
+ name: 'project',
247
+ short: 'p',
248
+ description: 'Path to project root',
249
+ type: 'string',
250
+ },
251
+ ],
252
+ handler: async (ctx) => {
253
+ const project_path = (ctx.options['project'] as string) || process.cwd();
254
+
255
+ const root_result = await find_project_root(project_path);
256
+ if (!root_result.success) {
257
+ console.error(`Error: ${root_result.error.message}`);
258
+ return root_result;
259
+ }
260
+
261
+ const project_root = root_result.value;
262
+ const cache_dir = path.join(project_root, DEFAULT_CACHE_DIR);
263
+ const cache = create_cache_manager(cache_dir);
264
+
265
+ const entries_result = await cache.list();
266
+ const size_result = await cache.size();
267
+
268
+ if (!entries_result.success || !size_result.success) {
269
+ console.log('Cache directory not found or empty');
270
+ return success(undefined);
271
+ }
272
+
273
+ const entries = entries_result.value;
274
+ const total_size = size_result.value;
275
+ const expired = entries.filter((e) => e.expires_at && e.expires_at < new Date()).length;
276
+
277
+ console.log('Cache statistics:\n');
278
+ console.log(` Location: ${cache_dir}`);
279
+ console.log(` Total entries: ${entries.length}`);
280
+ console.log(` Expired entries: ${expired}`);
281
+ console.log(` Total size: ${format_bytes(total_size)}`);
282
+
283
+ return success(undefined);
284
+ },
285
+ },
286
+
287
+ // sources cache prune
288
+ {
289
+ name: 'prune',
290
+ description: 'Remove expired cache entries',
291
+ options: [
292
+ {
293
+ name: 'project',
294
+ short: 'p',
295
+ description: 'Path to project root',
296
+ type: 'string',
297
+ },
298
+ ],
299
+ handler: async (ctx) => {
300
+ const project_path = (ctx.options['project'] as string) || process.cwd();
301
+
302
+ const root_result = await find_project_root(project_path);
303
+ if (!root_result.success) {
304
+ console.error(`Error: ${root_result.error.message}`);
305
+ return root_result;
306
+ }
307
+
308
+ const project_root = root_result.value;
309
+ const cache_dir = path.join(project_root, DEFAULT_CACHE_DIR);
310
+ const cache = create_cache_manager(cache_dir);
311
+
312
+ console.log('Pruning expired cache entries...');
313
+ const result = await cache.prune();
314
+
315
+ if (!result.success) {
316
+ console.error(`Error: ${result.error.message}`);
317
+ return result;
318
+ }
319
+
320
+ console.log(`✓ Removed ${result.value} expired entries`);
321
+ return success(undefined);
322
+ },
323
+ },
324
+
325
+ // sources cache clear
326
+ {
327
+ name: 'clear',
328
+ description: 'Clear all cached templates',
329
+ options: [
330
+ {
331
+ name: 'project',
332
+ short: 'p',
333
+ description: 'Path to project root',
334
+ type: 'string',
335
+ },
336
+ ],
337
+ handler: async (ctx) => {
338
+ const project_path = (ctx.options['project'] as string) || process.cwd();
339
+
340
+ const root_result = await find_project_root(project_path);
341
+ if (!root_result.success) {
342
+ console.error(`Error: ${root_result.error.message}`);
343
+ return root_result;
344
+ }
345
+
346
+ const project_root = root_result.value;
347
+ const cache_dir = path.join(project_root, DEFAULT_CACHE_DIR);
348
+ const cache = create_cache_manager(cache_dir);
349
+
350
+ console.log('Clearing template cache...');
351
+ const result = await cache.clear();
352
+
353
+ if (!result.success) {
354
+ console.error(`Error: ${result.error.message}`);
355
+ return result;
356
+ }
357
+
358
+ console.log('✓ Cache cleared');
359
+ return success(undefined);
360
+ },
361
+ },
362
+ ],
363
+ },
364
+
365
+ // sources versions
366
+ {
367
+ name: 'versions',
368
+ description: 'List available versions for a source',
369
+ arguments: [
370
+ {
371
+ name: 'source',
372
+ description: 'Source name',
373
+ required: true,
374
+ },
375
+ ],
376
+ options: [
377
+ {
378
+ name: 'project',
379
+ short: 'p',
380
+ description: 'Path to project root',
381
+ type: 'string',
382
+ },
383
+ ],
384
+ handler: async (ctx) => {
385
+ const project_path = (ctx.options['project'] as string) || process.cwd();
386
+ const source_name = ctx.args[0] as string | undefined;
387
+
388
+ if (!source_name) {
389
+ console.error('Error: Source name is required');
390
+ return failure({ code: 'INVALID_ARGUMENT', message: 'Source name is required' });
391
+ }
392
+
393
+ const root_result = await find_project_root(project_path);
394
+ if (!root_result.success) {
395
+ console.error(`Error: ${root_result.error.message}`);
396
+ return root_result;
397
+ }
398
+
399
+ const project_root = root_result.value;
400
+ const sources = await get_template_sources(project_root);
401
+ const source = sources.find((s) => s.name === source_name);
402
+
403
+ if (!source) {
404
+ console.error(`Source '${source_name}' not found in configuration`);
405
+ return failure({ code: 'NOT_FOUND', message: `Source '${source_name}' not found` });
406
+ }
407
+
408
+ const fetcher = get_fetcher_for_source(source);
409
+
410
+ console.log(`Fetching versions for '${source_name}'...`);
411
+ const versions_result = await fetcher.list_versions(source);
412
+
413
+ if (!versions_result.success) {
414
+ console.error(`Error: ${versions_result.error.message}`);
415
+ return versions_result;
416
+ }
417
+
418
+ const versions = versions_result.value;
419
+ if (versions.length === 0) {
420
+ console.log('No versions found (this source type may not support version listing)');
421
+ return success(undefined);
422
+ }
423
+
424
+ console.log(`\nAvailable versions (${versions.length}):\n`);
425
+ for (const v of versions.slice(0, 50)) {
426
+ // Limit to 50
427
+ const digest = v.digest ? ` (${v.digest.slice(0, 12)})` : '';
428
+ console.log(` ${v.version}${digest}`);
429
+ }
430
+
431
+ if (versions.length > 50) {
432
+ console.log(` ... and ${versions.length - 50} more`);
433
+ }
434
+
435
+ return success(undefined);
436
+ },
437
+ },
438
+ ],
439
+ });
@@ -1,15 +1,25 @@
1
1
  import * as path from 'node:path';
2
2
 
3
- import { failure, is_success, success } from '@kustodian/core';
3
+ import { type ResultType, failure, is_success, success } from '@kustodian/core';
4
+ import type { KustodianErrorType } from '@kustodian/core';
4
5
  import { read_yaml_file, write_yaml_file } from '@kustodian/loader';
5
6
  import { find_project_root, load_project } from '@kustodian/loader';
6
7
  import {
8
+ type ImageReferenceType,
9
+ type RegistryClientType,
10
+ type TagInfoType,
7
11
  check_version_update,
8
12
  create_client_for_image,
13
+ create_helm_client,
9
14
  filter_semver_tags,
10
15
  parse_image_reference,
11
16
  } from '@kustodian/registry';
12
- import { type VersionSubstitutionType, is_version_substitution } from '@kustodian/schema';
17
+ import {
18
+ type HelmSubstitutionType,
19
+ type VersionSubstitutionType,
20
+ is_helm_substitution,
21
+ is_version_substitution,
22
+ } from '@kustodian/schema';
13
23
 
14
24
  import { define_command } from '../command.js';
15
25
 
@@ -20,7 +30,8 @@ interface UpdateResultType {
20
30
  cluster: string;
21
31
  template: string;
22
32
  substitution: string;
23
- image: string;
33
+ source: string; // Image name or Helm chart reference
34
+ source_type: 'image' | 'helm';
24
35
  current: string;
25
36
  latest: string;
26
37
  constraint?: string | undefined;
@@ -96,12 +107,13 @@ export const update_command = define_command({
96
107
  });
97
108
  }
98
109
 
99
- // Collect all version substitutions from templates enabled in this cluster
110
+ // Collect all version and helm substitutions from templates enabled in this cluster
100
111
  const version_subs: Array<{
101
112
  template_name: string;
102
113
  kustomization_name: string;
103
- substitution: VersionSubstitutionType;
114
+ substitution: VersionSubstitutionType | HelmSubstitutionType;
104
115
  current_value: string | undefined;
116
+ type: 'version' | 'helm';
105
117
  }> = [];
106
118
 
107
119
  for (const loaded_template of project.templates) {
@@ -127,6 +139,19 @@ export const update_command = define_command({
127
139
  kustomization_name: kustomization.name,
128
140
  substitution: sub,
129
141
  current_value: template_config?.values?.[sub.name] ?? sub.default,
142
+ type: 'version',
143
+ });
144
+ } else if (is_helm_substitution(sub)) {
145
+ if (substitution_filter && sub.name !== substitution_filter) {
146
+ continue;
147
+ }
148
+
149
+ version_subs.push({
150
+ template_name: template.metadata.name,
151
+ kustomization_name: kustomization.name,
152
+ substitution: sub,
153
+ current_value: template_config?.values?.[sub.name] ?? sub.default,
154
+ type: 'helm',
130
155
  });
131
156
  }
132
157
  }
@@ -146,19 +171,53 @@ export const update_command = define_command({
146
171
  console.log(`Found ${version_subs.length} version substitution(s) to check\n`);
147
172
  }
148
173
 
149
- // Check each version substitution
174
+ // Check each version and helm substitution
150
175
  const results: UpdateResultType[] = [];
151
176
  const updates_to_apply: Map<string, Record<string, string>> = new Map();
152
177
 
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);
178
+ for (const { template_name, substitution, current_value, type } of version_subs) {
179
+ let source_name: string;
180
+ let client: RegistryClientType;
181
+
182
+ if (type === 'version') {
183
+ const version_sub = substitution as VersionSubstitutionType;
184
+ const image_ref = parse_image_reference(version_sub.registry.image);
185
+ source_name = version_sub.registry.image;
186
+ client = create_client_for_image(image_ref);
187
+ } else {
188
+ // helm type
189
+ const helm_sub = substitution as HelmSubstitutionType;
190
+ source_name = helm_sub.helm.oci || helm_sub.helm.repository || '';
191
+ source_name = `${source_name}/${helm_sub.helm.chart}`;
192
+ // Create helm config object to satisfy exactOptionalPropertyTypes
193
+ const helm_config: { repository?: string; oci?: string; chart: string } = {
194
+ chart: helm_sub.helm.chart,
195
+ };
196
+ if (helm_sub.helm.repository) {
197
+ helm_config.repository = helm_sub.helm.repository;
198
+ }
199
+ if (helm_sub.helm.oci) {
200
+ helm_config.oci = helm_sub.helm.oci;
201
+ }
202
+ client = create_helm_client(helm_config);
203
+ }
156
204
 
157
205
  if (!json_output) {
158
- console.log(`Checking ${substitution.name} (${substitution.registry.image})...`);
206
+ console.log(`Checking ${substitution.name} (${source_name})...`);
159
207
  }
160
208
 
161
- const tags_result = await client.list_tags(image_ref);
209
+ // For version type, we need to pass the image reference
210
+ // For helm type, the parameter is optional and ignored
211
+ let tags_result: ResultType<TagInfoType[], KustodianErrorType>;
212
+ if (type === 'version') {
213
+ tags_result = await client.list_tags(parse_image_reference(source_name));
214
+ } else {
215
+ // Helm client - the list_tags parameter is optional for helm clients
216
+ type ListTagsFn = (
217
+ ref?: ImageReferenceType,
218
+ ) => Promise<ResultType<TagInfoType[], KustodianErrorType>>;
219
+ tags_result = await (client.list_tags as ListTagsFn)(undefined);
220
+ }
162
221
  if (!is_success(tags_result)) {
163
222
  if (!json_output) {
164
223
  console.error(` Failed to fetch tags: ${tags_result.error.message}`);
@@ -184,7 +243,8 @@ export const update_command = define_command({
184
243
  cluster: cluster_name,
185
244
  template: template_name,
186
245
  substitution: substitution.name,
187
- image: substitution.registry.image,
246
+ source: source_name,
247
+ source_type: type === 'version' ? 'image' : 'helm',
188
248
  current: check.current_version,
189
249
  latest: check.latest_version,
190
250
  constraint: substitution.constraint,
@@ -1,5 +1,5 @@
1
1
  import { failure, success } from '@kustodian/core';
2
- import { validate_dependency_graph } from '@kustodian/generator';
2
+ import { validate_dependency_graph, validate_template_requirements } from '@kustodian/generator';
3
3
  import { find_project_root, load_project } from '@kustodian/loader';
4
4
 
5
5
  import { define_command } from '../command.js';
@@ -82,6 +82,46 @@ export const validate_command = define_command({
82
82
  }
83
83
  }
84
84
 
85
+ // Validate template requirements for each cluster
86
+ console.log('\nValidating template requirements...');
87
+ let has_requirement_errors = false;
88
+
89
+ for (const cluster_data of clusters) {
90
+ const cluster_name = cluster_data.cluster.metadata.name;
91
+ const enabled_template_refs =
92
+ cluster_data.cluster.spec.templates?.filter((t) => t.enabled !== false) || [];
93
+
94
+ if (enabled_template_refs.length === 0) {
95
+ continue;
96
+ }
97
+
98
+ // Get enabled templates
99
+ const enabled_templates = project.templates
100
+ .filter((t) => enabled_template_refs.some((ref) => ref.name === t.template.metadata.name))
101
+ .map((t) => t.template);
102
+
103
+ // Validate requirements
104
+ const requirements_result = validate_template_requirements(
105
+ enabled_templates,
106
+ cluster_data.nodes,
107
+ );
108
+
109
+ if (!requirements_result.valid) {
110
+ has_requirement_errors = true;
111
+ console.error(`\nRequirement validation errors for cluster '${cluster_name}':`);
112
+ for (const error of requirements_result.errors) {
113
+ console.error(` ✗ ${error.template}: ${error.message}`);
114
+ }
115
+ }
116
+ }
117
+
118
+ if (has_requirement_errors) {
119
+ return failure({
120
+ code: 'REQUIREMENT_VALIDATION_ERROR',
121
+ message: 'Template requirement validation failed',
122
+ });
123
+ }
124
+
85
125
  // Validate dependency graph
86
126
  console.log('\nValidating dependency graph...');
87
127
  const templates = project.templates.map((t) => t.template);